@loopback/openapi-v3
Version:
Decorators that annotate LoopBack artifacts with OpenAPI v3 metadata and utilities that transform LoopBack metadata to OpenAPI v3 specifications
571 lines (501 loc) • 16.5 kB
text/typescript
// Copyright IBM Corp. and LoopBack contributors 2018,2020. All Rights Reserved.
// Node module: @loopback/openapi-v3
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
import {DecoratorFactory, MetadataInspector} from '@loopback/core';
import {
getJsonSchema,
getJsonSchemaRef,
JsonSchemaOptions,
} from '@loopback/repository-json-schema';
import {includes} from 'lodash';
import {buildResponsesFromMetadata} from './build-responses-from-metadata';
import {REQUEST_BODY_INDEX} from './decorators';
import {resolveSchema} from './generate-schema';
import {jsonToSchemaObject, SchemaRef} from './json-to-schema';
import {OAI3Keys} from './keys';
import {
ComponentsObject,
ISpecificationExtension,
isReferenceObject,
OperationObject,
OperationVisibility,
ParameterObject,
PathObject,
ReferenceObject,
RequestBodyObject,
ResponseDecoratorMetadata,
ResponseObject,
SchemaObject,
SchemasObject,
TagsDecoratorMetadata,
} from './types';
const debug = require('debug')('loopback:openapi3:metadata:controller-spec');
export interface ControllerSpec {
/**
* The base path on which the Controller API is served.
* If it is not included, the API is served directly under the host.
* The value MUST start with a leading slash (/).
*/
basePath?: string;
/**
* The available paths and operations for the API.
*/
paths: PathObject;
/**
* OpenAPI components.schemas generated from model metadata
*/
components?: ComponentsObject;
}
/**
* Data structure for REST related metadata
*/
export interface RestEndpoint {
verb: string;
path: string;
spec?: OperationObject;
}
export const TS_TYPE_KEY = 'x-ts-type';
/**
* Build the api spec from class and method level decorations
* @param constructor - Controller class
*/
function resolveControllerSpec(constructor: Function): ControllerSpec {
debug(`Retrieving OpenAPI specification for controller ${constructor.name}`);
let spec = MetadataInspector.getClassMetadata<ControllerSpec>(
OAI3Keys.CLASS_KEY,
constructor,
);
if (spec) {
debug(' using class-level spec defined via @api()', spec);
spec = DecoratorFactory.cloneDeep(spec);
} else {
spec = {paths: {}};
}
const isClassDeprecated = MetadataInspector.getClassMetadata<boolean>(
OAI3Keys.DEPRECATED_CLASS_KEY,
constructor,
);
if (isClassDeprecated) {
debug(' using class-level @deprecated()');
}
const classTags = MetadataInspector.getClassMetadata<TagsDecoratorMetadata>(
OAI3Keys.TAGS_CLASS_KEY,
constructor,
);
const classVisibility =
MetadataInspector.getClassMetadata<OperationVisibility>(
OAI3Keys.VISIBILITY_CLASS_KEY,
constructor,
);
if (classVisibility) {
debug(` using class-level @oas.visibility(): '${classVisibility}'`);
}
if (classTags) {
debug(' using class-level @oas.tags()');
}
if (classTags || isClassDeprecated || classVisibility) {
for (const path of Object.keys(spec.paths)) {
for (const method of Object.keys(spec.paths[path])) {
/* istanbul ignore else */
if (isClassDeprecated) {
spec.paths[path][method].deprecated = true;
}
/* istanbul ignore else */
if (classVisibility) {
spec.paths[path][method]['x-visibility'] = classVisibility;
}
/* istanbul ignore else */
if (classTags) {
if (spec.paths[path][method].tags?.length) {
spec.paths[path][method].tags = spec.paths[path][
method
].tags.concat(classTags.tags);
} else {
spec.paths[path][method].tags = classTags.tags;
}
}
}
}
}
let endpoints =
MetadataInspector.getAllMethodMetadata<RestEndpoint>(
OAI3Keys.METHODS_KEY,
constructor.prototype,
) ?? {};
endpoints = DecoratorFactory.cloneDeep(endpoints);
for (const op in endpoints) {
debug(' processing method %s', op);
const endpoint = endpoints[op];
const verb = endpoint.verb!;
const path = endpoint.path!;
const isMethodDeprecated = MetadataInspector.getMethodMetadata<boolean>(
OAI3Keys.DEPRECATED_METHOD_KEY,
constructor.prototype,
op,
);
if (isMethodDeprecated) {
debug(' using method-level deprecation via @deprecated()');
}
const methodVisibility =
MetadataInspector.getMethodMetadata<OperationVisibility>(
OAI3Keys.VISIBILITY_METHOD_KEY,
constructor.prototype,
op,
);
if (methodVisibility) {
debug(
` using method-level visibility via @visibility(): '${methodVisibility}'`,
);
}
const methodTags =
MetadataInspector.getMethodMetadata<TagsDecoratorMetadata>(
OAI3Keys.TAGS_METHOD_KEY,
constructor.prototype,
op,
);
if (methodTags) {
debug(' using method-level tags via @oas.tags()');
}
let endpointName = '';
/* istanbul ignore if */
if (debug.enabled) {
const className = constructor.name || '<AnonymousClass>';
const fullMethodName = `${className}.${op}`;
endpointName = `${fullMethodName} (${verb} ${path})`;
}
const defaultResponse = {
'200': {
description: `Return value of ${constructor.name}.${op}`,
},
};
let operationSpec = endpoint.spec;
const decoratedResponses =
MetadataInspector.getMethodMetadata<ResponseDecoratorMetadata>(
OAI3Keys.RESPONSE_METHOD_KEY,
constructor.prototype,
op,
);
if (!operationSpec) {
if (decoratedResponses) {
operationSpec = buildResponsesFromMetadata(decoratedResponses);
} else {
// The operation was defined via @operation(verb, path) with no spec
operationSpec = {
responses: defaultResponse,
};
}
endpoint.spec = operationSpec;
} else if (decoratedResponses) {
operationSpec.responses = buildResponsesFromMetadata(
decoratedResponses,
operationSpec,
).responses;
}
if (classTags && !operationSpec.tags) {
operationSpec.tags = classTags.tags;
}
if (methodTags) {
if (operationSpec.tags?.length) {
operationSpec.tags = operationSpec.tags.concat(methodTags.tags);
} else {
operationSpec.tags = methodTags.tags;
}
}
debug(' operation for method %s: %j', op, endpoint);
debug(' spec responses for method %s: %o', op, operationSpec.responses);
// Precedence: method decorator > class decorator > operationSpec > undefined
const deprecationSpec =
isMethodDeprecated ??
isClassDeprecated ??
operationSpec.deprecated ??
false;
if (deprecationSpec) {
operationSpec.deprecated = true;
}
// Precedence: method decorator > class decorator > operationSpec > 'documented'
const visibilitySpec: OperationVisibility =
methodVisibility ?? classVisibility ?? operationSpec['x-visibility'];
if (visibilitySpec) {
operationSpec['x-visibility'] = visibilitySpec;
}
for (const code in operationSpec.responses) {
const responseObject: ResponseObject | ReferenceObject =
operationSpec.responses[code];
if (isReferenceObject(responseObject)) continue;
const content = responseObject.content ?? {};
for (const c in content) {
debug(' processing response code %s with content-type %', code, c);
processSchemaExtensions(spec, content[c].schema);
}
}
debug(' processing parameters for method %s', op);
let params = MetadataInspector.getAllParameterMetadata<ParameterObject>(
OAI3Keys.PARAMETERS_KEY,
constructor.prototype,
op,
);
debug(' parameters for method %s: %j', op, params);
const paramIndexes: number[] = [];
if (params != null) {
params = DecoratorFactory.cloneDeep<ParameterObject[]>(params);
/**
* If a controller method uses dependency injection, the parameters
* might be sparse. For example,
* ```ts
* class MyController {
* greet(
* @inject('prefix') prefix: string,
* @param.query.string('name) name: string) {
* return `${prefix}`, ${name}`;
* }
* ```
*/
operationSpec.parameters = params
.filter((p, i) => {
if (p == null) return false;
paramIndexes.push(i);
return true;
})
.map(p => {
// Per OpenAPI spec, `required` must be `true` for path parameters
if (p.in === 'path') {
p.required = true;
}
return p;
});
}
debug(' processing requestBody for method %s', op);
let requestBodies =
MetadataInspector.getAllParameterMetadata<RequestBodyObject>(
OAI3Keys.REQUEST_BODY_KEY,
constructor.prototype,
op,
);
const bodyIndexes: number[] = [];
if (requestBodies != null)
requestBodies = requestBodies.filter((p, i) => {
if (p == null) return false;
bodyIndexes.push(i);
return true;
});
let requestBody: RequestBodyObject;
if (requestBodies) {
if (requestBodies.length > 1)
throw new Error(
'An operation should only have one parameter decorated by @requestBody',
);
requestBody = requestBodies[0];
debug(' requestBody for method %s: %j', op, requestBody);
/* istanbul ignore else */
if (requestBody) {
// Find the relative index of the request body
const bodyIndex = bodyIndexes[0];
let index = 0;
for (; index < paramIndexes.length; index++) {
if (bodyIndex < paramIndexes[index]) break;
}
if (index !== 0) {
requestBody[REQUEST_BODY_INDEX] = index;
}
operationSpec.requestBody = requestBody;
/* istanbul ignore else */
const content = requestBody.content || {};
for (const mediaType in content) {
processSchemaExtensions(spec, content[mediaType].schema);
}
}
}
operationSpec['x-operation-name'] = op;
operationSpec['x-controller-name'] =
operationSpec['x-controller-name'] || constructor.name;
if (operationSpec.operationId == null) {
// Build the operationId as `<controllerName>.<operationName>`
// Please note API explorer (https://github.com/swagger-api/swagger-js/)
// will normalize it as `<controllerName>_<operationName>`
operationSpec.operationId =
operationSpec['x-controller-name'] +
'.' +
operationSpec['x-operation-name'];
}
if (!spec.paths[path]) {
spec.paths[path] = {};
}
if (spec.paths[path][verb]) {
// Operations from subclasses override those from the base
debug(` Overriding ${endpointName} - endpoint was already defined`);
}
debug(` adding ${endpointName}`, operationSpec);
spec.paths[path][verb] = {...endpoint.spec, ...operationSpec};
debug(` inferring schema object for method %s`, op);
const opMetadata = MetadataInspector.getDesignTypeForMethod(
constructor.prototype,
op,
);
const paramTypes = opMetadata?.parameterTypes ?? [];
const isComplexType = (ctor: Function) =>
!includes([String, Number, Boolean, Array, Object], ctor);
for (const p of paramTypes) {
if (isComplexType(p)) {
generateOpenAPISchema(spec, p);
}
}
}
return spec;
}
declare type MixKey = 'allOf' | 'anyOf' | 'oneOf';
const SCHEMA_ARR_KEYS: MixKey[] = ['allOf', 'anyOf', 'oneOf'];
/**
* Resolve the x-ts-type in the schema object
* @param spec - Controller spec
* @param schema - Schema object
*/
function processSchemaExtensions(
spec: ControllerSpec,
schema?: SchemaObject | (ReferenceObject & ISpecificationExtension),
) {
debug(' processing extensions in schema: %j', schema);
if (!schema) return;
assignRelatedSchemas(spec, schema.definitions);
delete schema.definitions;
/**
* check if we have been provided a `not`
* `not` is valid in many cases- here we're checking for
* `not: { schema: {'x-ts-type': SomeModel }}
*/
if (schema.not) {
processSchemaExtensions(spec, schema.not);
}
/**
* check for schema.allOf, schema.oneOf, schema.anyOf arrays first.
* You cannot provide BOTH a defnintion AND one of these keywords.
*/
/* istanbul ignore else */
const hasOwn = (prop: string) =>
schema != null && Object.prototype.hasOwnProperty.call(schema, prop);
if (SCHEMA_ARR_KEYS.some(k => hasOwn(k))) {
SCHEMA_ARR_KEYS.forEach((k: MixKey) => {
/* istanbul ignore else */
if (schema?.[k] && Array.isArray(schema[k])) {
schema[k].forEach((r: (SchemaObject | ReferenceObject)[]) => {
processSchemaExtensions(spec, r);
});
}
});
} else {
if (isReferenceObject(schema)) return;
const tsType = schema[TS_TYPE_KEY];
debug(' %s => %o', TS_TYPE_KEY, tsType);
if (tsType) {
schema = resolveSchema(tsType, schema);
if (schema.$ref) generateOpenAPISchema(spec, tsType);
// We don't want a Function type in the final spec.
delete schema[TS_TYPE_KEY];
return;
}
if (schema.type === 'array') {
processSchemaExtensions(spec, schema.items);
} else if (schema.type === 'object') {
if (schema.properties) {
for (const p in schema.properties) {
processSchemaExtensions(spec, schema.properties[p]);
}
}
}
}
}
/**
* Generate json schema for a given x-ts-type
* @param spec - Controller spec
* @param tsType - TS Type
*/
function generateOpenAPISchema(spec: ControllerSpec, tsType: Function) {
spec.components = spec.components ?? {};
spec.components.schemas = spec.components.schemas ?? {};
if (tsType.name in spec.components.schemas) {
// Preserve user-provided definitions
debug(' skipping type %j as already defined', tsType.name || tsType);
return;
}
const jsonSchema = getJsonSchema(tsType);
const openapiSchema = jsonToSchemaObject(jsonSchema);
assignRelatedSchemas(spec, openapiSchema.definitions);
delete openapiSchema.definitions;
debug(' defining schema for %j: %j', tsType.name, openapiSchema);
spec.components.schemas[tsType.name] = openapiSchema;
}
/**
* Assign related schemas from definitions to the controller spec
* @param spec - Controller spec
* @param definitions - Schema definitions
*/
function assignRelatedSchemas(
spec: ControllerSpec,
definitions?: SchemasObject,
) {
if (!definitions) return;
debug(
' assigning related schemas: ',
definitions && Object.keys(definitions),
);
spec.components = spec.components ?? {};
spec.components.schemas = spec.components.schemas ?? {};
const outputSchemas = spec.components.schemas;
for (const key in definitions) {
// Preserve user-provided definitions
if (key in outputSchemas) continue;
const relatedSchema = definitions[key];
debug(' defining referenced schema for %j: %j', key, relatedSchema);
outputSchemas[key] = relatedSchema;
}
}
/**
* Get the controller spec for the given class
* @param constructor - Controller class
*/
export function getControllerSpec(constructor: Function): ControllerSpec {
let spec = MetadataInspector.getClassMetadata<ControllerSpec>(
OAI3Keys.CONTROLLER_SPEC_KEY,
constructor,
{ownMetadataOnly: true},
);
if (!spec) {
spec = resolveControllerSpec(constructor);
MetadataInspector.defineMetadata(
OAI3Keys.CONTROLLER_SPEC_KEY.key,
spec,
constructor,
);
}
return spec;
}
/**
* Describe the provided Model as a reference to a definition shared by multiple
* endpoints. The definition is included in the returned schema.
*
* @example
*
* ```ts
* const schema = {
* $ref: '#/components/schemas/Product',
* definitions: {
* Product: {
* title: 'Product',
* properties: {
* // etc.
* }
* }
* }
* }
* ```
*
* @param modelCtor - The model constructor (e.g. `Product`)
* @param options - Additional options
*/
export function getModelSchemaRef<T extends object>(
modelCtor: Function & {prototype: T},
options?: JsonSchemaOptions<T>,
): SchemaRef {
const jsonSchema = getJsonSchemaRef(modelCtor, options);
return jsonToSchemaObject(jsonSchema) as SchemaRef;
}