@aws/pdk
Version:
All documentation is located at: https://aws.github.io/aws-pdk
1,090 lines (941 loc) • 44.1 kB
text/typescript
/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0 */
import * as fs from "fs";
import * as util from "util";
import SwaggerParser from "@apidevtools/swagger-parser";
import { parse } from "ts-command-line-args";
import * as ejs from "ejs";
import * as path from "path";
import _get from "lodash/get";
import _set from "lodash/set";
import _trim from "lodash/trim";
import _upperFirst from "lodash/upperFirst";
import _camelCase from "lodash/camelCase";
import _snakeCase from "lodash/snakeCase";
import _kebabCase from "lodash/kebabCase";
import _orderBy from "lodash/orderBy";
import _uniq from "lodash/uniq";
import _uniqBy from "lodash/uniqBy";
import _isEqual from "lodash/isEqual";
import _cloneDeepWith from "lodash/cloneDeepWith"
import { OpenAPIV3 } from "openapi-types";
import * as parseOpenapi from "parse-openapi";
import { getOperationResponses } from "parse-openapi/dist/parser/getOperationResponses";
import { getOperationResponse } from "parse-openapi/dist/parser/getOperationResponse";
import { generateMockDataForSchema } from "../custom/mock-data/generate-mock-data";
import { allFakers, Faker } from "@faker-js/faker";
import { minimatch } from "minimatch";
const TSAPI_WRITE_FILE_START = "###TSAPI_WRITE_FILE###";
const TSAPI_WRITE_FILE_END = "###/TSAPI_WRITE_FILE###";
const MANIFEST_FILE_PATH = ".tsapi-manifest";
const LEGACY_MANIFEST_FILE_PATH = ".openapi-generator/FILES";
interface Arguments {
/**
* Path to the OpenAPI spec
*/
readonly specPath: string;
/**
* Directories for templates - names relative to the location of this script
*/
readonly templateDirs: string[];
/**
* Glob patterns for templates to exclude
*/
readonly excludeTemplates?: string[];
/**
* JSON string containing metadata
*/
readonly metadata?: string;
/**
* Location to write the generated code to
*/
readonly outputPath: string;
/**
* Print the data passed to the ejs templates
*/
readonly printData?: boolean;
}
interface WriteFileConfig {
readonly id?: string;
readonly dir: string;
readonly name: string;
readonly ext: string;
readonly overwrite?: boolean;
readonly kebabCaseFileName?: boolean;
/**
* Generate conditionally based on whether we generated the file with the given id
*/
readonly generateConditionallyId?: string;
}
interface SplitFile {
readonly contents: string;
readonly pathRelativeToOutputPath: string;
readonly config: WriteFileConfig;
readonly shouldWrite?: boolean;
}
/**
* Return whether or not the given OpenAPI object is a reference
*/
const isRef = (obj: unknown): obj is OpenAPIV3.ReferenceObject =>
!!obj && typeof obj === "object" && "$ref" in obj;
/**
* Split a reference into its component parts
* eg: #/components/schemas/Foo -> ["components", "schemas", "Foo"]
*/
const splitRef = (ref: string): string[] =>
ref
.slice(2)
.split("/")
.map((p) => p.replace(/~0/g, "~").replace(/~1/g, "/"));
/**
* Resolve the given reference in the spec
*/
const resolveRef = (spec: OpenAPIV3.Document, ref: string): any => {
const refParts = splitRef(ref);
const resolved = _get(spec, refParts);
if (!resolved) {
throw new Error(`Unable to resolve ref ${ref} in spec`);
}
return resolved;
};
/**
* Resolve the given object in an openapi spec if it's a ref
*/
const resolveIfRef = <T>(spec: OpenAPIV3.Document, possibleRef: T | OpenAPIV3.ReferenceObject): T => {
let resolved = possibleRef;
if (isRef(possibleRef)) {
resolved = resolveRef(spec, possibleRef.$ref);
}
return resolved as T;
};
/**
* Copy vendor extensions from the first parameter to the second
*/
const copyVendorExtensions = (object: object, vendorExtensions: { [key: string]: any }) => {
Object.entries(object ?? {}).forEach(([key, value]) => {
if (key.startsWith('x-')) {
vendorExtensions[key] = value;
}
});
};
/**
* Converts a string to snake_case. Matches OpenAPI generator's behaviour, which is slightly different to lodash
* in how it handles numbers (ie no underscore before a number, but an underscore after a number so long as the next letter is capitalized)
* @see https://github.com/OpenAPITools/openapi-generator/blob/38dac13c261d26a72be78bba89ee4a681843e7b0/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/StringUtils.java#L77
*/
const snakeCase = (str: string): string => {
return str
.replace(/\./g, '/')
.replace(/\$/g, '__')
.replace(/([A-Z]+)([A-Z][a-z][a-z]+)/g, (_match, g1, g2) => `${g1}_${g2}`)
.replace(/([a-z\d])([A-Z])/g, (_match, g1, g2) => `${g1}_${g2}`)
.replace(/[-\s]/g, '_')
.toLowerCase();
};
// @see https://github.com/OpenAPITools/openapi-generator/blob/e2a62ace74de361bef6338b7fa37da8577242aef/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java#L106
const PYTHON_KEYWORDS = new Set([
// local variable name used in API methods (endpoints)
"all_params", "resource_path", "path_params", "query_params",
"header_params", "form_params", "local_var_files", "body_params", "auth_settings",
// @property
"property",
// typing keywords
"schema", "base64", "json",
"date", "float",
// python reserved words
"and", "del", "from", "not", "while", "as", "elif", "global", "or", "with",
"assert", "else", "if", "pass", "yield", "break", "except", "import",
"print", "class", "exec", "in", "raise", "continue", "finally", "is",
"return", "def", "for", "lambda", "try", "self", "nonlocal", "None", "True",
"False", "async", "await",
]);
const toPythonName = (namedEntity: 'model' | 'property' | 'operation', name: string) => {
const nameSnakeCase = snakeCase(name);
// Check if the name is a reserved word. Reserved words that overlap with TypeScript will already be escaped
// with a leading _ by parseOpenapi, so we remove this to test
if (PYTHON_KEYWORDS.has(name.startsWith('_') ? name.slice(1) : name)) {
const nameSuffix = name.startsWith('_') ? nameSnakeCase : `_${nameSnakeCase}`;
switch(namedEntity) {
case "model":
return `model${nameSuffix}`;
case "operation":
return `call${nameSuffix}`;
case "property":
return `var${nameSnakeCase}`;
default:
break;
}
}
return nameSnakeCase;
};
// @see https://github.com/OpenAPITools/openapi-generator/blob/8f2676c5c2bcbcc41942307e5c8648cee38bcc44/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java#L179
const JAVA_KEYWORDS = new Set([
// special words
"object", "list", "file",
// used as internal variables, can collide with parameter names
"localVarPath", "localVarQueryParams", "localVarCollectionQueryParams",
"localVarHeaderParams", "localVarCookieParams", "localVarFormParams", "localVarPostBody",
"localVarAccepts", "localVarAccept", "localVarContentTypes",
"localVarContentType", "localVarAuthNames", "localReturnType",
"ApiClient", "ApiException", "ApiResponse", "Configuration", "StringUtil",
// language reserved words
"_", "abstract", "continue", "for", "new", "switch", "assert",
"default", "if", "package", "synchronized", "boolean", "do", "goto", "private",
"this", "break", "double", "implements", "protected", "throw", "byte", "else",
"import", "public", "throws", "case", "enum", "instanceof", "return", "transient",
"catch", "extends", "int", "short", "try", "char", "final", "interface", "static",
"void", "class", "finally", "long", "strictfp", "volatile", "const", "float",
"native", "super", "while", "null", "offsetdatetime", "localdate", "localtime"
]);
const toJavaName = (name: string) => {
// Check if the name is a reserved word. Reserved words that overlap with TypeScript will already be escaped
// with a leading _ by parseOpenapi, so we remove this to test
const unescapedName = _camelCase(name.startsWith('_') ? name.slice(1) : name);
if (JAVA_KEYWORDS.has(unescapedName)) {
// Special case for "class"
if (unescapedName === "class") {
return "propertyClass";
}
return `_${unescapedName}`;
}
return unescapedName;
};
/**
* Clean up any generated code that already exists
*/
const cleanGeneratedCode = (outputPath: string) => {
let manifestPath = path.resolve(outputPath, MANIFEST_FILE_PATH);
const legacyManifestPath = path.resolve(outputPath, LEGACY_MANIFEST_FILE_PATH);
// If upgrading from a previous version of PDK which uses openapi-generator, honour that manifest
// to clean up old generated code
if (!fs.existsSync(manifestPath) && fs.existsSync(legacyManifestPath)) {
manifestPath = legacyManifestPath;
}
// If the manifest exists, delete the files it lists
if (fs.existsSync(manifestPath)) {
const previouslyGeneratedFiles = new Set(
fs
.readFileSync(manifestPath, { encoding: "utf-8" })
.split("\n")
.filter((x) => x)
);
previouslyGeneratedFiles.forEach((previouslyGeneratedFile) => {
const filePath = path.resolve(outputPath, previouslyGeneratedFile);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
});
}
};
const splitAndWriteFiles = (renderedFileContents: string[], outputPath: string) => {
const splitFiles: SplitFile[] = [];
renderedFileContents.forEach(contents => contents.split(TSAPI_WRITE_FILE_START)
.filter((t) => t.includes(TSAPI_WRITE_FILE_END))
.forEach((destinationFileTemplate) => {
// Split by the end template to receive the file path, and contents
const [configString, newFileContents] =
destinationFileTemplate.split(TSAPI_WRITE_FILE_END);
const config = JSON.parse(configString) as WriteFileConfig;
const newFileName = `${config.name}${config.ext}`;
const newFilePath = path.join(
config.dir,
newFileName
);
splitFiles.push({
contents: newFileContents,
pathRelativeToOutputPath: newFilePath,
shouldWrite: !fs.existsSync(path.join(outputPath, newFilePath)) || config.overwrite,
config,
});
}));
const splitFilesById: { [id: string]: SplitFile } = Object.fromEntries(splitFiles.filter((s) => s.config.id).map((s) => [s.config.id, s]));
const generatedFilePaths: string[] = [];
// Write the split files
splitFiles.forEach(({ pathRelativeToOutputPath, config, contents, shouldWrite }) => {
const newFilePath = path.join(outputPath, pathRelativeToOutputPath);
const conditionalShouldWrite = splitFilesById[config.generateConditionallyId ?? '']?.shouldWrite ?? true;
// Write to the instructed file path (relative to the src dir)
if (shouldWrite && conditionalShouldWrite) {
// Create it's containing directory if needed
fs.mkdirSync(path.dirname(newFilePath), {
recursive: true,
});
fs.writeFileSync(newFilePath, contents);
// Overwritten files are added to the manifest so that they can be cleaned up
// by clean-openapi-generated-code
if (config.overwrite) {
generatedFilePaths.push(pathRelativeToOutputPath);
}
}
});
// Write the manifest file
fs.writeFileSync(path.resolve(outputPath, MANIFEST_FILE_PATH), generatedFilePaths.join('\n'));
};
// Model types which indicate it is composed (ie inherits/mixin's another schema)
const COMPOSED_SCHEMA_TYPES = new Set(["one-of", "any-of", "all-of"]);
const COLLECTION_TYPES = new Set(["array", "dictionary"]);
const PRIMITIVE_TYPES = new Set(["string", "integer", "number", "boolean", "null", "any", "binary", "void"]);
/**
* Mutates the given data to ensure composite models (ie allOf, oneOf, anyOf) have the necessary
* properties for representing them in generated code. Adds `composedModels` and `composedPrimitives`
* which contain the models and primitive types that each model is composed of.
*/
const ensureCompositeModels = (data: parseOpenapi.ParsedSpecification) => {
const visited = new Set<parseOpenapi.Model>();
data.models.forEach(model => mutateModelWithCompositeProperties(data, model, visited));
}
const mutateModelWithCompositeProperties = (data: parseOpenapi.ParsedSpecification, model: parseOpenapi.Model, visited: Set<parseOpenapi.Model>) => {
if (COMPOSED_SCHEMA_TYPES.has(model.export) && !visited.has(model)) {
visited.add(model);
// Find the models/primitives which this is composed from
const composedModelReferences = model.properties.filter(p => !p.name && p.export === "reference");
const composedPrimitives = model.properties.filter(p => !p.name && p.export !== "reference");
const modelsByName = Object.fromEntries(data.models.map(m => [m.name, m]));
const composedModels = composedModelReferences.flatMap(r => modelsByName[r.type] ? [modelsByName[r.type]] : []);
// Recursively resolve composed properties of properties, to ensure mixins for all-of include all recursive all-of properties
composedModels.forEach(m => mutateModelWithCompositeProperties(data, m, visited));
// For all-of models, we include all composed model properties.
if (model.export === "all-of") {
if (composedPrimitives.length > 0) {
throw new Error(`Schema "${model.name}" defines allOf with non-object types. allOf may only compose object types in the OpenAPI specification.`);
}
}
(model as any).composedModels = composedModels;
(model as any).composedPrimitives = composedPrimitives;
}
};
const toTypescriptPrimitive = (property: parseOpenapi.Model): string => {
if (property.type === "string" && ["date", "date-time"].includes(property.format ?? '')) {
return "Date";
} else if (property.type === "binary") {
return "Blob";
}
return property.type;
};
/**
* Return the typescript type for the given model
*/
const toTypeScriptType = (property: parseOpenapi.Model): string => {
switch (property.export) {
case "generic":
case "reference":
return toTypescriptPrimitive(property);
case "array":
return `Array<${property.link && property.link.export !== "enum" ? toTypeScriptType(property.link) : property.type}>`;
case "dictionary":
return `{ [key: string]: ${property.link && property.link.export !== "enum" ? toTypeScriptType(property.link) : property.type}; }`;
case "one-of":
case "any-of":
case "all-of":
return property.name;
default:
return property.type;
}
};
const toJavaPrimitive = (property: parseOpenapi.Model): string => {
if (property.type === "string" && property.format === "date") {
return "LocalDate";
} else if (property.type === "string" && property.format === "date-time") {
return "OffsetDateTime";
} else if (property.type === "string" && (property.format as any) === "uuid") {
return "UUID";
} else if (property.type === "string" && (property.format as any) === "uri") {
return "URI";
} else if (property.type === "binary" || (property.type === "string" && ["byte", "binary"].includes(property.format as any))) {
return "byte[]";
} else if (property.type === "number") {
switch(property.format) {
case "int32":
return "Integer";
case "int64":
return "Long";
case "float":
return "Float";
case "double":
return "Double";
default:
break;
}
if ((property as any).openapiType === "integer") {
return "Integer";
}
return "BigDecimal";
} else if (property.type === "boolean") {
return "Boolean";
} else if (property.type === "string") {
return "String";
} else if (property.type === "any") {
return "Object";
}
return property.type;
};
const toJavaType = (property: parseOpenapi.Model): string => {
switch (property.export) {
case "generic":
case "reference":
return toJavaPrimitive(property);
case "array":
return `${property.uniqueItems ? 'Set' : 'List'}<${property.link && property.link.export !== "enum" ? toJavaType(property.link) : property.type}>`;
case "dictionary":
return `Map<String, ${property.link && property.link.export !== "enum" ? toJavaType(property.link) : property.type}>`;
case "one-of":
case "any-of":
case "all-of":
return property.name;
default:
// "any" has export = interface
if (PRIMITIVE_TYPES.has(property.type)) {
return toJavaPrimitive(property);
}
return property.type;
}
};
const toPythonPrimitive = (property: parseOpenapi.Model): string => {
if (property.type === "string" && property.format === "date") {
return "date";
} else if (property.type === "string" && property.format === "date-time") {
return "datetime"
} else if (property.type === "any") {
return "object";
} else if (property.type === "binary") {
return "bytearray";
} else if (property.type === "number") {
if ((property as any).openapiType === "integer") {
return "int";
}
switch(property.format) {
case "int32":
case "int64":
return "int";
case "float":
case "double":
default:
return "float";
}
} else if (property.type === "boolean") {
return "bool";
} else if (property.type === "string") {
return "str";
}
return property.type;
};
const toPythonType = (property: parseOpenapi.Model): string => {
switch (property.export) {
case "generic":
case "reference":
return toPythonPrimitive(property);
case "array":
return `List[${property.link && property.link.export !== "enum" ? toPythonType(property.link) : property.type}]`;
case "dictionary":
return `Dict[str, ${property.link && property.link.export !== "enum" ? toPythonType(property.link) : property.type}]`;
case "one-of":
case "any-of":
case "all-of":
return property.name;
default:
// "any" has export = interface
if (PRIMITIVE_TYPES.has(property.type)) {
return toPythonPrimitive(property);
}
return property.type;
}
};
/**
* Mutates the given model to add language specific types and names
*/
const mutateModelWithAdditionalTypes = (model: parseOpenapi.Model) => {
// Trim any surrounding quotes from name
model.name = _trim(model.name, `"'`);
(model as any).typescriptName = model.name;
(model as any).typescriptType = toTypeScriptType(model);
(model as any).javaName = toJavaName(model.name);
(model as any).javaType = toJavaType(model);
(model as any).pythonName = toPythonName('property', model.name);
(model as any).pythonType = toPythonType(model);
(model as any).isPrimitive = PRIMITIVE_TYPES.has(model.type) && !COMPOSED_SCHEMA_TYPES.has(model.export) && !COLLECTION_TYPES.has(model.export);
};
interface MockDataContext {
readonly dereferencedSpec: OpenAPIV3.Document;
readonly faker: Faker;
}
const mutateWithOpenapiSchemaProperties = (spec: OpenAPIV3.Document, model: parseOpenapi.Model, schema: OpenAPIV3.SchemaObject, mockDataContext: MockDataContext, visited: Set<parseOpenapi.Model> = new Set()) => {
(model as any).format = schema.format;
(model as any).isInteger = schema.type === "integer";
(model as any).isShort = schema.format === "int32";
(model as any).isLong = schema.format === "int64";
(model as any).deprecated = !!schema.deprecated;
(model as any).openapiType = schema.type;
(model as any).isNotSchema = !!schema.not;
(model as any).isEnum = !!schema.enum && schema.enum.length > 0;
// Copy any schema vendor extensions
(model as any).vendorExtensions = {};
copyVendorExtensions(schema, (model as any).vendorExtensions);
// Use our added vendor extension
(model as any).isHoisted = !!(model as any).vendorExtensions?.['x-tsapi-hoisted'];
mutateModelWithAdditionalTypes(model);
// Add mock data
(model as any).mockData = generateMockDataForSchema(mockDataContext.dereferencedSpec, {
faker: mockDataContext.faker,
maxArrayLength: 3,
maxCircularReferenceDepth: 2,
}, schema);
visited.add(model);
// Also apply to array items recursively
if (model.export === "array" && model.link && 'items' in schema && schema.items && !visited.has(model.link)) {
const subSchema = resolveIfRef(spec, schema.items);
mutateWithOpenapiSchemaProperties(spec, model.link, subSchema, mockDataContext, visited);
}
// Also apply to object properties recursively
if (model.export === "dictionary" && model.link && 'additionalProperties' in schema && schema.additionalProperties && !visited.has(model.link)) {
const subSchema = resolveIfRef(spec, schema.additionalProperties);
// Additional properties can be "true" rather than a type
if (subSchema !== true) {
mutateWithOpenapiSchemaProperties(spec, model.link, subSchema, mockDataContext, visited);
}
}
model.properties.filter(p => !visited.has(p) && schema.properties?.[_trim(p.name, `"'`)]).forEach(property => {
const subSchema = resolveIfRef(spec, schema.properties![_trim(property.name, `"'`)]);
mutateWithOpenapiSchemaProperties(spec, property, subSchema, mockDataContext, visited);
});
if (COMPOSED_SCHEMA_TYPES.has(model.export)) {
model.properties.forEach((property, i) => {
const subSchema = resolveIfRef(spec, (schema as any)[_camelCase(model.export)]?.[i]);
if (subSchema) {
mutateWithOpenapiSchemaProperties(spec, property, subSchema, mockDataContext, visited);
}
});
}
};
/**
* Ensure that the "link" property of all dictionary/array models and properties are set recursively
*/
const ensureModelLinks = (spec: OpenAPIV3.Document, data: parseOpenapi.ParsedSpecification) => {
const modelsByName = Object.fromEntries(data.models.map((m) => [m.name, m]));
const visited = new Set<parseOpenapi.Model>();
// Ensure set for all models
data.models.forEach((model) => {
const schema = resolveIfRef(spec, spec?.components?.schemas?.[model.name]);
if (schema) {
// Object schemas should be typed as the model we will create
if (schema.type === "object" && schema.properties) {
model.type = model.name;
}
_ensureModelLinks(spec, modelsByName, model, schema, visited)
}
});
// Ensure set for all parameters
data.services.forEach((service) => {
service.operations.forEach((op) => {
const specOp = (spec as any)?.paths?.[op.path]?.[op.method.toLowerCase()] as OpenAPIV3.OperationObject | undefined;
const specParametersByName = Object.fromEntries((specOp?.parameters ?? []).map((p) => {
const param = resolveIfRef(spec, p);
return [param.name, param];
}));
op.parameters.forEach((parameter) => {
const specParameter = specParametersByName[parameter.prop];
const specParameterSchema = resolveIfRef(spec, specParameter?.schema);
if (specParameterSchema) {
_ensureModelLinks(spec, modelsByName, parameter, specParameterSchema, visited);
}
});
});
});
};
const _ensureModelLinks = (spec: OpenAPIV3.Document, modelsByName: {[name: string]: parseOpenapi.Model}, model: parseOpenapi.Model, schema: OpenAPIV3.SchemaObject, visited: Set<parseOpenapi.Model>) => {
if (visited.has(model)) {
return;
}
visited.add(model);
if (model.export === "dictionary" && 'additionalProperties' in schema && schema.additionalProperties) {
if (isRef(schema.additionalProperties)) {
const name = splitRef(schema.additionalProperties.$ref)[2];
if (modelsByName[name] && !model.link) {
model.link = modelsByName[name];
}
} else if (model.link && typeof schema.additionalProperties !== 'boolean') {
_ensureModelLinks(spec, modelsByName, model.link, schema.additionalProperties, visited);
}
} else if (model.export === "array" && 'items' in schema && schema.items) {
if (isRef(schema.items)) {
const name = splitRef(schema.items.$ref)[2];
if (modelsByName[name] && !model.link) {
model.link = modelsByName[name];
}
} else if (model.link) {
_ensureModelLinks(spec, modelsByName, model.link, schema.items, visited);
}
}
model.properties.filter(p => !visited.has(p) && schema.properties?.[_trim(p.name, `"'`)]).forEach(property => {
const subSchema = resolveIfRef(spec, schema.properties![_trim(property.name, `"'`)]);
_ensureModelLinks(spec, modelsByName, property, subSchema, visited);
});
if (COMPOSED_SCHEMA_TYPES.has(model.export)) {
model.properties.forEach((property, i) => {
const subSchema = resolveIfRef(spec, (schema as any)[_camelCase(model.export)]?.[i]);
if (subSchema) {
_ensureModelLinks(spec, modelsByName, property, subSchema, visited);
}
});
}
};
interface SubSchema {
readonly nameParts: string[];
readonly schema: OpenAPIV3.SchemaObject;
readonly prop: string;
}
interface SubSchemaRef {
readonly $ref: string;
readonly name: string;
readonly schema: OpenAPIV3.SchemaObject;
}
const isCompositeSchema = (schema: OpenAPIV3.SchemaObject) =>
!!schema.allOf || !!schema.anyOf || !!schema.oneOf;
const hasSubSchemasToVisit = (schema?: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject): schema is OpenAPIV3.SchemaObject =>
!!schema && !isRef(schema) && (["object", "array"].includes(schema.type as any) || isCompositeSchema(schema) || !!schema.not || (schema.type === "string" && !!schema.enum));
const filterInlineCompositeSchemas = (schemas: (OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject)[], nameParts: string[], namePartPrefix: string, prop: string): SubSchema[] => {
let inlineSchemaIndex = 0;
return schemas.flatMap((s, i) => {
if (hasSubSchemasToVisit(s)) {
const subSchema: SubSchema = { nameParts: s.title ? [_upperFirst(_camelCase(s.title))] : [...nameParts, `${namePartPrefix}${inlineSchemaIndex === 0 ? '' : inlineSchemaIndex}`], schema: s, prop: `${prop}.[${i}]` };
inlineSchemaIndex++;
return [subSchema];
}
return [];
});
}
const hoistInlineObjectSubSchemas = (nameParts: string[], schema: OpenAPIV3.SchemaObject): SubSchemaRef[] => {
// Find all the inline subschemas we should visit
const inlineSubSchemas: SubSchema[] = [
...(hasSubSchemasToVisit(schema.not) ? [{ nameParts: [...nameParts, 'Not'], schema: schema.not, prop: 'not' }] : []),
...(schema.anyOf ? filterInlineCompositeSchemas(schema.anyOf, nameParts, 'AnyOf', 'anyOf') : []),
...(schema.allOf ? filterInlineCompositeSchemas(schema.allOf, nameParts, 'AllOf', 'allOf') : []),
...(schema.oneOf ? filterInlineCompositeSchemas(schema.oneOf, nameParts, 'OneOf', 'oneOf') : []),
...('items' in schema && hasSubSchemasToVisit(schema.items) ? [{ nameParts: [...nameParts, 'Inner'], schema: schema.items, prop: 'items' }] : []),
...(Object.entries(schema.properties ?? {}).filter(([, s]) => hasSubSchemasToVisit(s)).map(([name, s]) => ({ nameParts: [...nameParts, name], schema: s as OpenAPIV3.SchemaObject, prop: `properties.${name}` }))),
...((typeof schema.additionalProperties !== "boolean" && hasSubSchemasToVisit(schema.additionalProperties)) ? [{ nameParts: [...nameParts, 'Value'], schema: schema.additionalProperties, prop: `additionalProperties` }] : []),
];
// Hoist these recursively first (ie depth first search) so that we don't miss refs
const recursiveRefs = inlineSubSchemas.flatMap((s) => hoistInlineObjectSubSchemas(s.nameParts, s.schema));
// Clone the object subschemas to build the refs. Note that only objects with "properties" are hoisted as these are non-dictionary types
const refs = inlineSubSchemas.filter(s => (s.schema.type === "object" && s.schema.properties) || isCompositeSchema(s.schema) || (s.schema.type === "string" && s.schema.enum)).map(s => {
const name = [...s.nameParts, ...(s.schema.type === "string" && s.schema.enum ? ["Enum"] : [])].map(_upperFirst).join('');
const $ref = `#/components/schemas/${name}`;
const ref = {
$ref,
name,
schema: structuredClone({
...s.schema,
"x-tsapi-hoisted": true,
}),
};
// Replace each subschema with a ref in the spec
_set(schema, s.prop, { $ref });
return ref;
});
return [...refs, ...recursiveRefs];
};
const buildData = async (inSpec: OpenAPIV3.Document, metadata: any) => {
// Using openapi generator, we passed "KEEP_ONLY_FIRST_TAG_IN_OPERATION" to ensure we don't generate duplicate
// handler wrappers where multiple tags are used.
// In order for the new generator not to be breaking, we apply the same logic here, however this can be removed
// in future since we have control to avoid the duplicate handlers while allowing an operation to be part of
// multiple "services".
let spec = _cloneDeepWith(inSpec, (value, key) => {
// Keep only the first tag where we find a tag
if (key === "tags" && value && value.length > 0 && typeof value[0] === "string") {
return [value[0]];
}
}) as OpenAPIV3.Document;
// Ensure spec has schemas set
if (!spec?.components?.schemas) {
spec.components = {
...spec.components,
};
spec.components.schemas = {
...spec.components.schemas,
};
}
// "Hoist" inline request and response schemas
Object.entries(spec.paths ?? {}).forEach(([path, pathOps]) => Object.entries(pathOps ?? {}).forEach(([method, op]) => {
const operation = resolveIfRef(spec, op);
if (operation && typeof operation === "object") {
if ("responses" in operation) {
Object.entries(operation.responses ?? {}).forEach(([code, res]) => {
const response = resolveIfRef(spec, res);
const jsonResponseSchema = response?.content?.['application/json']?.schema;
if (jsonResponseSchema && !isRef(jsonResponseSchema) && ["object", "array"].includes(jsonResponseSchema.type!)) {
const schemaName = `${_upperFirst(_camelCase(operation.operationId ?? `${path}-${method}`))}${code}Response`;
spec.components!.schemas![schemaName] = jsonResponseSchema;
response!.content!['application/json'].schema = {
$ref: `#/components/schemas/${schemaName}`,
};
}
});
}
if ("requestBody" in operation) {
const requestBody = resolveIfRef(spec, operation.requestBody);
const jsonRequestSchema = requestBody?.content?.['application/json']?.schema;
if (jsonRequestSchema && !isRef(jsonRequestSchema) && ["object", "array"].includes(jsonRequestSchema.type!)) {
const schemaName = `${_upperFirst(_camelCase(operation.operationId ?? `${path}-${method}`))}RequestContent`;
spec.components!.schemas![schemaName] = jsonRequestSchema;
requestBody!.content!['application/json'].schema = {
$ref: `#/components/schemas/${schemaName}`,
};
}
}
}
}));
// "Hoist" any nested object definitions in arrays/maps that aren't already refs, as parseOpenapi will treat the
// type as "any" if they're defined inline (and not a ref)
Object.entries(spec.components?.schemas ?? {}).forEach(([name, schema]) => {
if (!isRef(schema)) {
const refs = hoistInlineObjectSubSchemas([name], schema);
refs.forEach(ref => {
spec.components!.schemas![ref.name] = ref.schema;
});
}
});
// "Inline" any refs to non objects/enums
const inlinedRefs: Set<string> = new Set();
spec = _cloneDeepWith(spec, (v) => {
if (v && typeof v === "object" && v.$ref) {
const resolved = resolveRef(spec, v.$ref);
if (resolved && resolved.type && resolved.type !== "object" && !(resolved.type === "string" && resolved.enum)) {
inlinedRefs.add(v.$ref);
return resolved;
}
}
});
// Delete the non object schemas that were inlined
[...inlinedRefs].forEach(ref => {
const parts = splitRef(ref);
if (parts.length === 3 && parts[0] === "components" && parts[1] === "schemas") {
delete spec.components!.schemas![parts[2]];
}
});
// Start with the data from https://github.com/webpro/parse-openapi which extracts most of what we need
const data = { ...parseOpenapi.parse(spec), metadata };
// Mutate the models with enough data to render composite models in the templates
ensureCompositeModels(data);
// Ensure the models have their links set when they are arrays/dictionaries
ensureModelLinks(spec, data);
const faker = allFakers['en'];
faker.seed(1337);
faker.setDefaultRefDate(new Date("2021-06-10"));
const mockDataContext: MockDataContext = {
faker,
dereferencedSpec: await SwaggerParser.dereference(structuredClone(spec), { dereference: { circular: 'ignore' } }) as OpenAPIV3.Document,
};
// Augment operations with additional data
data.services.forEach((service) => {
// Keep track of the request and response models we need the service (ie api client) to import
const requestModelImports: string[] = [];
const responseModelImports: string[] = [];
service.operations.forEach((op) => {
// Extract the operation back from the openapi spec
const specOp = (spec as any)?.paths?.[op.path]?.[op.method.toLowerCase()] as OpenAPIV3.OperationObject | undefined;
// Add vendor extensions
(op as any).vendorExtensions = (op as any).vendorExtensions ?? {};
copyVendorExtensions(specOp ?? {}, (op as any).vendorExtensions);
if (specOp) {
// parseOpenapi has a method to retrieve the operation responses, but later filters to only
// return information about successful (2XX) responses. We call the method directly to retrieve
// all responses
const responses = getOperationResponses(spec, specOp.responses);
(op as any).responses = responses;
// Add all response models to the response model imports
responseModelImports.push(...responses.filter(r => r.export === "reference").map(r => r.type));
const defaultResponse = resolveIfRef(spec, specOp.responses?.['default']);
[...responses, ...op.results].forEach((response) => {
// Check whether this response is actually the "default" response.
if (response.code === 200 && defaultResponse && _isEqual(response, getOperationResponse(spec, defaultResponse, 200))) {
// For backwards compatibility with OpenAPI generator, we set the response code for the default response to 0.
// See: https://github.com/OpenAPITools/openapi-generator/blob/8f2676c5c2bcbcc41942307e5c8648cee38bcc44/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenResponse.java#L622
// TODO: we should likely revisit this to make the handler wrappers more intuitive for the default response case, as
// the code 0 would actually need to be returned by the server for marshalling etc to work for the model associated with
// the default response.
response.code = 0;
}
const matchingSpecResponse = specOp.responses[`${response.code}`];
// parseOpenapi does not distinguish between returning an "any" or returning "void"
// We distinguish this by looking back each response in the spec, and checking whether it
// has content
if (matchingSpecResponse) {
// Resolve the ref if necessary
const specResponse = resolveIfRef(spec, matchingSpecResponse);
// When there's no content, we set the type to 'void'
if (!specResponse.content) {
response.type = 'void';
} else {
// Add the response media types
(response as any).mediaTypes = Object.keys(specResponse.content);
const responseSchema = specResponse.content?.['application/json'] ?? Object.values(specResponse.content)[0];
if (responseSchema) {
mutateWithOpenapiSchemaProperties(spec, response, responseSchema, mockDataContext);
}
}
}
});
// If the operation didn't specify an operationId, we need to generate one in a backwards compatible way
// which matches openapi generator
if (!specOp.operationId) {
(op as any).name = _camelCase(`${op.path.replace(/{(.*?)}/g, 'by-$1').replace(/[/:]/g, '-')}-${op.method}`);
}
}
const specParametersByName = Object.fromEntries((specOp?.parameters ?? []).map((p) => {
const param = resolveIfRef(spec, p);
return [param.name, param];
}));
// Loop through the parameters
op.parameters.forEach((parameter) => {
// Add the request model import
if (parameter.export === "reference") {
requestModelImports.push(parameter.type);
}
const specParameter = specParametersByName[parameter.prop];
const specParameterSchema = resolveIfRef(spec, specParameter?.schema);
if (specParameterSchema) {
mutateWithOpenapiSchemaProperties(spec, parameter, specParameterSchema, mockDataContext);
}
if (parameter.in === "body") {
// Parameter name for the body is it's type in camelCase
parameter.name = parameter.export === "reference" ? _camelCase(parameter.type) : "body";
parameter.prop = "body";
// The request body is not in the "parameters" section of the openapi spec so we won't have added the schema
// properties above. Find it here.
const specBody = resolveIfRef(spec, specOp?.requestBody);
if (specBody) {
if (parameter.mediaType) {
const bodySchema = resolveIfRef(spec, specBody.content?.[parameter.mediaType]?.schema);
if (bodySchema) {
mutateWithOpenapiSchemaProperties(spec, parameter, bodySchema, mockDataContext);
}
}
// Track all the media types that can be accepted in the request body
(parameter as any).mediaTypes = Object.keys(specBody.content);
}
} else if (["query", "header"].includes(parameter.in) && specParameter) {
// Translate style/explode to OpenAPI v2 style collectionFormat
// https://spec.openapis.org/oas/v3.0.3.html#style-values
const style = specParameter.style ?? (parameter.in === "query" ? "form" : "simple");
const explode = specParameter.explode ?? style === "form";
if (parameter.in === "query") {
(parameter as any).collectionFormat = explode ? "multi" : ({ spaceDelimited: "ssv", pipeDelimited: "tsv", simple: "csv", form: "csv" }[style] ?? "multi");
} else { // parameter.in === "header"
(parameter as any).collectionFormat = explode ? "multi" : "csv";
}
}
mutateModelWithAdditionalTypes(parameter);
});
// Add language types to response models
[...((op as any).responses ?? []), ...op.results].forEach(mutateModelWithAdditionalTypes);
// Add variants of operation name
(op as any).operationIdPascalCase = _upperFirst(op.name);
(op as any).operationIdKebabCase = _kebabCase(op.name);
(op as any).operationIdSnakeCase = toPythonName('operation', op.name);
});
// Lexicographical ordering of operations to match openapi generator
service.operations = _orderBy(service.operations, (op) => op.name);
// Add the models to import
(service as any).modelImports = _orderBy(_uniq([...service.imports, ...requestModelImports, ...responseModelImports]));
// Add the service class name
(service as any).className = `${service.name}Api`;
(service as any).classNameSnakeCase = snakeCase((service as any).className);
(service as any).nameSnakeCase = snakeCase(service.name);
});
// Augment models with additional data
data.models.forEach((model) => {
// Add a snake_case name
(model as any).nameSnakeCase = toPythonName('model', model.name);
const matchingSpecModel = spec?.components?.schemas?.[model.name];
if (matchingSpecModel) {
const specModel = resolveIfRef(spec, matchingSpecModel);
mutateWithOpenapiSchemaProperties(spec, model, specModel, mockDataContext);
// Add unique imports
(model as any).uniqueImports = _orderBy(_uniq([
...model.imports,
// Include property imports, if any
...model.properties.filter(p => p.export === "reference").map(p => p.type),
])).filter(modelImport => modelImport !== model.name); // Filter out self for recursive model references
// Add deprecated flag if present
(model as any).deprecated = specModel.deprecated || false;
// If the model has "additionalProperties" there should be a "dictionary" property
if (specModel.additionalProperties) {
(model as any).additionalPropertiesProperty = model.properties.find(p => !p.name && p.export === "dictionary");
}
// Augment properties with additional data
model.properties.forEach((property) => {
const matchingSpecProperty = specModel.properties?.[property.name];
if (matchingSpecProperty) {
const specProperty = resolveIfRef(spec, matchingSpecProperty);
mutateWithOpenapiSchemaProperties(spec, property, specProperty, mockDataContext);
}
// Add language-specific names/types
mutateModelWithAdditionalTypes(property);
});
}
});
// Order models lexicographically by name
data.models = _orderBy(data.models, d => d.name);
// Order services so default appears first, then otherwise by name
data.services = _orderBy(data.services, (s => s.name === "Default" ? "" : s.name));
// All operations across all services
const allOperations = _uniqBy(data.services.flatMap(s => s.operations), o => o.name);
// Add top level vendor extensions
const vendorExtensions: { [key: string]: any } = {};
copyVendorExtensions(spec ?? {}, vendorExtensions);
return {
...data,
allOperations,
info: spec.info,
vendorExtensions,
};
};
const resolveTemplateDir = (rootScriptDir: string, templateDir: string) => {
// Prefer built in template, eg "typescript-lambda-handlers"
const builtinTemplateDir = path.join(rootScriptDir, "generators", templateDir);
if (fs.existsSync(builtinTemplateDir)) {
return builtinTemplateDir;
}
// Otherwise use as-is, as a directory relative to cwd
if (fs.existsSync(templateDir)) {
return templateDir;
}
throw new Error(`Template directory ${templateDir} does not exist!`);
};
export const listFilesInDirRecursive = (dir: string): string[] => {
if ((fs.lstatSync(dir)).isDirectory()) {
return fs.readdirSync(dir).map((f) => listFilesInDirRecursive(path.join(dir, f))).flatMap(x => x);
} else {
return [dir];
}
};
export default async (argv: string[], rootScriptDir: string) => {
const args = parse<Arguments>({
specPath: { type: String },
metadata: { type: String, optional: true },
templateDirs: { type: String, multiple: true },
excludeTemplates: { type: String, multiple: true, optional: true },
outputPath: { type: String },
printData: { type: Boolean, optional: true },
}, { argv });
const spec = (await SwaggerParser.bundle(args.specPath)) as any;
// Build data
const data = await buildData(spec, JSON.parse(args.metadata ?? '{}'));
if (args.printData) {
console.log(util.inspect(data, { depth: 100 }));
}
// Read all .ejs files in each template directory
const candidateTemplates = args.templateDirs.flatMap(t => listFilesInDirRecursive(resolveTemplateDir(rootScriptDir, t))
.filter(f => f.endsWith('.ejs') && !f.endsWith('.partial.ejs')));
// Filter out any excluded templates
const templates = candidateTemplates.filter((t => !(args.excludeTemplates ?? []).some(pattern => minimatch(t, pattern))));
// Render the templates with the data from the spec
const renderedFiles = await Promise.all(templates.map(async (template) => {
return await ejs.renderFile(template, data);
}));
// Prior to writing the new files, clean up
cleanGeneratedCode(args.outputPath);
// Write the rendered files
splitAndWriteFiles(renderedFiles, args.outputPath);
};