UNPKG

@nestjs/swagger

Version:

Nest - modern, fast, powerful node.js web framework (@swagger)

383 lines (382 loc) 18.4 kB
import { RequestMethod, VersioningType } from '@nestjs/common'; import { METHOD_METADATA, PATH_METADATA, VERSION_METADATA } from '@nestjs/common/constants.js'; import { VERSION_NEUTRAL } from '@nestjs/common/interfaces/index.js'; import { addLeadingSlash, isUndefined } from '@nestjs/common/utils/shared.utils.js'; import { MetadataScanner } from '@nestjs/core'; import { LegacyRouteConverter } from '@nestjs/core/router/legacy-route-converter.js'; import { RoutePathFactory } from '@nestjs/core/router/route-path-factory.js'; import { cloneDeep, flatten, get, head, isArray, isEmpty, mapValues, omit, omitBy, pick } from 'es-toolkit/compat'; import { parse } from 'path-to-regexp'; import { DECORATORS } from './constants.js'; import { exploreApiCallbacksMetadata } from './explorers/api-callbacks.explorer.js'; import { exploreApiExcludeControllerMetadata } from './explorers/api-exclude-controller.explorer.js'; import { exploreApiExcludeEndpointMetadata } from './explorers/api-exclude-endpoint.explorer.js'; import { exploreApiExtraModelsMetadata, exploreGlobalApiExtraModelsMetadata } from './explorers/api-extra-models.explorer.js'; import { exploreGlobalApiHeaderMetadata } from './explorers/api-headers.explorer.js'; import { exploreApiIncludeEndpointMetadata } from './explorers/api-include-endpoint.explorer.js'; import { exploreApiOperationMetadata } from './explorers/api-operation.explorer.js'; import { exploreApiParametersMetadata } from './explorers/api-parameters.explorer.js'; import { exploreApiResponseMetadata, exploreGlobalApiResponseMetadata } from './explorers/api-response.explorer.js'; import { exploreApiSecurityMetadata, exploreGlobalApiSecurityMetadata } from './explorers/api-security.explorer.js'; import { exploreApiTagsMetadata, exploreGlobalApiTagsMetadata } from './explorers/api-use-tags.explorer.js'; import { MimetypeContentWrapper } from './services/mimetype-content-wrapper.js'; import { ResponseObjectFactory } from './services/response-object-factory.js'; import { isBodyParameter } from './utils/is-body-parameter.util.js'; import { mergeAndUniq } from './utils/merge-and-uniq.util.js'; export class SwaggerExplorer { constructor(schemaObjectFactory, options = {}) { this.schemaObjectFactory = schemaObjectFactory; this.options = options; this.mimetypeContentWrapper = new MimetypeContentWrapper(); this.metadataScanner = new MetadataScanner(); this.schemas = {}; this.operationIdFactory = (controllerKey, methodKey, version) => version ? controllerKey ? `${controllerKey}_${methodKey}_${version}` : `${methodKey}_${version}` : controllerKey ? `${controllerKey}_${methodKey}` : methodKey; this.linkNameFactory = (controllerKey, methodKey, fieldKey) => controllerKey ? `${controllerKey}_${methodKey}_from_${fieldKey}` : `${methodKey}_from_${fieldKey}`; this.responseObjectFactory = new ResponseObjectFactory(this.options.standardSchemaConverter); } exploreController(wrapper, applicationConfig, options) { const { operationIdFactory, linkNameFactory } = options; this.routePathFactory = new RoutePathFactory(applicationConfig); if (operationIdFactory) { this.operationIdFactory = operationIdFactory; } if (linkNameFactory) { this.linkNameFactory = linkNameFactory; } const { instance, metatype } = wrapper; const prototype = Object.getPrototypeOf(instance); const documentResolvers = { root: [ this.exploreRoutePathAndMethod, exploreApiOperationMetadata, exploreApiParametersMetadata.bind(null, this.schemas, this.schemaObjectFactory) ], security: [exploreApiSecurityMetadata], tags: [exploreApiTagsMetadata], callbacks: [exploreApiCallbacksMetadata], responses: [ exploreApiResponseMetadata.bind(null, this.schemas, this.responseObjectFactory, { operationId: this.operationIdFactory, linkName: this.linkNameFactory }) ] }; return this.generateDenormalizedDocument(metatype, prototype, instance, documentResolvers, applicationConfig, options); } getSchemas() { return this.schemas; } generateDenormalizedDocument(metatype, prototype, instance, documentResolvers, applicationConfig, options) { const self = this; const excludeController = exploreApiExcludeControllerMetadata(metatype); if (excludeController) { return []; } const globalMetadata = this.exploreGlobalMetadata(metatype, { autoTagControllers: options.autoTagControllers }); const ctrlExtraModels = exploreGlobalApiExtraModelsMetadata(metatype); this.registerExtraModels(ctrlExtraModels); const denormalizedPaths = this.metadataScanner.scanFromPrototype(instance, prototype, (name) => { const targetCallback = prototype[name]; const includeEndpoint = exploreApiIncludeEndpointMetadata(instance, prototype, targetCallback); if (options.onlyIncludeDecoratedEndpoints && !includeEndpoint) { return; } const excludeEndpoint = exploreApiExcludeEndpointMetadata(instance, prototype, targetCallback); if (excludeEndpoint && excludeEndpoint.disable) { return; } const ctrlExtraModels = exploreApiExtraModelsMetadata(instance, prototype, targetCallback); this.registerExtraModels(ctrlExtraModels); const methodMetadata = mapValues(documentResolvers, (explorers) => explorers.reduce((metadata, fn) => { const exploredMetadata = fn.call(self, instance, prototype, targetCallback, metatype, options.globalPrefix, options.modulePath, applicationConfig, options.autoTagControllers); if (!exploredMetadata) { return metadata; } if (!isArray(exploredMetadata)) { if (Array.isArray(metadata)) { return metadata.map((item) => ({ ...item, ...exploredMetadata })); } return { ...metadata, ...exploredMetadata }; } return isArray(metadata) ? [...metadata, ...exploredMetadata] : exploredMetadata; }, {})); if (Array.isArray(methodMetadata.root)) { return methodMetadata.root.map((endpointMetadata) => { endpointMetadata = cloneDeep({ ...methodMetadata, root: endpointMetadata }); const mergedMethodMetadata = this.mergeMetadata(globalMetadata, omitBy(endpointMetadata, isEmpty)); return this.migrateOperationSchema({ responses: {}, ...omit(globalMetadata, 'chunks'), ...mergedMethodMetadata }, prototype, targetCallback, metatype); }); } const mergedMethodMetadata = this.mergeMetadata(globalMetadata, omitBy(methodMetadata, isEmpty)); return [ this.migrateOperationSchema({ responses: {}, ...omit(globalMetadata, 'chunks'), ...mergedMethodMetadata }, prototype, targetCallback, metatype) ]; }); return flatten(denormalizedPaths).filter((path) => path.root?.path); } exploreGlobalMetadata(metatype, options) { const globalExplorers = [ exploreGlobalApiTagsMetadata(options.autoTagControllers), exploreGlobalApiSecurityMetadata, exploreGlobalApiResponseMetadata.bind(null, this.schemas, this.responseObjectFactory), exploreGlobalApiHeaderMetadata ]; const globalMetadata = globalExplorers .map((explorer) => explorer.call(explorer, metatype)) .filter((val) => !isUndefined(val)) .reduce((curr, next) => { if (next.depth) { return { ...curr, chunks: (curr.chunks || []).concat(next) }; } return { ...curr, ...next }; }, {}); return globalMetadata; } exploreRoutePathAndMethod(instance, prototype, method, metatype, globalPrefix, modulePath, applicationConfig) { const methodPath = Reflect.getMetadata(PATH_METADATA, method); if (isUndefined(methodPath)) { return undefined; } const requestMethod = Reflect.getMetadata(METHOD_METADATA, method); const webhookMetadata = Reflect.getMetadata(DECORATORS.API_WEBHOOK, method); const isWebhook = Boolean(webhookMetadata); const methodVersion = Reflect.getMetadata(VERSION_METADATA, method); const versioningOptions = applicationConfig.getVersioning(); const controllerVersion = this.getVersionMetadata(metatype, versioningOptions); const versionOrVersions = methodVersion ?? controllerVersion; const versions = this.getRoutePathVersions(versionOrVersions, versioningOptions); const allRoutePaths = this.routePathFactory.create({ methodPath, methodVersion, modulePath, globalPrefix, controllerVersion, ctrlPath: this.reflectControllerPath(metatype), versioningOptions: applicationConfig.getVersioning() }, requestMethod); return flatten(allRoutePaths.map((routePath, index) => { const fullPath = this.validateRoutePath(routePath); const apiExtension = Reflect.getMetadata(DECORATORS.API_EXTENSION, method); if (requestMethod === RequestMethod.ALL) { const validMethods = [ 'get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'search' ]; return validMethods.map((requestMethod) => ({ method: requestMethod, path: fullPath === '' ? '/' : fullPath, ...(isWebhook ? { isWebhook: true, webhookName: typeof webhookMetadata === 'string' ? webhookMetadata : method.name } : {}), operationId: `${this.getOperationId(instance, method.name)}_${requestMethod.toLowerCase()}`, ...apiExtension })); } const pathVersion = versions.find((v) => fullPath.includes(`/${v}/`) || fullPath.endsWith(`/${v}`)); const isAlias = allRoutePaths.length > 1 && allRoutePaths.length !== versions.length; const methodKey = isAlias ? `${method.name}[${index}]` : method.name; const nonPathVersion = this.getNonPathVersion(methodVersion, metatype, versioningOptions); const operationVersion = pathVersion ?? nonPathVersion; return { method: RequestMethod[requestMethod].toLowerCase(), path: fullPath === '' ? '/' : fullPath, ...(isWebhook ? { isWebhook: true, webhookName: typeof webhookMetadata === 'string' ? webhookMetadata : method.name } : {}), operationId: this.getOperationId(instance, methodKey, operationVersion), ...apiExtension }; })); } getOperationId(instance, methodKey, version) { return this.operationIdFactory(instance.constructor?.name || '', methodKey, version); } getRoutePathVersions(versionValue, versioningOptions) { let versions = []; if (!versionValue || versioningOptions?.type !== VersioningType.URI) { return versions; } if (Array.isArray(versionValue)) { versions = versionValue.filter((v) => v !== VERSION_NEUTRAL); } else if (versionValue !== VERSION_NEUTRAL) { versions = [versionValue]; } const prefix = this.routePathFactory.getVersionPrefix(versioningOptions); versions = versions.map((v) => `${prefix}${v}`); return versions; } reflectControllerPath(metatype) { return Reflect.getMetadata(PATH_METADATA, metatype); } validateRoutePath(path) { if (isUndefined(path)) { return ''; } if (Array.isArray(path)) { path = head(path); } let pathWithParams = ''; try { let normalizedPath = LegacyRouteConverter.tryConvert(path, { logs: this.options.httpAdapterType !== 'fastify' }); normalizedPath = normalizedPath.replace(/::/g, '\\:'); normalizedPath = normalizedPath.replace(/\[:\]/g, '\\:'); normalizedPath = normalizedPath.replace(/\(\^([^)]+)\)/g, ''); const { tokens } = parse(normalizedPath); for (const item of tokens) { if (item.type === 'text') { pathWithParams += item.value; } else if (item.type === 'param') { pathWithParams += `{${item.name}}`; } else if (item.type === 'wildcard') { pathWithParams += `{${item.name}}`; } else if (item.type === 'group') { pathWithParams += item.tokens.reduce((acc, item) => acc + (item.type === 'text' ? item.value : `{${item.name}}`), ''); } } } catch (err) { if (err instanceof TypeError) { LegacyRouteConverter.printError(path); } throw err; } return pathWithParams === '/' ? '' : addLeadingSlash(pathWithParams); } mergeMetadata(globalMetadata, methodMetadata) { if (methodMetadata.root && !methodMetadata.root.parameters) { methodMetadata.root.parameters = []; } const deepMerge = (metadata) => (value, key) => { if (!metadata[key]) { return value; } const globalValue = metadata[key]; if (metadata.depth) { return this.deepMergeMetadata(globalValue, value, metadata.depth); } return this.mergeValues(globalValue, value); }; if (globalMetadata.chunks) { const { chunks } = globalMetadata; chunks.forEach((chunk) => { methodMetadata = mapValues(methodMetadata, deepMerge(chunk)); }); } return mapValues(methodMetadata, deepMerge(globalMetadata)); } deepMergeMetadata(globalValue, methodValue, maxDepth, currentDepthLevel = 0) { if (currentDepthLevel === maxDepth) { return this.mergeValues(globalValue, methodValue); } return mapValues(methodValue, (value, key) => { if (key in globalValue) { return this.deepMergeMetadata(globalValue[key], methodValue[key], maxDepth, currentDepthLevel + 1); } return value; }); } mergeValues(globalValue, methodValue) { if (!isArray(globalValue)) { return { ...globalValue, ...methodValue }; } return [...globalValue, ...methodValue]; } migrateOperationSchema(document, prototype, method, metatype) { const parametersObject = get(document, 'root.parameters'); const requestBodyIndex = (parametersObject || []).findIndex(isBodyParameter); if (requestBodyIndex < 0) { return document; } const requestBody = parametersObject[requestBodyIndex]; parametersObject.splice(requestBodyIndex, 1); const classConsumes = Reflect.getMetadata(DECORATORS.API_CONSUMES, metatype ?? prototype?.constructor); const methodConsumes = Reflect.getMetadata(DECORATORS.API_CONSUMES, method); let consumes = mergeAndUniq(classConsumes, methodConsumes); consumes = isEmpty(consumes) ? ['application/json'] : consumes; const keysToRemove = ['schema', 'in', 'name', 'examples', 'encoding']; document.root.requestBody = { ...omit(requestBody, keysToRemove), ...this.mimetypeContentWrapper.wrap(consumes, pick(requestBody, ['schema', 'examples', 'encoding'])) }; return document; } registerExtraModels(extraModels) { extraModels.forEach((item) => this.schemaObjectFactory.exploreModelSchema(item, this.schemas)); } getVersionMetadata(metatype, versioningOptions) { if (versioningOptions?.type === VersioningType.URI) { return (Reflect.getMetadata(VERSION_METADATA, metatype) ?? versioningOptions.defaultVersion); } } getNonPathVersion(methodVersion, metatype, versioningOptions) { if (!versioningOptions || versioningOptions.type === VersioningType.URI) { return undefined; } const version = methodVersion ?? Reflect.getMetadata(VERSION_METADATA, metatype) ?? versioningOptions.defaultVersion; if (!version || version === VERSION_NEUTRAL) { return undefined; } if (Array.isArray(version)) { const filtered = version.filter((v) => v !== VERSION_NEUTRAL); return filtered.length > 0 ? filtered[0] : undefined; } return version; } }