@loopback/cli
Version:
Yeoman generator for LoopBack 4
503 lines (471 loc) • 15.4 kB
JavaScript
// Copyright IBM Corp. and LoopBack contributors 2018,2020. All Rights Reserved.
// Node module: @loopback/cli
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
;
const debug = require('../../lib/debug')('openapi-generator');
const {mapSchemaType, registerSchema} = require('./schema-helper');
const {
isExtension,
titleCase,
debugJson,
toFileName,
printSpecObject,
camelCase,
escapeIdentifier,
} = require('./utils');
const {validRegex} = require('../../lib/utils');
const HTTP_VERBS = [
'get',
'put',
'post',
'delete',
'options',
'head',
'patch',
'trace',
];
/**
* Check if a key is an http verb
* @param {string} key
*/
function isHttpVerb(key) {
return key && HTTP_VERBS.includes(key.toLowerCase());
}
/**
* Find the tag description by name
* @param {object} apiSpec API spec object
* @param {string} tag Tag name
*/
function getTagDescription(apiSpec, tag) {
if (Array.isArray(apiSpec.tags)) {
const tagEntry = apiSpec.tags.find(t => t.name === tag);
return tagEntry && tagEntry.description;
}
return undefined;
}
/**
* Merge path level parameters into the operation level
* @param {OperationObject} operationSpec Operation spec
* @param {ParameterObject[]} pathLevelParams Path level parameters
*/
function mergeParameters(operationSpec, pathLevelParams) {
if (!pathLevelParams || pathLevelParams.length === 0) return;
for (const p of pathLevelParams) {
operationSpec.parameters = operationSpec.parameters || [];
let found = false;
for (const param of operationSpec.parameters) {
if (p.name === param.name && p.in === param.in) {
// The parameter has been overridden at operation level
found = true;
break;
}
}
if (!found) {
operationSpec.parameters.push(p);
}
}
}
/**
* Group operations by controller class name
* @param {object} apiSpec OpenAPI 3.x spec
*/
function groupOperationsByController(apiSpec) {
const operationsMapByController = {};
if (apiSpec.paths == null) return operationsMapByController;
for (const path in apiSpec.paths) {
if (isExtension(path)) continue;
debug('Path: %s', path);
const pathLevelParams = apiSpec.paths[path].parameters;
for (const verb in apiSpec.paths[path]) {
if (isExtension(verb) || !isHttpVerb(verb)) continue;
debug('Verb: %s', verb);
const op = apiSpec.paths[path][verb];
mergeParameters(op, pathLevelParams);
const operation = {
path,
verb,
spec: op,
};
debugJson('Operation', operation);
// Default to `openapi` if no tags are present
let controllers = ['OpenApiController'];
if (op['x-controller-name']) {
controllers = [op['x-controller-name']];
} else if (Array.isArray(op.tags) && op.tags.length) {
// Only add the operation to first tag to avoid duplicate routes
controllers = [titleCase(op.tags[0] + 'Controller')];
}
controllers.forEach((c, index) => {
/**
* type ControllerSpec = {
* tag?: string;
* description?: string;
* operations: Operation[]
* }
*/
let controllerSpec = operationsMapByController[c];
if (!controllerSpec) {
controllerSpec = {operations: [operation]};
if (op.tags && op.tags[index]) {
controllerSpec.tag = op.tags[index];
controllerSpec.description =
getTagDescription(apiSpec, controllerSpec.tag) || '';
}
const apiSpecJson = printSpecObject({
components: apiSpec.components,
paths: {},
});
controllerSpec.decoration = `@api(${apiSpecJson})`;
operationsMapByController[c] = controllerSpec;
} else {
controllerSpec.operations.push(operation);
}
});
}
}
return operationsMapByController;
}
/**
* Get the method name for an operation spec. If `x-operation-name` is set, use it
* as-is. Otherwise, derive the name from `operationId`.
*
* @param {object} opSpec OpenAPI operation spec
* @internal
*/
function getMethodName(opSpec) {
let methodName;
if (opSpec['x-operation-name']) {
methodName = opSpec['x-operation-name'];
} else if (opSpec.operationId) {
methodName = escapeIdentifier(opSpec.operationId);
} else {
throw new Error(
'Could not infer method name from OpenAPI Operation Object. OpenAPI Operation Objects must have either `x-operation-name` or `operationId`.',
);
}
return camelCase(methodName);
}
function registerAnonymousSchema(names, schema, typeRegistry) {
if (!typeRegistry.promoteAnonymousSchemas) {
// Skip anonymous schemas
return;
}
// Skip referenced schemas
if (schema['x-$ref']) return;
// Only map object/array types
if (
schema.properties ||
schema.type === 'object' ||
schema.type === 'array'
) {
if (typeRegistry.anonymousSchemaNames == null) {
typeRegistry.anonymousSchemaNames = new Set();
}
// Infer the schema name
let schemaName;
if (Array.isArray(names)) {
schemaName = names.join('-');
} else if (typeof names === 'string') {
schemaName = names;
}
if (!schemaName && schema.title) {
schemaName = schema.title;
}
schemaName = camelCase(schemaName);
// Make sure the schema name is unique
let index = 1;
while (typeRegistry.anonymousSchemaNames.has(schemaName)) {
schemaName = schemaName + index++;
}
typeRegistry.anonymousSchemaNames.add(schemaName);
registerSchema(schemaName, schema, typeRegistry);
}
}
/**
* Build method spec for an operation
* @param {object} OpenAPI operation
*/
function buildMethodSpec(controllerSpec, op, options) {
const methodName = getMethodName(op.spec);
const opName = op.spec['x-operation-name'] || op.spec['operationId'];
controllerSpec.methodMapping[methodName] = opName;
const comments = [];
let args = [];
let interfaceArgs = [];
const namedParameters = [];
const parameters = op.spec.parameters;
// Keep track of param names to avoid duplicates
const paramNames = {};
const interfaceParamNames = {};
if (parameters) {
args = parameters.map(p => {
const {paramType, argName} = buildParameter(p, paramNames, comments);
const paramJson = printSpecObject(p);
const optionalType = !p.required ? ' | undefined' : '';
return `@param(${paramJson}) ${argName}: ${paramType.signature}${optionalType}`;
});
interfaceArgs = parameters.map(p => {
const param = buildParameter(p, interfaceParamNames);
namedParameters.push(param);
const {paramType, argName} = param;
const optionalType = !p.required ? ' | undefined' : '';
return `${argName}: ${paramType.signature}${optionalType}`;
});
}
if (op.spec.requestBody) {
/**
* requestBody:
* description: Pet to add to the store
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/NewPet'
*/
let bodyType = {signature: 'unknown'};
const content = op.spec.requestBody.content;
const contentType =
content &&
(content['application/json'] || content[Object.keys(content)[0]]);
let bodyName = 'requestBody';
if (bodyName in paramNames) {
bodyName = `${bodyName}${paramNames[bodyName]++}`;
}
bodyName = escapeIdentifier(bodyName);
if (contentType && contentType.schema) {
registerAnonymousSchema(
[methodName, bodyName],
contentType.schema,
options,
);
bodyType = mapSchemaType(contentType.schema, options);
addImportsForType(bodyType);
}
const bodyParam = bodyName; // + (op.spec.requestBody.required ? '' : '?');
// Add body as the last param
const bodySpecJson = printSpecObject(op.spec.requestBody);
args.push(
`@requestBody(${bodySpecJson}) ${bodyParam}: ${bodyType.signature}`,
);
interfaceArgs.push(`${bodyParam}: ${bodyType.signature}`);
namedParameters.push({
paramName: 'requestBody',
paramType: bodyType,
argName: bodyParam,
});
let bodyDescription = op.spec.requestBody.description || '';
bodyDescription = bodyDescription ? ` ${bodyDescription}` : '';
comments.push(`@param ${bodyName}${bodyDescription}`);
}
let returnType = {signature: 'unknown'};
const responses = op.spec.responses;
if (responses) {
/**
* responses:
* '200':
* description: pet response
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Pet'
*/
for (const code in responses) {
if (isExtension(code)) continue;
if (code !== '200' && code !== '201') continue;
const content = responses[code].content;
const contentType =
content &&
(content['application/json'] || content[Object.keys(content)[0]]);
if (contentType && contentType.schema) {
registerAnonymousSchema(
[methodName, 'responseBody'],
contentType.schema,
options,
);
returnType = mapSchemaType(contentType.schema, options);
addImportsForType(returnType);
comments.push(`@returns ${responses[code].description || ''}`);
break;
}
}
}
const signature = `async ${methodName}(${args.join(', ')}): Promise<${
returnType.signature
}>`;
const signatureForInterface = `${methodName}(${interfaceArgs.join(
', ',
)}): Promise<${returnType.signature}>`;
const argTypes = namedParameters
.map(p => {
if (p.paramName.match(validRegex)) {
return `${p.paramName}: ${p.paramType.signature}`;
}
return `'${p.paramName}': ${p.paramType.signature}`;
})
.join('; ');
const signatureForNamedParams = `${methodName}(params: { ${argTypes} }): Promise<${returnType.signature}>`;
comments.unshift(op.spec.description || '', '');
// Normalize path variable names to alphanumeric characters including the
// underscore (Equivalent to [A-Za-z0-9_]). Please note `@loopback/rest`
// does not allow other characters that don't match `\w`.
const opPath = op.path.replace(/\{[^\}]+\}/g, varName =>
varName.replace(/[^\w\{\}]+/g, '_'),
);
const opSpecJson = printSpecObject(op.spec);
const methodSpec = {
description: op.spec.description,
comments,
decoration: `@operation('${op.verb}', '${opPath}', ${opSpecJson})`,
signature,
signatureForInterface,
signatureForNamedParams,
};
if (op.spec['x-implementation']) {
methodSpec.implementation = op.spec['x-implementation'];
}
if (!methodSpec.implementation) {
const methodParameters = {};
methodParameters[methodName] = [];
if (parameters) {
parameters.forEach(param => {
methodParameters[methodName].push(param.name);
});
}
if (op.spec.requestBody) {
methodParameters[methodName].push('_requestBody');
}
controllerSpec.serviceClassNameCamelCase = camelCase(
controllerSpec.serviceClassName,
);
methodSpec.implementation = `return this.${
controllerSpec.serviceClassNameCamelCase
}.${methodName}(${methodParameters[methodName].join(', ')});`;
}
return methodSpec;
/**
* Build the parameter
* @param {object} paramSpec
* @param {string[]} names
* @param {string[]} _comments
*/
function buildParameter(paramSpec, names, _comments) {
let argName = escapeIdentifier(paramSpec.name);
if (argName in names) {
argName = `${argName}${names[argName]++}`;
} else {
names[argName] = 1;
}
registerAnonymousSchema([methodName, argName], paramSpec.schema, options);
let propSchema = paramSpec.schema;
if (propSchema == null) {
/*
{
name: 'where',
in: 'query',
content: { 'application/json': { schema: [Object] } }
}
*/
const content = paramSpec.content;
const json = content && content['application/json'];
propSchema = json && json.schema;
if (propSchema == null && content) {
for (const contentProperty in content) {
propSchema = contentProperty.schema;
if (propSchema) break;
}
}
}
propSchema = propSchema || {type: 'string'};
const paramType = mapSchemaType(propSchema, options);
addImportsForType(paramType);
if (Array.isArray(_comments)) {
_comments.push(`@param ${argName} ${paramSpec.description || ''}`);
}
// Normalize parameter name to match `\w`
let paramName = paramSpec.name;
if (paramSpec.in === 'path') {
paramName = paramName.replace(/[^\w]+/g, '_');
}
return {paramName, paramType, argName};
}
function addImportsForType(typeSpec) {
if (typeSpec.className && typeSpec.import) {
const importStmt = typeSpec.import.replace('./', '../models/');
if (!controllerSpec.imports.includes(importStmt)) {
controllerSpec.imports.push(importStmt);
}
}
if (!typeSpec.className && Array.isArray(typeSpec.imports)) {
typeSpec.imports.forEach(i => {
i = i.replace('./', '../models/');
if (!controllerSpec.imports.includes(i)) {
controllerSpec.imports.push(i);
}
});
}
}
}
/**
* Build an array of controller specs
* @param {object} operationsMapByController
*/
function buildControllerSpecs(operationsMapByController, options) {
const controllerSpecs = [];
for (const controller in operationsMapByController) {
const entry = operationsMapByController[controller];
const controllerSpec = {
tag: entry.tag || '',
decoration: entry.decoration,
description: entry.description || '',
className: controller,
// Class name for service proxy
serviceClassName: getBaseName(controller, 'Controller') + 'Service',
imports: [],
methodMapping: {},
};
controllerSpec.methods = entry.operations.map(op =>
buildMethodSpec(controllerSpec, op, options),
);
controllerSpec.methodMappingObject = printSpecObject(
controllerSpec.methodMapping,
);
controllerSpecs.push(controllerSpec);
}
return controllerSpecs;
}
/**
* Generate an array of controller specs for the openapi spec
* @param {object} apiSpec
*/
function generateControllerSpecs(apiSpec, options) {
const operationsMapByController = groupOperationsByController(apiSpec);
return buildControllerSpecs(operationsMapByController, options);
}
function getControllerFileName(controllerName) {
const name = getBaseName(controllerName, 'Controller');
return toFileName(name) + '.controller.ts';
}
/**
* Get the base name by trimming the suffix
* @param {string} fullName
* @param {string} suffix
*/
function getBaseName(fullName, suffix) {
if (fullName.endsWith(suffix)) {
return fullName.substring(0, fullName.length - suffix.length);
}
return fullName;
}
function getServiceFileName(serviceName) {
const name = getBaseName(serviceName, 'Service');
return toFileName(name) + '.service.ts';
}
module.exports = {
getControllerFileName,
getMethodName,
getServiceFileName,
generateControllerSpecs,
};