ng-openapi-gen
Version:
An OpenAPI 3.0 and 3.1 codegen for Angular 16+
472 lines • 21.1 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.NgOpenApiGen = void 0;
exports.runNgOpenApiGen = runNgOpenApiGen;
exports.filterPaths = filterPaths;
const json_schema_ref_parser_1 = __importDefault(require("@apidevtools/json-schema-ref-parser"));
const eol_1 = __importDefault(require("eol"));
const fs_extra_1 = __importDefault(require("fs-extra"));
const os_1 = __importDefault(require("os"));
const path_1 = __importDefault(require("path"));
const cmd_args_1 = require("./cmd-args");
const gen_utils_1 = require("./gen-utils");
const globals_1 = require("./globals");
const handlebars_manager_1 = require("./handlebars-manager");
const logger_1 = require("./logger");
const model_1 = require("./model");
const operation_1 = require("./operation");
const service_1 = require("./service");
const templates_1 = require("./templates");
const model_index_1 = require("./model-index");
/**
* Main generator class
*/
class NgOpenApiGen {
constructor(openApi, options) {
this.openApi = openApi;
this.options = options;
this.models = new Map();
this.services = new Map();
this.operations = new Map();
this.logger = new logger_1.Logger(options.silent);
this.setDefaults();
// Validate OpenAPI version
this.validateOpenApiVersion();
this.outDir = this.options.output || 'src/app/api';
// Make sure the output path doesn't end with a slash
if (this.outDir.endsWith('/') || this.outDir.endsWith('\\')) {
this.outDir = this.outDir.substring(0, this.outDir.length - 1);
}
this.tempDir = this.outDir + '$';
this.initTempDir();
this.initHandlebars();
this.readTemplates();
this.readModels();
this.readServices();
// Ignore the unused models if not set to false in options
if (this.options.ignoreUnusedModels !== false) {
this.ignoreUnusedModels();
}
}
/**
* Set the temp dir to a system temporary directory if option useTempDir is set
*/
initTempDir() {
if (this.options.useTempDir === true) {
const systemTempDir = path_1.default.join(os_1.default.tmpdir(), `ng-openapi-gen-${path_1.default.basename(this.outDir)}$`);
this.tempDir = systemTempDir;
}
}
/**
* Actually generates the files
*/
generate() {
// Make sure the temporary directory is empty before starting
(0, gen_utils_1.deleteDirRecursive)(this.tempDir);
fs_extra_1.default.mkdirsSync(this.tempDir);
try {
// Generate each model
const models = [...this.models.values()];
for (const model of models) {
this.write('model', model, model.fileName, 'models');
if (this.options.enumArray && model.enumArrayFileName) {
this.write('enumArray', model, model.enumArrayFileName, 'models');
}
}
// Generate each service and function
const generateServices = !!this.options.services;
const services = [...this.services.values()];
for (const service of services) {
if (generateServices) {
this.write('service', service, service.fileName, 'services');
}
}
// Generate each function
const allFunctions = services.reduce((acc, service) => [
...acc,
...service.operations.reduce((opAcc, operation) => [
...opAcc,
...operation.variants
], [])
], []);
// Remove duplicates
const functions = allFunctions.filter((fn, index, arr) => arr.findIndex(f => f.methodName === fn.methodName) === index);
for (const fn of functions) {
this.write('fn', fn, fn.importFile, fn.importPath);
}
// Context object passed to general templates
const general = { services, models, functions };
// Generate the general files
this.write('configuration', general, this.globals.configurationFile);
this.write('response', general, this.globals.responseFile);
this.write('requestBuilder', general, this.globals.requestBuilderFile);
if (generateServices) {
this.write('baseService', general, this.globals.baseServiceFile);
}
if (this.globals.apiServiceFile) {
this.write('apiService', general, this.globals.apiServiceFile);
}
if (generateServices && this.globals.moduleClass && this.globals.moduleFile) {
this.write('module', general, this.globals.moduleFile);
}
const modelIndex = this.globals.modelIndexFile || this.options.indexFile ? new model_index_1.ModelIndex(models, this.options) : null;
if (this.globals.modelIndexFile) {
this.write('modelIndex', { ...general, modelIndex }, this.globals.modelIndexFile);
}
if (this.globals.functionIndexFile) {
this.write('functionIndex', general, this.globals.functionIndexFile);
}
if (generateServices && this.globals.serviceIndexFile) {
this.write('serviceIndex', general, this.globals.serviceIndexFile);
}
if (this.options.indexFile) {
this.write('index', { ...general, modelIndex }, 'index');
}
// Now synchronize the temp to the output folder
(0, gen_utils_1.syncDirs)(this.tempDir, this.outDir, this.options.removeStaleFiles !== false, this.logger);
this.logger.info(`Generation from ${this.options.input} finished with ${models.length} models and ${services.length} services.`);
}
finally {
// Always remove the temporary directory
(0, gen_utils_1.deleteDirRecursive)(this.tempDir);
}
}
write(template, model, baseName, subDir) {
const ts = this.setEndOfLine(this.templates.apply(template, model));
const file = path_1.default.join(this.tempDir, subDir || '.', `${baseName}.ts`);
const dir = path_1.default.dirname(file);
fs_extra_1.default.ensureDirSync(dir);
fs_extra_1.default.writeFileSync(file, ts, { encoding: 'utf-8' });
}
initHandlebars() {
this.handlebarsManager = new handlebars_manager_1.HandlebarsManager();
this.handlebarsManager.readCustomJsFile(this.options);
}
readTemplates() {
const hasLib = __dirname.endsWith(path_1.default.sep + 'lib');
const builtInDir = path_1.default.join(__dirname, hasLib ? '../templates' : 'templates');
const customDir = this.options.templates || '';
this.globals = new globals_1.Globals(this.options);
this.globals.rootUrl = this.readRootUrl();
this.templates = new templates_1.Templates(builtInDir, customDir, this.handlebarsManager.instance);
this.templates.setGlobals(this.globals);
}
readRootUrl() {
if (!this.openApi.servers || this.openApi.servers.length === 0) {
return '';
}
const server = this.openApi.servers[0];
let rootUrl = server.url;
if (rootUrl == null || rootUrl.length === 0) {
return '';
}
const vars = server.variables || {};
for (const key of Object.keys(vars)) {
const value = String(vars[key].default);
rootUrl = rootUrl.replace(`{${key}}`, value);
}
return rootUrl;
}
readModels() {
const schemas = (this.openApi.components || {}).schemas || {};
for (const name of Object.keys(schemas)) {
const schema = schemas[name];
if (!schema)
continue;
// Resolve reference if needed
let resolvedSchema;
if ('$ref' in schema) {
// It's a ReferenceObject, resolve it
resolvedSchema = (0, gen_utils_1.resolveRef)(this.openApi, schema.$ref);
}
else {
// It's already a SchemaObject
resolvedSchema = schema;
}
const model = new model_1.Model(this.openApi, name, resolvedSchema, this.options);
this.models.set(name, model);
}
}
readServices() {
const defaultTag = this.options.defaultTag || 'Api';
// First read all operations, as tags are by operation
const operationsByTag = new Map();
if (this.openApi.paths) {
for (const opPath of Object.keys(this.openApi.paths)) {
const pathSpec = this.openApi.paths[opPath];
if (!pathSpec)
continue;
for (const method of gen_utils_1.HTTP_METHODS) {
const methodSpec = pathSpec[method];
if (methodSpec) {
let id = methodSpec.operationId;
if (id) {
// Make sure the id is valid
id = (0, gen_utils_1.methodName)(id);
}
else {
// Generate an id
id = (0, gen_utils_1.methodName)(`${opPath}.${method}`);
this.logger.warn(`Operation '${opPath}.${method}' didn't specify an 'operationId'. Assuming '${id}'.`);
}
if (this.operations.has(id)) {
// Duplicated id. Add a suffix
let suffix = 0;
let newId = id;
while (this.operations.has(newId)) {
newId = `${id}_${++suffix}`;
}
this.logger.warn(`Duplicate operation id '${id}'. Assuming id ${newId} for operation '${opPath}.${method}'.`);
id = newId;
}
const operation = new operation_1.Operation(this.openApi, opPath, pathSpec, method, id, methodSpec, this.options);
// Set a default tag if no tags are found
if (operation.tags.length === 0) {
this.logger.warn(`No tags set on operation '${opPath}.${method}'. Assuming '${defaultTag}'.`);
operation.tags.push(defaultTag);
}
for (const tag of operation.tags) {
let operations = operationsByTag.get(tag);
if (!operations) {
operations = [];
operationsByTag.set(tag, operations);
}
operations.push(operation);
}
// Store the operation
this.operations.set(id, operation);
}
}
}
// Then create a service per operation, as long as the tag is included
const includeTags = this.options.includeTags || [];
const excludeTags = this.options.excludeTags || [];
const tags = this.openApi.tags || [];
for (const tagName of operationsByTag.keys()) {
if (includeTags.length > 0 && !includeTags.includes(tagName)) {
this.logger.info(`Ignoring tag ${tagName} because it is not listed in the 'includeTags' option`);
continue;
}
if (excludeTags.length > 0 && excludeTags.includes(tagName)) {
this.logger.info(`Ignoring tag ${tagName} because it is listed in the 'excludeTags' option`);
continue;
}
const operations = operationsByTag.get(tagName) || [];
const tag = tags.find(t => t.name === tagName) || { name: tagName };
const service = new service_1.Service(tag, operations, this.options);
this.services.set(tag.name, service);
}
}
}
ignoreUnusedModels() {
// First, collect all type names used by services
const usedNames = new Set();
for (const service of this.services.values()) {
for (const imp of service.imports) {
if (imp.path.includes('/models/')) {
usedNames.add(imp.typeName);
}
}
for (const op of service.operations) {
for (const variant of op.variants) {
for (const imp of variant.imports) {
if (imp.path.includes('/models/')) {
usedNames.add(imp.typeName);
}
}
}
}
for (const imp of service.additionalDependencies) {
usedNames.add(imp);
}
}
// Collect dependencies on models themselves
const referencedModels = Array.from(usedNames);
usedNames.clear();
referencedModels.forEach(name => this.collectDependencies(name, usedNames));
// Then delete all unused models
for (const model of this.models.values()) {
if (!usedNames.has(model.name)) {
this.logger.debug(`Ignoring model ${model.name} because it is not used anywhere`);
this.models.delete(model.name);
}
}
}
collectDependencies(name, usedNames) {
const model = this.models.get(name);
if (!model || usedNames.has(model.name)) {
return;
}
// Add the model name itself
usedNames.add(model.name);
// Then find all referenced names and recurse
this.allReferencedNames(model.schema).forEach(n => this.collectDependencies(n, usedNames));
}
allReferencedNames(schema) {
if (!schema) {
return [];
}
// Type guard for ReferenceObject
if ('$ref' in schema) {
return [(0, gen_utils_1.simpleName)(schema.$ref)];
}
// Now we know it's a SchemaObject
const result = [];
(schema.allOf || []).forEach(s => Array.prototype.push.apply(result, this.allReferencedNames(s)));
(schema.anyOf || []).forEach(s => Array.prototype.push.apply(result, this.allReferencedNames(s)));
(schema.oneOf || []).forEach(s => Array.prototype.push.apply(result, this.allReferencedNames(s)));
if (schema.properties) {
for (const prop of Object.keys(schema.properties)) {
Array.prototype.push.apply(result, this.allReferencedNames(schema.properties[prop]));
}
}
if (typeof schema.additionalProperties === 'object') {
Array.prototype.push.apply(result, this.allReferencedNames(schema.additionalProperties));
}
// Type guard for ArraySchemaObject (has items property)
if ('type' in schema && schema.type === 'array' && 'items' in schema) {
Array.prototype.push.apply(result, this.allReferencedNames(schema.items));
}
return result;
}
validateOpenApiVersion() {
const version = this.openApi.openapi;
if (!version) {
throw new Error('OpenAPI specification version is missing');
}
// Check if it's a supported version (3.0.x or 3.1.x)
const versionRegex = /^3\.(0|1)(\.\d+)?$/;
if (!versionRegex.test(version)) {
throw new Error(`Unsupported OpenAPI version: ${version}. Only OpenAPI 3.0.x and 3.1.x are supported.`);
}
this.logger.info(`Using OpenAPI specification version: ${version}`);
}
setEndOfLine(text) {
switch (this.options.endOfLineStyle) {
case 'cr':
return eol_1.default.cr(text);
case 'lf':
return eol_1.default.lf(text);
case 'crlf':
return eol_1.default.crlf(text);
default:
return eol_1.default.auto(text);
}
}
setDefaults() {
if (this.options.module === undefined) {
this.options.module = false;
}
else if (this.options.module === true) {
this.options.module = 'ApiModule';
}
if (!this.options.enumStyle) {
this.options.enumStyle = 'alias';
}
if (this.options.enumStyle === 'alias' && this.options.enumArray == null) {
this.options.enumArray = true;
}
}
}
exports.NgOpenApiGen = NgOpenApiGen;
/**
* Parses the command-line arguments, reads the configuration file and run the generation
*/
async function runNgOpenApiGen() {
const options = (0, cmd_args_1.parseOptions)();
const refParser = new json_schema_ref_parser_1.default();
let input = options.input;
const timeout = options.fetchTimeout == null ? 20000 : options.fetchTimeout;
try {
// If input is a URL, try downloading it locally first to avoid URL-based $ref resolution issues
if (input.startsWith('http://') || input.startsWith('https://')) {
try {
const response = await fetch(input);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const specContent = await response.text();
// Write to a temporary file
const tempFile = path_1.default.join(os_1.default.tmpdir(), `ng-openapi-gen-${Date.now()}.json`);
await fs_extra_1.default.writeFile(tempFile, specContent);
input = tempFile;
// Clean up temp file after processing
process.on('exit', () => {
try {
fs_extra_1.default.unlinkSync(tempFile);
}
catch {
// Ignore cleanup errors
}
});
}
catch (fetchError) {
console.warn(`Failed to download spec from URL, will try direct parsing: ${fetchError}`);
// Fall back to original input
input = options.input;
}
}
// Parse the OpenAPI specification without dereferencing to preserve $ref properties
// The generator expects $ref properties to remain intact for proper model generation
const openApi = await refParser.parse(input, {
resolve: {
http: { timeout }
}
});
const { excludeTags = [], excludePaths = [], includeTags = [] } = options;
openApi.paths = filterPaths(openApi.paths ?? {}, excludeTags, excludePaths, includeTags);
const gen = new NgOpenApiGen(openApi, options);
gen.generate();
}
catch (err) {
console.log(`Error on API generation from ${input}: ${err}`);
process.exit(1);
}
}
function filterPaths(paths, excludeTags = [], excludePaths = [], includeTags = []) {
paths = JSON.parse(JSON.stringify(paths));
const filteredPaths = {};
for (const key in paths) {
if (!paths.hasOwnProperty(key))
continue;
if (excludePaths?.includes(key)) {
console.log(`Path ${key} is excluded by excludePaths`);
continue;
}
const pathItem = paths[key];
if (!pathItem)
continue;
let shouldRemovePath = false;
const httpMethods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
for (const method of httpMethods) {
const operation = pathItem[method];
if (!operation)
continue;
const tags = operation.tags || [];
// if tag on method in includeTags then continue
if (tags.some(tag => includeTags.includes(tag))) {
continue;
}
// if tag on method in excludeTags then remove the method
if (tags.some(tag => excludeTags.includes(tag)) || !!includeTags?.length) {
console.log(`Path ${key} is excluded by excludeTags`);
delete pathItem[method];
// if path has no method left then "should remove"
const remainingMethods = httpMethods.filter(m => pathItem[m]);
if (remainingMethods.length === 0) {
shouldRemovePath = true;
break;
}
}
}
if (shouldRemovePath) {
continue;
}
filteredPaths[key] = pathItem;
}
return filteredPaths;
}
//# sourceMappingURL=ng-openapi-gen.js.map