/// import { writeFileSync } from "fs"; import mkdirp from "mkdirp"; import fetch from "node-fetch"; import * as path from "path"; /* Open API TypeScript Client Generator This is a bespoke Open API client generator not intended for general public use. It is not designed to handle the full OpenAPI spec. Many other more general purpose generators exist, but their output is very verbose and overly complex for our use case. But it does work well enough to generate a fully typed tree-shakeable client for the Cosmos resource provider. Results of this file should be checked into the repo. */ // Array of strings to use for eventual output const outputTypes: string[] = [""]; const version = "2020-04-01"; const schemaURL = `https://raw.githubusercontent.com/Azure/azure-rest-api-specs/master/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/stable/${version}/cosmos-db.json`; const outputDir = path.join(__dirname, `../../src/Utils/arm/generatedClients/${version}`); mkdirp.sync(outputDir); // Buckets for grouping operations based on their name interface Client { paths: string[]; functions: string[]; constructorParams: string[]; } const clients: { [key: string]: Client } = {}; // Mapping for OpenAPI types to TypeScript types const propertyMap: { [key: string]: string } = { integer: "number" }; // Converts a Open API reference: "#/definitions/Foo" to a type name: Foo function refToType(path: string | undefined, namespace?: string) { // References must be in the same file. Bail to `unknown` types for remote references if (path && path.startsWith("#")) { const type = path.split("/").pop(); return namespace ? `${namespace}.${type}` : type; } return "unknown"; } // Converts "Something_Foo" -> "somethingFoo" function camelize(str: string) { return str .replace(/(?:^\w|[A-Z]|\b\w)/g, (word: string, index: number) => { return index === 0 ? word.toLowerCase() : word.toUpperCase(); }) .replace(/\s+/g, ""); } // Converts a body paramter to the equivalent typescript function parameter type function bodyParam(parameter: { schema: { $ref: string } }, namespace: string) { if (!parameter) { return ""; } return `,body: ${refToType(parameter.schema.$ref, namespace)}`; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function parametersFromPath(path: string) { // TODO: Remove any. String.matchAll is a real thing. // eslint-disable-next-line @typescript-eslint/no-explicit-any const matches = (path as any).matchAll(/{(\w+)}/g); return Array.from(matches).map((match: string[]) => match[1]); } type Operation = { responses: { [key: string]: { schema: { $ref: string } } } }; // Converts OpenAPI response definition to TypeScript return type. Uses unions if possible. Bails to unknown function responseType(operation: Operation, namespace: string) { if (operation.responses) { return Object.keys(operation.responses) .map((responseCode: string) => { if (!operation.responses[responseCode].schema) { return "void"; } return refToType(operation.responses[responseCode].schema.$ref, namespace); }) .filter((value, index, array) => array.indexOf(value) === index) .join(" | "); } return "unknown"; } interface Property { $ref?: string; description?: string; readOnly?: boolean; type: "array" | "object" | "unknown"; properties: Property[]; items?: { $ref: string; }; allOf?: { $ref: string; }[]; } const propertyToType = (property: Property, prop: string) => { if (property) { if (property.allOf) { outputTypes.push(` /* ${property.description || "undocumented"} */ ${property.readOnly ? "readonly " : ""}${prop}: ${property.allOf .map((allof: { $ref: string }) => refToType(allof.$ref)) .join(" & ")}`); } else if (property.$ref) { const type = refToType(property.$ref); outputTypes.push(` /* ${property.description || "undocumented"} */ ${property.readOnly ? "readonly " : ""}${prop}: ${type} `); } else if (property.type === "array") { const type = refToType(property.items.$ref); outputTypes.push(` /* ${property.description || "undocumented"} */ ${property.readOnly ? "readonly " : ""}${prop}: ${type}[] `); } else if (property.type === "object") { const type = refToType(property.$ref); outputTypes.push(` /* ${property.description || "undocumented"} */ ${property.readOnly ? "readonly " : ""}${prop}: ${type} `); } else { if (property.type === undefined) { console.log(`UHANDLED TYPE: ${prop}. Falling back to unknown`); property.type = "unknown"; } outputTypes.push(` /* ${property.description || "undocumented"} */ ${property.readOnly ? "readonly " : ""}${prop}: ${ propertyMap[property.type] ? propertyMap[property.type] : property.type }`); } } }; async function main() { const response = await fetch(schemaURL); const schema = await response.json(); // STEP 1: Convert all definitions to TypeScript types and interfaces for (const definition in schema.definitions) { const properties = schema.definitions[definition].properties; if (properties) { outputTypes.push(` /* ${schema.definitions[definition].description || "undocumented"} */ `); if (schema.definitions[definition].allOf) { const baseTypes = schema.definitions[definition].allOf .map((allof: { $ref: string }) => refToType(allof.$ref)) .join(" & "); outputTypes.push(`export type ${definition} = ${baseTypes} & {`); } else { outputTypes.push(`export interface ${definition} {`); } if (definition === "SqlDatabaseGetProperties") { console.log(schema.definitions[definition]); } for (const prop in schema.definitions[definition].properties) { const property = schema.definitions[definition].properties[prop]; propertyToType(property, prop); } outputTypes.push(`}`); outputTypes.push("\n\n"); } else { const def = schema.definitions[definition]; if (def.enum) { outputTypes.push(` /* ${def.description || "undocumented"} */ export type ${definition} = ${def.enum.map((v: string) => `"${v}"`).join(" | ")}`); outputTypes.push("\n"); } else if (def.type === "string") { outputTypes.push(` /* ${def.description || "undocumented"} */ export type ${definition} = string `); } else if (def.type === "array") { const type = refToType(def.items.$ref); outputTypes.push(` /* ${def.description || "undocumented"} */ export type ${definition} = ${type}[] `); } else if (def.type === "object" && def.additionalProperties) { outputTypes.push(` /* ${def.description || "undocumented"} */ export type ${definition} = { [key: string]: ${def.additionalProperties.type}} `); } else if (def.type === "object" && def.allOf) { const type = refToType(def.allOf[0].$ref); outputTypes.push(` /* ${def.description} */ export type ${definition} = ${type} `); } else { console.log("UNHANDLED MODEL:", def, schema.definitions[def]); } } } // STEP 2: Group all paths by output client and extract common constructor parameters // Functions are grouped into clients based on operation name for (const path in schema.paths) { for (const method in schema.paths[path]) { const operation = schema.paths[path][method]; const [namespace] = operation.operationId.split("_"); if (clients[namespace] === undefined) { clients[namespace] = { paths: [], functions: [], constructorParams: [] }; clients[namespace]; } if (!clients[namespace].paths.includes(path)) { clients[namespace].paths.push(path); } } } // Write all grouped fetch functions to objects for (const clientName in clients) { const outputClient: string[] = [""]; outputClient.push(`import { armRequest } from "../../request"\n`); outputClient.push(`import * as Types from "./types"\n`); outputClient.push(`import { configContext } from "../../../../ConfigContext";\n`); outputClient.push(`const apiVersion = "${version}"\n\n`); for (const path of clients[clientName].paths) { for (const method in schema.paths[path]) { const operation = schema.paths[path][method]; const [, methodName] = operation.operationId.split("_"); const bodyParameter = operation.parameters.find( (parameter: { in: string; required: boolean }) => parameter.in === "body" && parameter.required === true ); outputClient.push(` /* ${operation.description || "undocumented"} */ export async function ${sanitize(camelize(methodName))} ( ${parametersFromPath(path) .map(p => `${p}: string`) .join(",\n")} ${bodyParam(bodyParameter, "Types")} ) : Promise<${responseType(operation, "Types")}> { const path = \`${path.replace(/{/g, "${")}\` return armRequest({ host: configContext.ARM_ENDPOINT, path, method: "${method.toLocaleUpperCase()}", apiVersion, ${ bodyParameter ? "body: JSON.stringify(body)" : "" } }) } `); } } writeOutputFile(`./${camelize(clientName)}.ts`, outputClient); } writeOutputFile("./types.ts", outputTypes); } function sanitize(name: string) { if (name === "delete") { return "destroy"; } return name; } function writeOutputFile(outputPath: string, components: string[]) { components.unshift(`/* AUTOGENERATED FILE Do not manually edit Run "npm run generateARMClients" to regenerate */\n\n`); writeFileSync(path.join(outputDir, outputPath), components.join("")); } main().catch(e => { console.error(e); process.exit(1); });