@canonical/jujulib
Version:
Juju API client
378 lines (356 loc) • 11.4 kB
text/typescript
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();
}