UNPKG

@canonical/jujulib

Version:

Juju API client

378 lines (356 loc) 11.4 kB
import { execSync } from "child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "fs"; import glob from "glob"; import { join, resolve } from "path"; import { FacadeMethod, FacadeTemplate, FileInfo, InterfaceData, InterfaceType, InterfaceValueType, ReadmeTemplate, } from "./interfaces.js"; import generateFacadeIndexTemplate from "./templates/facade-index.js"; import generateFacadeTemplate from "./templates/facade.js"; import readmeTemplateGenerator from "./templates/readme.js"; import { DefinitionProperties, Facade, FacadeList, JSONSchemaType, SchemaDefinition, SchemaDefinitions, SchemaMethod, SchemaMethods, SchemaProperties, } from "./templates/types.js"; import { attributeOverrides, definitionsOverrides, methodOverrides, } from "./overrides.js"; export function generator() { // if present, only generate the README and use links to docs instead of Github repo const onlyReadmeForDocs = Boolean(process.env.README_FOR_DOCS); if (!onlyReadmeForDocs) generateFacadeFiles(); const facadesGroupedByName: FacadeList = {}; type ExistingFacade = { folder: string; name: string; version: number; }; const allExistingFacades: ExistingFacade[] = glob .sync("./api/facades/*/*V[0-9]*.ts") .map((f: string) => f.split("/")) .map((f: string[]) => { // e.g. ClientV5.ts const filename = f[f.length - 1].match( /(?<name>[a-z-]+)V(?<version>\d+)\.ts/i )!.groups!; return { folder: f[f.length - 2], ...filename }; }) as ExistingFacade[]; allExistingFacades.forEach((facade) => { if (!facadesGroupedByName[facade.name]) { facadesGroupedByName[facade.name] = []; } facadesGroupedByName[facade.name].push(facade.version); }); if (!onlyReadmeForDocs) generateFacadeIndexTemplate(facadesGroupedByName); const clientAPIInfo: string = execSync( "./node_modules/.bin/documentation build api/client.ts --document-exported --shallow --markdown-toc false -f md", { encoding: "utf8" } ); const facadeList: { [key: string]: FileInfo[]; } = {}; Object.keys(facadesGroupedByName).forEach((facadeName) => { facadeList[facadeName] = facadesGroupedByName[facadeName].map( (FacadeVersion) => ({ name: `${facadeName}V${FacadeVersion}.ts`, path: `/api/facades/${facadeFolderName( facadeName )}/${facadeName}V${FacadeVersion}.ts`, }) ); }); const readmeTemplateData: ReadmeTemplate = { clientAPIInfo, exampleList: readdirSync("examples").map((f) => ({ name: f, path: `examples/${f}`, })), facadeList, }; generateReadmeFile(readmeTemplateData, onlyReadmeForDocs); } function getRefString(ref: string): string { const parts = ref.split("/"); return parts[parts.length - 1]; } function extractType( method: SchemaMethod, segment: keyof SchemaProperties ): string | undefined { if (method.properties?.[segment]) { const ref = method.properties[segment]?.["$ref"]; const type = method.properties[segment]?.["type"]; if (ref) { return getRefString(ref); } return type; } return undefined; } function generateFacadeFiles() { const schemaLocation: string = process.env.SCHEMA || ""; const jujuVersion: string = process.env.JUJU_VERSION || ""; const jujuGitSHA: string = process.env.JUJU_GIT_SHA || ""; let schema: Array<Facade>; try { const schemaData: string = readFileSync(resolve(schemaLocation), { encoding: "utf8", }); schema = JSON.parse(schemaData); } catch (e) { console.error("Unable to parse schema", e); process.exit(1); } schema.forEach(async (facade) => { const facadeTemplateData: FacadeTemplate = { name: facade.Name, version: facade.Version, methods: generateMethods(facade.Schema.properties, facade.Name), interfaces: generateInterfaces(facade.Schema.definitions, facade.Name), availableTo: facade.AvailableTo, docBlock: facade.Description, jujuVersion, jujuGitSHA, }; generateFile(facadeTemplateData); }); } /** Generate the list of methods available for the facade. While the API may expose methods, the actual data sent over the wire is an RPC call. */ export function generateMethods( methods: SchemaMethods, facadeName: string ): FacadeMethod[] { if (!methods) { return []; } const facadeMethods: FacadeMethod[] = Object.entries(methods).map( ([name, method]) => { const generatedMethod: FacadeMethod = { name, params: methodOverrides[facadeName]?.[name]?.Params || extractType(method, "Params") || "any", result: methodOverrides[facadeName]?.[name]?.Result || extractType(method, "Result") || "any", docBlock: method.description, }; if (methodOverrides[facadeName]?.[name]?.paramsAtTop) { generatedMethod.paramsAtTop = true; } return generatedMethod; } ); return facadeMethods; } export function generateInterfaces( definitions: SchemaDefinitions, facadeName: string ): InterfaceData[] { if (!definitions) { return []; } const interfaces = Object.entries(definitions).map(([name, definition]) => { return generateInterface([ name, definitionsOverrides[facadeName]?.[name] ?? definition, ]); }); interfaces.push( generateInterface([ "AdditionalProperties", { properties: { "[key: string]": { type: "any" } }, type: "object" }, ]) ); return interfaces; } function generateInterface([name, definition]: [ string, SchemaDefinition ]): InterfaceData { let types: InterfaceType[]; if (definition.properties) { types = generateTypes(definition.properties, definition.required || []); } else { types = [ { name: "[key: string]", type: "AdditionalProperties", required: false, }, ]; } return { name, types, }; } export function generateTypes( properties: DefinitionProperties, required: string[] ): InterfaceType[] { function extractType(values: JSONSchemaType): InterfaceValueType { if (values.type) { if (values.type === "object") { if (values.patternProperties) { const regex = Object.keys(values.patternProperties)?.[0]; // Handle the pattern properties if the regex is matching all keys. if ( Object.keys(values.patternProperties).length === 1 && regex === ".*" ) { const patternProperty = values.patternProperties[regex]; // If pattern is a ref then use that for the object value type. if (patternProperty["$ref"]) { return { type: "object", valueType: getRefString(patternProperty["$ref"]), }; } const valueType = extractType(patternProperty); // If the pattern is additionalProperties then use that as the // object type. if (valueType === "AdditionalProperties") { return valueType; } // If the pattern has an explicit primitive type then use that for // the object value type. if (patternProperty.type) return { type: "object", valueType, }; } // Return unknown if the schema doesn't match what we expect. This // shouldn't occur. return "unknown"; } if (values.additionalProperties) { // There are additional unknown properties defined. return "AdditionalProperties"; } } // TODO: Recirsify this conditional. if (values.type === "array" && values.items) { if (values.items["$ref"]) { return `${getRefString(values.items["$ref"])}[]`; } if (values.items.type === "integer") { values.items.type = "number"; } else if (values.items.type === "array" && values.items.items) { // multi-dimensional array if (values.items.items["$ref"]) { return `${getRefString(values.items.items["$ref"])}[][]`; } return "[]"; } return `${values.items.type}[]`; } if (values.type === "integer") { return "number"; } return values.type; } if (values["$ref"]) { return getRefString(values["$ref"]); } return "any"; // If we don't know the type then type it as any. } function isRequired(requiredArgs: string[], propertyName: string): boolean { if (!requiredArgs.length) { // If requiredArgs doesn't exist then all properties will be not required. return false; } return requiredArgs.includes(propertyName); } return Object.entries(properties).map(([name, property]) => { let valueType: string; const extractedType = extractType(property); const hasOverride = name in attributeOverrides; if (hasOverride) { valueType = attributeOverrides[name]; } else { valueType = typeof extractedType === "object" ? extractedType.type : extractedType; } const interfaceType: InterfaceType = { name, type: valueType, required: isRequired(required, name), }; if (!hasOverride && typeof extractedType === "object") { interfaceType.valueType = extractedType.valueType; } return interfaceType; }); } function generateFile(facadeTemplateData: FacadeTemplate): void { const output: string = generateFacadeTemplate(facadeTemplateData); const filename = `${facadeTemplateData.name}V${facadeTemplateData.version}`; const facadeFoldername = facadeFolderName(facadeTemplateData.name); const outputFolder = `api/facades/${facadeFoldername}/`; mkdirSync(outputFolder, { recursive: true }); const newFacadeFilePath = join(outputFolder, `${filename}.ts`); if ( process.env.OVERWRITE_SCHEMAS === "true" || !existsSync(newFacadeFilePath) ) writeFileSync(join(outputFolder, `${filename}.ts`), output); } function generateReadmeFile( readmeTemplateData: ReadmeTemplate, onlyReadmeForDocs: boolean ): void { if (onlyReadmeForDocs) { readmeTemplateData.exampleList.forEach((example) => { // instead of relative path for docs page which will 404 // return the example page on the Github repo example.path = `https://github.com/juju/js-libjuju/blob/main/${example.path}`; }); Object.entries(readmeTemplateData.facadeList).forEach( ([facadeName, facade]) => facade.forEach((facadeVersion) => { // instead of the default (404) /api/facades/action-pruner/v1.ts // return https://juju.github.io/js-libjuju/modules/facades_action_pruner_ActionPrunerV1.html facadeVersion.path = `https://juju.github.io/js-libjuju/modules/facades_${facadeFolderName( facadeName ).replace(/-/g, "_")}_${facadeVersion.name.split(".")[0]}.html`; }) ); } const output: string = readmeTemplateGenerator(readmeTemplateData); writeFileSync("README.md", output); } export function facadeFolderName(facadeName: string) { // from CamelCase to kebab-case return facadeName .replace(/\W+/g, "-") .replace(/([a-z\d])([A-Z])/g, "$1-$2") .toLowerCase(); }