UNPKG

@trapi/swagger

Version:

Generate Swagger files from a decorator APIs.

1,295 lines (1,294 loc) 46.1 kB
import { BaseError } from "@ebec/core"; import { ParameterSource, TypeName, isAnyType, isArrayType, isBinaryType, isEnumType, isIntersectionType, isMetadata, isNestedObjectLiteralType, isNeverType, isPrimitiveType, isRefAliasType, isRefEnumType, isRefObjectType, isReferenceType, isTupleType, isUndefinedType, isUnionType, isVoidType } from "@trapi/core"; import { URL } from "node:url"; import { isObject, merge } from "smob"; import path from "node:path"; import fs from "node:fs"; import process from "node:process"; import YAML from "yamljs"; //#region src/core/config/utils.ts function buildSpecGeneratorOptions(input) { const servers = []; if (input.servers) if (Array.isArray(input.servers)) for (const server of input.servers) if (typeof server === "string") servers.push({ url: server }); else servers.push(server); else if (typeof input.servers === "string") servers.push({ url: input.servers }); else servers.push(input.servers); return { ...input, servers }; } //#endregion //#region src/core/constants.ts const Version = { V2: "v2", V3: "v3", V3_1: "v3.1", V3_2: "v3.2" }; const DocumentFormat = { YAML: "yaml", JSON: "json" }; const SecurityType = { API_KEY: "apiKey", BASIC: "basic", HTTP: "http", OAUTH2: "oauth2" }; //#endregion //#region src/core/error/codes.ts const SwaggerErrorCode = { SPEC_NOT_BUILT: "SWAGGER_SPEC_NOT_BUILT", ENUM_UNSUPPORTED_TYPE: "SWAGGER_ENUM_UNSUPPORTED_TYPE", BODY_PARAMETER_DUPLICATE: "SWAGGER_BODY_PARAMETER_DUPLICATE", BODY_FORM_CONFLICT: "SWAGGER_BODY_FORM_CONFLICT", PARAMETER_SOURCE_UNSUPPORTED: "SWAGGER_PARAMETER_SOURCE_UNSUPPORTED", METADATA_INVALID: "SWAGGER_METADATA_INVALID" }; //#endregion //#region src/core/error/module.ts var SwaggerError = class extends BaseError {}; //#endregion //#region src/core/schema/v2/constants.ts const ParameterSourceV2 = { BODY: "body", FORM_DATA: "formData", HEADER: "header", PATH: "path", QUERY: "query" }; //#endregion //#region src/core/schema/v3/constants.ts const ParameterSourceV3 = { COOKIE: "cookie", HEADER: "header", PATH: "path", QUERY: "query" }; //#endregion //#region src/core/schema/constants.ts const TransferProtocol = { HTTP: "http", HTTPS: "https", WS: "ws", WSS: "wss" }; const DataFormatName = { INT_32: "int32", INT_64: "int64", FLOAT: "float", DOUBLE: "double", BYTE: "byte", BINARY: "binary", DATE: "date", DATE_TIME: "date-time", PASSWORD: "password" }; const DataTypeName = { VOID: "void", INTEGER: "integer", NUMBER: "number", BOOLEAN: "boolean", STRING: "string", ARRAY: "array", OBJECT: "object", FILE: "file" }; //#endregion //#region src/core/utils/character.ts function removeDuplicateSlashes(str) { return str.replace(/([^:]\/)\/+/g, "$1"); } function removeFinalCharacter(str, character) { while (str.charAt(str.length - 1) === character && str.length > 0) str = str.slice(0, -1); return str; } //#endregion //#region src/core/utils/path.ts function normalizePathParameters(str) { str = str.replace(/<:([^/]+)>/g, "{$1}"); str = str.replace(/:([^/]+)/g, "{$1}"); str = str.replace(/<([^/]+)>/g, "{$1}"); return str; } function joinPaths(...segments) { let result = segments.join("/").replace(/\/{2,}/g, "/"); if (!result.startsWith("/")) result = `/${result}`; if (result.length > 1 && result.endsWith("/")) result = result.slice(0, -1); return result; } //#endregion //#region src/core/utils/object.ts function hasOwnProperty(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); } //#endregion //#region src/core/utils/value.ts function transformValueTo(type, value) { if (value === null) return null; switch (type) { case "integer": case "number": return Number(value); case "boolean": return !!value; default: return String(value); } } //#endregion //#region src/adapters/generator/abstract.ts var AbstractSpecGenerator = class { spec; metadata; config; constructor(metadata, config) { this.metadata = metadata; this.config = buildSpecGeneratorOptions(config); } buildInfo() { const info = { title: this.config.name || "Documentation", version: this.config.version || "1.0.0" }; if (this.config.description) info.description = this.config.description; if (this.config.license) info.license = { name: this.config.license }; return info; } buildTags() { const tagMap = /* @__PURE__ */ new Map(); for (const controller of this.metadata.controllers) { if (controller.hidden) continue; const extensions = controller.extensions ?? []; if (extensions.length === 0) continue; const tagNames = controller.tags.length > 0 ? controller.tags : [controller.name]; const extensionFields = this.transformExtensions(extensions); for (const tagName of tagNames) { let entry = tagMap.get(tagName); if (!entry) { entry = { name: tagName }; tagMap.set(tagName, entry); } Object.assign(entry, extensionFields); } } return Array.from(tagMap.values()); } getSchemaForType(type) { if (isVoidType(type) || isUndefinedType(type) || isNeverType(type)) return {}; if (isReferenceType(type)) return this.getSchemaForReferenceType(type); if (isPrimitiveType(type)) return this.getSchemaForPrimitiveType(type); if (isArrayType(type)) return this.getSchemaForArrayType(type); if (isTupleType(type)) return this.getSchemaForTupleType(type); if (isEnumType(type)) return this.getSchemaForEnumType(type); if (isUnionType(type)) return this.getSchemaForUnionType(type); if (isIntersectionType(type)) return this.getSchemaForIntersectionType(type); if (isNestedObjectLiteralType(type)) return this.getSchemaForObjectLiteralType(type); return {}; } getSchemaForEnumType(enumType) { const nullable = enumType.members.includes(null); const nonNullMembers = enumType.members.filter((m) => m !== null); const type = this.decideEnumType(nonNullMembers); const schema = { type, enum: enumType.members.map((member) => transformValueTo(type, member)) }; this.applyNullable(schema, nullable); return schema; } getSchemaForPrimitiveType(type) { return { [TypeName.ANY]: { additionalProperties: true }, [TypeName.BINARY]: { type: DataTypeName.STRING, format: DataFormatName.BINARY }, [TypeName.BOOLEAN]: { type: DataTypeName.BOOLEAN }, [TypeName.BUFFER]: { type: DataTypeName.STRING, format: DataFormatName.BYTE }, [TypeName.BYTE]: { type: DataTypeName.STRING, format: DataFormatName.BYTE }, [TypeName.DATE]: { type: DataTypeName.STRING, format: DataFormatName.DATE }, [TypeName.DATETIME]: { type: DataTypeName.STRING, format: DataFormatName.DATE_TIME }, [TypeName.DOUBLE]: { type: DataTypeName.NUMBER, format: DataFormatName.DOUBLE }, [TypeName.FILE]: { type: DataTypeName.STRING, format: DataFormatName.BINARY }, [TypeName.FLOAT]: { type: DataTypeName.NUMBER, format: DataFormatName.FLOAT }, [TypeName.BIGINT]: { type: DataTypeName.INTEGER }, [TypeName.INTEGER]: { type: DataTypeName.INTEGER, format: DataFormatName.INT_32 }, [TypeName.LONG]: { type: DataTypeName.INTEGER, format: DataFormatName.INT_64 }, [TypeName.OBJECT]: { type: DataTypeName.OBJECT, additionalProperties: true }, [TypeName.STRING]: { type: DataTypeName.STRING }, [TypeName.UNDEFINED]: {} }[type.typeName] || { type: DataTypeName.OBJECT }; } getSchemaForArrayType(arrayType) { return { type: DataTypeName.ARRAY, items: this.getSchemaForType(arrayType.elementType) }; } getSchemaForTupleType(tupleType) { if (tupleType.elements.length === 0) return { type: DataTypeName.ARRAY, items: {} }; const elementSchemas = tupleType.elements.map((el) => this.getSchemaForType(el.type)); if (elementSchemas.length === 1) return { type: DataTypeName.ARRAY, items: elementSchemas[0] }; return { type: DataTypeName.ARRAY, items: { anyOf: elementSchemas } }; } getSchemaForObjectLiteralType(objectLiteral) { const properties = this.buildProperties(objectLiteral.properties); const additionalProperties = objectLiteral.additionalProperties && this.getSchemaForType(objectLiteral.additionalProperties); const required = objectLiteral.properties.filter((prop) => prop.required && !this.isUndefinedProperty(prop)).map((prop) => prop.name); return { properties, ...additionalProperties && { additionalProperties }, ...required && required.length && { required }, type: DataTypeName.OBJECT }; } getSchemaForReferenceType(referenceType) { return { $ref: `${this.getRefPrefix()}${referenceType.refName}` }; } buildSchemaForRefAlias(referenceType) { const swaggerType = this.getSchemaForType(referenceType.type); const format = referenceType.format; return { ...swaggerType, default: referenceType.default ?? swaggerType.default, example: referenceType.example ?? swaggerType.example, format: format ?? swaggerType.format, description: referenceType.description ?? swaggerType.description, ...this.transformValidators(referenceType.validators) }; } buildSchemaForRefEnum(referenceType) { const output = { ...this.getSchemaForEnumType({ typeName: TypeName.ENUM, members: referenceType.members }), description: referenceType.description }; if (typeof referenceType.memberNames !== "undefined" && referenceType.members.length === referenceType.memberNames.length) output["x-enum-varnames"] = referenceType.memberNames; return output; } buildSchemasForReferenceTypes(extendFn) { const output = {}; for (const referenceType of Object.values(this.metadata.referenceTypes)) { switch (referenceType.typeName) { case TypeName.REF_ALIAS: output[referenceType.refName] = this.buildSchemaForRefAlias(referenceType); break; case TypeName.REF_ENUM: output[referenceType.refName] = this.buildSchemaForRefEnum(referenceType); break; case TypeName.REF_OBJECT: output[referenceType.refName] = this.buildSchemaForRefObject(referenceType); break; } if (typeof extendFn === "function") extendFn(output[referenceType.refName], referenceType); } return output; } isUndefinedProperty(input) { return isUndefinedType(input.type) || isUnionType(input.type) && input.type.members.some((el) => isUndefinedType(el)); } buildProperties(properties) { const output = {}; properties.forEach((property) => { const swaggerType = this.getSchemaForType(property.type); if (swaggerType.$ref && this.shouldStripRefSiblings()) { output[property.name] = { $ref: swaggerType.$ref }; return; } swaggerType.description = property.description; swaggerType.example = property.example; swaggerType.format = property.format || swaggerType.format; this.assignPropertyDefaults(swaggerType, property); if (property.deprecated) this.markPropertyDeprecated(swaggerType); const extensions = this.transformExtensions(property.extensions); const validators = this.transformValidators(property.validators); output[property.name] = { ...swaggerType, ...validators, ...extensions }; }); return output; } shouldStripRefSiblings() { return true; } assignPropertyDefaults(_schema, _property) {} buildSchemaForRefObject(referenceType) { const required = referenceType.properties.filter((p) => p.required && !this.isUndefinedProperty(p)).map((p) => p.name); const output = { description: referenceType.description, properties: this.buildProperties(referenceType.properties), required: required && required.length > 0 ? Array.from(new Set(required)) : void 0, type: DataTypeName.OBJECT }; if (referenceType.additionalProperties) output.additionalProperties = this.resolveAdditionalProperties(referenceType.additionalProperties); if (referenceType.example !== void 0) output.example = referenceType.example; return output; } determineTypesUsedInEnum(anEnum) { const set = /* @__PURE__ */ new Set(); for (const element of anEnum) { if (element === null) continue; set.add(typeof element); } return Array.from(set); } decideEnumType(input) { const types = this.determineTypesUsedInEnum(input); if (types.length === 1) { const value = types[0]; if (value === "string" || value === "number" || value === "boolean") return value; throw new SwaggerError({ message: `Enum contains unsupported type '${types[0] || "unknown"}'. Only string, number, and boolean values are allowed.`, code: SwaggerErrorCode.ENUM_UNSUPPORTED_TYPE }); } const unsupportedTypes = types.filter((type) => type !== "string" && type !== "number" && type !== "boolean"); if (unsupportedTypes.length > 0) throw new SwaggerError({ message: `Enum contains unsupported types: ${unsupportedTypes.join(", ")}. Only string, number, and boolean values are allowed.`, code: SwaggerErrorCode.ENUM_UNSUPPORTED_TYPE }); return "string"; } getOperationId(name) { return name.charAt(0).toUpperCase() + name.substring(1); } groupParameters(items) { const output = {}; for (const item of items) (output[item.in] ?? (output[item.in] = [])).push(item); return output; } transformExtensions(input) { if (!input) return {}; const output = {}; for (const extension of input) { const key = extension.key.startsWith("x-") ? extension.key : `x-${extension.key}`; output[key] = extension.value; } return output; } transformValidators(input) { if (!isObject(input)) return {}; const output = {}; for (const [name, validator] of Object.entries(input)) { const mapping = validator.meta?.openApi ?? DEFAULT_VALIDATOR_OPENAPI_MAPPINGS[name]; if (!mapping || mapping.kind === "ignore") continue; if (mapping.kind === "keyword") output[mapping.key] = validator.value; else if (mapping.kind === "format") output.format = mapping.format; } return output; } }; const DEFAULT_VALIDATOR_OPENAPI_MAPPINGS = { maxLength: { kind: "keyword", key: "maxLength" }, minLength: { kind: "keyword", key: "minLength" }, maximum: { kind: "keyword", key: "maximum" }, minimum: { kind: "keyword", key: "minimum" }, pattern: { kind: "keyword", key: "pattern" }, maxItems: { kind: "keyword", key: "maxItems" }, minItems: { kind: "keyword", key: "minItems" }, uniqueItems: { kind: "keyword", key: "uniqueItems" } }; //#endregion //#region src/adapters/generator/v2/module.ts function uniqueOperationId$1(base, used) { if (!used.has(base)) { used.add(base); return base; } let counter = 2; while (used.has(`${base}_${counter}`)) counter += 1; const candidate = `${base}_${counter}`; used.add(candidate); return candidate; } var V2Generator = class V2Generator extends AbstractSpecGenerator { async build() { if (typeof this.spec !== "undefined") return this.spec; let spec = { definitions: this.buildSchemasForReferenceTypes(), info: this.buildInfo(), paths: this.buildPaths(), swagger: "2.0" }; spec.securityDefinitions = this.config.securityDefinitions ? V2Generator.translateSecurityDefinitions(this.config.securityDefinitions) : {}; if (this.config.consumes) spec.consumes = this.config.consumes; if (this.config.produces) spec.produces = this.config.produces; const firstServer = this.config.servers?.[0]; if (firstServer) { const url = new URL(firstServer.url, "http://localhost:3000/"); spec.host = url.host; if (url.pathname) spec.basePath = url.pathname; } const tags = this.buildTags(); if (tags.length > 0) spec.tags = tags; if (this.config.specificationExtra) spec = merge(spec, this.config.specificationExtra); this.spec = spec; return spec; } static translateSecurityDefinitions(securityDefinitions) { const definitions = {}; for (const [key, securityDefinition] of Object.entries(securityDefinitions)) switch (securityDefinition.type) { case "http": if (securityDefinition.scheme === "basic") definitions[key] = { type: "basic" }; break; case "apiKey": definitions[key] = securityDefinition; break; case "oauth2": if (securityDefinition.flows.implicit) definitions[`${key}Implicit`] = { type: "oauth2", flow: "implicit", authorizationUrl: securityDefinition.flows.implicit.authorizationUrl, scopes: securityDefinition.flows.implicit.scopes }; if (securityDefinition.flows.password) definitions[`${key}Password`] = { type: "oauth2", flow: "password", tokenUrl: securityDefinition.flows.password.tokenUrl, scopes: securityDefinition.flows.password.scopes }; if (securityDefinition.flows.authorizationCode) definitions[`${key}AccessCode`] = { type: "oauth2", flow: "accessCode", tokenUrl: securityDefinition.flows.authorizationCode.tokenUrl, authorizationUrl: securityDefinition.flows.authorizationCode.authorizationUrl, scopes: securityDefinition.flows.authorizationCode.scopes }; if (securityDefinition.flows.clientCredentials) definitions[`${key}Application`] = { type: "oauth2", flow: "application", tokenUrl: securityDefinition.flows.clientCredentials.tokenUrl, scopes: securityDefinition.flows.clientCredentials.scopes }; break; } return definitions; } resolveAdditionalProperties(_type) { return true; } markPropertyDeprecated(schema) { schema["x-deprecated"] = true; } buildPaths() { const output = {}; const usedOperationIds = /* @__PURE__ */ new Set(); const unique = (input) => [...new Set(input)]; this.metadata.controllers.forEach((controller) => { if (controller.hidden) return; const controllerPaths = controller.paths.length === 0 ? [""] : controller.paths; controller.methods.forEach((method) => { if (method.hidden) return; method.consumes = unique([...controller.consumes, ...method.consumes]); method.produces = unique([...controller.produces, ...method.produces]); method.tags = unique([...controller.tags, ...method.tags]); if (!method.security?.length) method.security = controller.security; method.deprecated = method.deprecated || controller.deprecated; method.responses = unique([...controller.responses, ...method.responses]); for (const controllerPath of controllerPaths) { const fullPath = normalizePathParameters(joinPaths(controllerPath, method.path)); const pathItem = output[fullPath] ?? (output[fullPath] = {}); pathItem[method.method] = this.buildMethod(method, fullPath, usedOperationIds); } }); }); return output; } buildMethod(method, emittedPath, usedOperationIds) { const output = this.buildOperation(method); output.consumes = this.buildMethodConsumes(method); output.operationId = uniqueOperationId$1(method.operationId || output.operationId, usedOperationIds); output.description = method.description; if (method.summary) output.summary = method.summary; if (method.deprecated) output.deprecated = method.deprecated; if (method.tags.length) output.tags = method.tags; if (method.security?.length) output.security = method.security; const parameters = this.groupParameters(method.parameters); output.parameters = [ ...(parameters[ParameterSource.PATH] || []).filter((p) => emittedPath.includes(`{${p.name}}`)), ...parameters[ParameterSource.QUERY_PROP] || [], ...parameters[ParameterSource.HEADER] || [], ...parameters[ParameterSource.FORM_DATA] || [] ].map((p) => this.buildParameter(p)); const bodyParameters = parameters[ParameterSource.BODY] || []; if (bodyParameters.length > 1) throw new SwaggerError({ message: `Only one body parameter allowed per method, but ${bodyParameters.length} found in '${method.name}'.`, code: SwaggerErrorCode.BODY_PARAMETER_DUPLICATE }); const bodyParameter = bodyParameters[0] ? this.buildParameter(bodyParameters[0]) : void 0; const bodyPropParams = parameters[ParameterSource.BODY_PROP] || []; if (bodyPropParams.length > 0) { const schema = { type: DataTypeName.OBJECT, title: "Body", properties: {} }; const required = []; for (const bodyPropParam of bodyPropParams) { const bodyProp = this.getSchemaForType(bodyPropParam.type); bodyProp.default = bodyPropParam.default; bodyProp.description = bodyPropParam.description; bodyProp.example = bodyPropParam.examples; if (bodyProp.required) required.push(bodyPropParam.name); schema.properties[bodyPropParam.name] = bodyProp; } if (bodyParameter && bodyParameter.in === ParameterSourceV2.BODY) { if (bodyParameter.schema.type === DataTypeName.OBJECT) { bodyParameter.schema.properties = { ...bodyParameter.schema.properties || {}, ...schema.properties }; bodyParameter.schema.required = [...bodyParameter.schema.required || [], ...required]; } else bodyParameter.schema = schema; output.parameters.push(bodyParameter); } else { const parameter = { in: ParameterSourceV2.BODY, name: "body", schema }; if (required.length) parameter.schema.required = required; output.parameters.push(parameter); } } else if (bodyParameter) output.parameters.push(bodyParameter); Object.assign(output, this.transformExtensions(method.extensions)); return output; } transformParameterSource(source) { if (source === ParameterSource.BODY) return ParameterSourceV2.BODY; if (source === ParameterSource.FORM_DATA) return ParameterSourceV2.FORM_DATA; if (source === ParameterSource.HEADER) return ParameterSourceV2.HEADER; if (source === ParameterSource.PATH) return ParameterSourceV2.PATH; if (source === ParameterSource.QUERY || source === ParameterSource.QUERY_PROP) return ParameterSourceV2.QUERY; } buildParameter(input) { const sourceIn = this.transformParameterSource(input.in); if (!sourceIn) throw new SwaggerError({ message: `The parameter source '${input.in}' for parameter '${input.name}' is not supported in OpenAPI 2.0.`, code: SwaggerErrorCode.PARAMETER_SOURCE_UNSUPPORTED }); const parameter = { description: input.description, in: sourceIn, name: input.name, required: input.required }; Object.assign(parameter, this.transformExtensions(input.extensions)); if (input.in !== ParameterSource.BODY && isRefEnumType(input.type)) input.type = { typeName: TypeName.ENUM, members: input.type.members }; if (parameter.in === ParameterSourceV2.FORM_DATA && input.type.typeName === TypeName.FILE) { parameter.type = "file"; Object.assign(parameter, this.transformValidators(input.validators)); return parameter; } const parameterType = this.getSchemaForType(input.type); if (parameter.in !== ParameterSourceV2.BODY && parameterType.format) parameter.format = parameterType.format; if ((parameter.in === ParameterSourceV2.FORM_DATA || parameter.in === ParameterSourceV2.QUERY) && (input.type.typeName === TypeName.ARRAY || parameterType.type === DataTypeName.ARRAY)) parameter.collectionFormat = input.collectionFormat || this.config.collectionFormat || "multi"; if (parameter.in === ParameterSourceV2.BODY) { if (input.type.typeName === TypeName.ARRAY || parameterType.type === DataTypeName.ARRAY) parameter.schema = { items: parameterType.items, type: DataTypeName.ARRAY }; else if (input.type.typeName === TypeName.ANY) parameter.schema = { type: DataTypeName.OBJECT }; else parameter.schema = parameterType; parameter.schema = { ...parameter.schema, ...this.transformValidators(input.validators) }; return parameter; } Object.assign(parameter, this.transformValidators(input.validators)); if (input.type.typeName === TypeName.ANY) parameter.type = DataTypeName.STRING; else if (parameterType.type && !Array.isArray(parameterType.type)) parameter.type = parameterType.type; if (parameterType.items) parameter.items = parameterType.items; if (parameterType.enum) parameter.enum = parameterType.enum; if (typeof input.default !== "undefined") parameter.default = input.default; return parameter; } buildMethodConsumes(method) { if (method.consumes && method.consumes.length > 0) return method.consumes; if (this.hasFileParams(method)) return ["multipart/form-data"]; if (this.hasFormParams(method)) return ["application/x-www-form-urlencoded"]; if (this.supportsBodyParameters(method.method)) return ["application/json"]; return []; } hasFileParams(method) { return method.parameters.some((p) => p.in === ParameterSource.FORM_DATA && p.type.typeName === "file"); } hasFormParams(method) { return method.parameters.some((p) => p.in === ParameterSource.FORM_DATA); } supportsBodyParameters(method) { return [ "post", "put", "patch" ].includes(method); } applyNullable(schema, nullable) { schema["x-nullable"] = nullable; } getRefPrefix() { return "#/definitions/"; } getSchemaForIntersectionType(type) { const properties = type.members.reduce((acc, type) => { if (isRefObjectType(type)) { const refType = this.metadata.referenceTypes[type.refName]; const props = refType && refType.properties && refType.properties.reduce((pAcc, prop) => ({ ...pAcc, [prop.name]: this.getSchemaForType(prop.type) }), {}); return { ...acc, ...props }; } return { ...acc }; }, {}); return { type: DataTypeName.OBJECT, properties }; } getSchemaForUnionType(type) { const members = []; const enumTypeMember = { typeName: TypeName.ENUM, members: [] }; for (const member of type.members) { if (isEnumType(member)) enumTypeMember.members.push(...member.members); if (!isAnyType(member) && !isUndefinedType(member) && !isNeverType(member) && !isEnumType(member)) members.push(member); } if (members.length === 0 && enumTypeMember.members.length > 0) return this.getSchemaForEnumType(enumTypeMember); const isNullEnum = enumTypeMember.members.every((member) => member === null); if (members.length === 1) { const single = members[0]; if (isNullEnum) { const memberType = this.getSchemaForType(single); if (memberType.$ref) return memberType; memberType["x-nullable"] = true; return memberType; } if (enumTypeMember.members.length === 0) return this.getSchemaForType(single); } return { type: DataTypeName.OBJECT, ...isNullEnum ? { "x-nullable": true } : {} }; } buildOperation(method) { const operation = { operationId: this.getOperationId(method.name), consumes: method.consumes || [], produces: method.produces || [], responses: {} }; const produces = []; method.responses.forEach((res) => { operation.responses[res.status] = { description: res.description }; if (res.schema && !isVoidType(res.schema) && !isNeverType(res.schema)) { if (res.produces) produces.push(...res.produces); else if (isBinaryType(res.schema)) produces.push("application/octet-stream"); operation.responses[res.status].schema = this.getSchemaForType(res.schema); } const example = res.examples?.[0]; if (example?.value) operation.responses[res.status].examples = { "application/json": example.value }; }); const consumes = operation.consumes; if (consumes.length === 0) { if (method.parameters.some((parameter) => parameter.in === ParameterSource.BODY || parameter.in === ParameterSource.BODY_PROP)) consumes.push("application/json"); if (method.parameters.some((parameter) => parameter.in === ParameterSource.FORM_DATA)) consumes.push("multipart/form-data"); } if (operation.produces.length === 0 && produces.length > 0) operation.produces = [...new Set(produces)]; if (operation.produces.length === 0) operation.produces = ["application/json"]; return operation; } }; //#endregion //#region src/adapters/generator/v3/module.ts const OPENAPI_VERSION_MAP = { v3: "3.0.0", "v3.1": "3.1.0", "v3.2": "3.2.0" }; function uniqueOperationId(base, used) { if (!used.has(base)) { used.add(base); return base; } let counter = 2; while (used.has(`${base}_${counter}`)) counter += 1; const candidate = `${base}_${counter}`; used.add(candidate); return candidate; } var V3Generator = class V3Generator extends AbstractSpecGenerator { openApiVersion; constructor(metadata, config, version = "v3.2") { super(metadata, config); this.openApiVersion = OPENAPI_VERSION_MAP[version] || "3.2.0"; } async build() { if (typeof this.spec !== "undefined") return this.spec; let spec = { components: this.buildComponents(), info: this.buildInfo(), openapi: this.openApiVersion, paths: this.buildPaths(), servers: this.buildServers(), tags: this.buildTags() }; if (this.config.specificationExtra) spec = merge(spec, this.config.specificationExtra); this.spec = spec; return spec; } buildComponents() { const components = { examples: {}, headers: {}, parameters: {}, requestBodies: {}, responses: {}, schemas: this.buildSchemasForReferenceTypes((output, referenceType) => { if (referenceType.deprecated) output.deprecated = true; }), securitySchemes: {} }; if (this.config.securityDefinitions) components.securitySchemes = V3Generator.translateSecurityDefinitions(this.config.securityDefinitions); return components; } static translateSecurityDefinitions(securityDefinitions) { const output = {}; for (const [key, securityDefinition] of Object.entries(securityDefinitions)) switch (securityDefinition.type) { case "http": output[key] = securityDefinition; break; case "oauth2": output[key] = securityDefinition; break; case "apiKey": output[key] = securityDefinition; break; } return output; } buildPaths() { const output = {}; const usedOperationIds = /* @__PURE__ */ new Set(); for (const controller of this.metadata.controllers) { if (controller.hidden) continue; const controllerPaths = controller.paths.length === 0 ? [""] : controller.paths; for (const method of controller.methods) { if (method.hidden) continue; method.deprecated = method.deprecated || controller.deprecated; if (!method.security?.length) method.security = controller.security; for (const controllerPath of controllerPaths) { const path = normalizePathParameters(joinPaths(controllerPath, method.path)); const pathItem = output[path] ?? (output[path] = {}); pathItem[method.method] = this.buildMethod(controller.name, method, path, usedOperationIds); } } } return output; } buildMethod(controllerName, method, emittedPath, usedOperationIds) { const output = this.buildOperation(controllerName, method); output.description = method.description; output.summary = method.summary; output.tags = method.tags; output.operationId = uniqueOperationId(method.operationId || output.operationId, usedOperationIds); if (method.deprecated) output.deprecated = method.deprecated; if (method.security?.length) output.security = method.security; const parameters = this.groupParameters(method.parameters); const pathParams = (parameters[ParameterSource.PATH] || []).filter((p) => emittedPath.includes(`{${p.name}}`)); output.parameters = [ ...parameters[ParameterSource.QUERY_PROP] || [], ...parameters[ParameterSource.HEADER] || [], ...pathParams, ...parameters[ParameterSource.COOKIE] || [] ].map((p) => this.buildParameter(p)); const bodyParams = parameters[ParameterSource.BODY] || []; const formParams = parameters[ParameterSource.FORM_DATA] || []; if (bodyParams.length > 1) throw new SwaggerError({ message: `Only one body parameter allowed per method, but ${bodyParams.length} found in '${method.name}'.`, code: SwaggerErrorCode.BODY_PARAMETER_DUPLICATE }); if (bodyParams.length > 0 && formParams.length > 0) throw new SwaggerError({ message: `Cannot mix body and form parameters in method '${method.name}'.`, code: SwaggerErrorCode.BODY_FORM_CONFLICT }); const bodyPropParams = parameters[ParameterSource.BODY_PROP] || []; const firstBodyProp = bodyPropParams[0]; if (firstBodyProp) { if (bodyParams.length === 0) bodyParams.push({ in: ParameterSource.BODY, name: "body", description: "", parameterName: firstBodyProp.parameterName || "body", required: true, type: { typeName: TypeName.NESTED_OBJECT_LITERAL, properties: [] }, validators: {}, deprecated: false, extensions: [] }); const firstBody = bodyParams[0]; if (isNestedObjectLiteralType(firstBody.type)) for (const bodyPropParam of bodyPropParams) firstBody.type.properties.push({ default: bodyPropParam.default, validators: bodyPropParam.validators, description: bodyPropParam.description, name: bodyPropParam.name, type: bodyPropParam.type, required: bodyPropParam.required, deprecated: bodyPropParam.deprecated ?? false }); } const firstBodyParam = bodyParams[0]; if (firstBodyParam) output.requestBody = this.buildRequestBody(firstBodyParam); else if (formParams.length > 0) output.requestBody = this.buildRequestBodyWithFormData(formParams); Object.assign(output, this.transformExtensions(method.extensions)); return output; } buildRequestBodyWithFormData(parameters) { const required = []; const properties = {}; for (const parameter of parameters) { properties[parameter.name] = this.buildMediaType(parameter).schema; if (parameter.required) required.push(parameter.name); } return { required: required.length > 0, content: { "multipart/form-data": { schema: { type: DataTypeName.OBJECT, properties, ...required && required.length && { required } } } } }; } buildRequestBody(parameter) { const mediaType = this.buildMediaType(parameter); return { description: parameter.description, required: parameter.required, content: { "application/json": mediaType } }; } buildMediaType(parameter) { const examples = this.transformParameterExamples(parameter); return { schema: this.getSchemaForType(parameter.type), ...Object.keys(examples).length > 0 && { examples } }; } buildResponses(input) { const output = {}; for (const res of input) { const name = res.status || "default"; const response = { description: res.description }; output[name] = response; if (res.schema && !isVoidType(res.schema) && !isNeverType(res.schema)) { const examples = {}; if (res.examples) for (const [i, ex] of res.examples.entries()) { const label = ex.label || `example${i + 1}`; examples[label] = { value: ex.value }; } const content = response.content ?? (response.content = {}); const contentTypes = res.produces || ["application/json"]; for (const contentType of contentTypes) content[contentType] = { schema: this.getSchemaForType(res.schema), ...Object.keys(examples).length > 0 && { examples } }; } if (res.headers) { const headers = {}; if (isRefObjectType(res.headers)) headers[res.headers.refName] = { schema: this.getSchemaForReferenceType(res.headers), description: res.headers.description }; else if (isNestedObjectLiteralType(res.headers)) res.headers.properties.forEach((each) => { headers[each.name] = { schema: this.getSchemaForType(each.type), description: each.description, required: each.required }; }); response.headers = headers; } } return output; } buildOperation(_controllerName, method) { const operation = { operationId: this.getOperationId(method.name), responses: this.buildResponses(method.responses) }; if (method.description) operation.description = method.description; if (method.security?.length) operation.security = method.security; if (method.deprecated) operation.deprecated = method.deprecated; return operation; } transformParameterSource(source) { if (source === ParameterSource.COOKIE) return ParameterSourceV3.COOKIE; if (source === ParameterSource.HEADER) return ParameterSourceV3.HEADER; if (source === ParameterSource.PATH) return ParameterSourceV3.PATH; if (source === ParameterSource.QUERY_PROP || source === ParameterSource.QUERY) return ParameterSourceV3.QUERY; } buildParameter(input) { const sourceIn = this.transformParameterSource(input.in); if (!sourceIn) throw new SwaggerError({ message: `The parameter source '${input.in}' for parameter '${input.name}' is not supported in OpenAPI 3.x.`, code: SwaggerErrorCode.PARAMETER_SOURCE_UNSUPPORTED }); const parameter = { allowEmptyValue: false, deprecated: false, description: input.description, in: sourceIn, name: input.name, required: input.required, schema: { default: input.default, format: void 0, ...this.transformValidators(input.validators) } }; Object.assign(parameter, this.transformExtensions(input.extensions)); if (input.deprecated) parameter.deprecated = true; const parameterType = this.getSchemaForType(input.type); const schema = parameter.schema; if (parameterType.format) schema.format = parameterType.format; if (parameterType.$ref) { parameter.schema = parameterType; return parameter; } if (isAnyType(input.type)) schema.type = DataTypeName.STRING; else { if (parameterType.type) schema.type = parameterType.type; schema.items = parameterType.items; schema.enum = parameterType.enum; } parameter.examples = this.transformParameterExamples(input); return parameter; } transformParameterExamples(parameter) { const output = {}; if (parameter.examples) for (const [i, ex] of parameter.examples.entries()) { const label = ex.label || `example${i + 1}`; output[label] = { value: ex.value }; } return output; } buildServers() { const servers = []; const configured = this.config.servers ?? []; for (const entry of configured) { const url = new URL(entry.url, "http://localhost:3000/"); servers.push({ url: `${url.protocol}//${url.host}${url.pathname || ""}`, ...entry.description ? { description: entry.description } : {} }); } return servers; } resolveAdditionalProperties(type) { return this.getSchemaForType(type); } markPropertyDeprecated(schema) { schema.deprecated = true; } assignPropertyDefaults(schema, property) { schema.default = property.default; } buildSchemaForRefEnum(referenceType) { const typesUsed = this.determineTypesUsedInEnum(referenceType.members); if (typesUsed.length === 1) return super.buildSchemaForRefEnum(referenceType); const schema = { description: referenceType.description, anyOf: [] }; for (const element of typesUsed) schema.anyOf.push({ type: element, enum: referenceType.members.filter((e) => typeof e === element) }); return schema; } isV31OrLater() { return !this.openApiVersion.startsWith("3.0"); } shouldStripRefSiblings() { return !this.isV31OrLater(); } getSchemaForIntersectionType(type) { return { allOf: type.members.map((x) => this.getSchemaForType(x)) }; } applyNullable(schema, nullable) { if (!nullable) return; if (this.isV31OrLater()) { if (schema.type && !Array.isArray(schema.type)) schema.type = [schema.type, "null"]; else if (Array.isArray(schema.type) && !schema.type.includes("null")) schema.type = [...schema.type, "null"]; } else schema.nullable = true; } getRefPrefix() { return "#/components/schemas/"; } getSchemaForUnionType(type) { const members = []; let nullable = false; const enumMembers = {}; for (const member of type.members) { if (isEnumType(member)) for (const memberChild of member.members) { if (memberChild === null || memberChild === void 0) { nullable = true; continue; } const typeOf = typeof memberChild; if (typeOf === "string" || typeOf === "number" || typeOf === "boolean") (enumMembers[typeOf] ?? (enumMembers[typeOf] = [])).push(memberChild); } if (!isAnyType(member) && !isUndefinedType(member) && !isNeverType(member) && !isEnumType(member)) members.push(member); } const schemas = []; for (const member of members) schemas.push(this.getSchemaForType(member)); const enumMembersKeys = Object.keys(enumMembers); for (const enumMembersKey of enumMembersKeys) { const enumType = { typeName: "enum", members: enumMembers[enumMembersKey] }; schemas.push(this.getSchemaForEnumType(enumType)); } const useOneOf = members.length > 0 && enumMembersKeys.length === 0 && members.every((m) => V3Generator.isObjectLikeType(m)); const compositionKey = useOneOf ? "oneOf" : "anyOf"; if (this.isV31OrLater()) { if (nullable) schemas.push({ type: "null" }); if (schemas.length === 1) return schemas[0]; const schema = { [compositionKey]: schemas }; if (useOneOf) this.applyDiscriminator(schema, members); return schema; } if (schemas.length === 1) { const schema = schemas[0]; if (schema.$ref) return { allOf: [schema], nullable }; return { ...schema, nullable }; } const schema = { [compositionKey]: schemas, ...nullable ? { nullable } : {} }; if (useOneOf) this.applyDiscriminator(schema, members); return schema; } static isObjectLikeType(type) { if (isRefObjectType(type) || isNestedObjectLiteralType(type) || isIntersectionType(type)) return true; if (isRefAliasType(type)) return V3Generator.isObjectLikeType(type.type); return false; } applyDiscriminator(schema, members) { const discriminator = this.detectDiscriminator(members); if (discriminator) schema.discriminator = discriminator; } detectDiscriminator(members) { const resolvedMembers = members.map((m) => this.resolveDiscriminatorMember(m)); if (resolvedMembers.some((m) => !m)) return; const firstProps = resolvedMembers[0].properties; for (const prop of firstProps) { if (prop.type.typeName !== "enum") continue; if (prop.type.members.length !== 1) continue; const propName = prop.name; const mapping = {}; let isDiscriminator = true; for (const member of resolvedMembers) { const memberProp = member.properties.find((p) => p.name === propName); if (!memberProp || !memberProp.required || memberProp.type.typeName !== "enum" || memberProp.type.members.length !== 1) { isDiscriminator = false; break; } const value = String(memberProp.type.members[0]); if (mapping[value]) { isDiscriminator = false; break; } mapping[value] = `${this.getRefPrefix()}${member.refName}`; } if (isDiscriminator && Object.keys(mapping).length === members.length) return { propertyName: propName, mapping }; } } /** * Resolve a union member to its refName and properties for discriminator * detection. Accepts both refObject and refAlias members, unwrapping * aliases to find the underlying properties while preserving the * original refName for $ref mapping. */ resolveDiscriminatorMember(member) { if (!isRefObjectType(member) && !isRefAliasType(member)) return; const { refName } = member; const referenceType = this.metadata.referenceTypes[refName]; if (!referenceType) return; if (referenceType.typeName === "refObject") return { refName, properties: referenceType.properties }; if (referenceType.typeName === "refAlias") { let inner = referenceType.type; for (let depth = 0; depth < 10; depth++) { if (isRefObjectType(inner)) { const resolved = this.metadata.referenceTypes[inner.refName]; if (resolved?.typeName === "refObject") return { refName, properties: resolved.properties }; return; } if (isNestedObjectLiteralType(inner)) return { refName, properties: inner.properties }; if (isRefAliasType(inner)) { inner = inner.type; continue; } return; } } } }; //#endregion //#region src/app/module.ts function toSpecGeneratorOptionsInput(options) { const { data } = options; if (!data) return {}; return { name: data.name, version: data.version, description: data.description, license: data.license, servers: data.servers, securityDefinitions: data.securityDefinitions, consumes: data.consumes, produces: data.produces, collectionFormat: data.collectionFormat, specificationExtra: data.extra }; } async function generateSwagger(options) { const { metadata } = options; if (!isMetadata(metadata)) throw new SwaggerError({ message: "Expected `options.metadata` to be a pre-built Metadata object ({ controllers, referenceTypes }). Run `generateMetadata` from `@trapi/metadata` first, or supply your own Metadata-shaped value.", code: SwaggerErrorCode.METADATA_INVALID }); const specGeneratorOptionsInput = toSpecGeneratorOptionsInput(options); switch (options.version) { case Version.V3: case Version.V3_1: case Version.V3_2: return await new V3Generator(metadata, specGeneratorOptionsInput, options.version).build(); default: return await new V2Generator(metadata, specGeneratorOptionsInput).build(); } } //#endregion //#region src/app/save.ts const EXTENSION_PATTERN = /\.(json|ya?ml)$/i; function resolveFileName(name, format) { return `${(name ?? "swagger").replace(EXTENSION_PATTERN, "")}.${format}`; } function serialise(spec, format) { if (format === DocumentFormat.YAML) return YAML.stringify(spec, 1e3); return JSON.stringify(spec, null, 4); } async function saveSwagger(spec, options = {}) { const format = options.format ?? DocumentFormat.JSON; let cwd = process.cwd(); if (options.cwd) cwd = path.isAbsolute(options.cwd) ? options.cwd : path.join(process.cwd(), options.cwd); const name = resolveFileName(options.name, format); const filePath = path.join(cwd, name); await fs.promises.mkdir(cwd, { recursive: true }); const content = serialise(spec, format); await fs.promises.writeFile(filePath, content, { encoding: "utf-8" }); return { path: filePath, name, content }; } //#endregion export { AbstractSpecGenerator, DataFormatName, DataTypeName, DocumentFormat, ParameterSourceV2, ParameterSourceV3, SecurityType, SwaggerError, SwaggerErrorCode, TransferProtocol, V2Generator, V3Generator, Version, buildSpecGeneratorOptions, generateSwagger, hasOwnProperty, joinPaths, normalizePathParameters, removeDuplicateSlashes, removeFinalCharacter, saveSwagger, transformValueTo }; //# sourceMappingURL=index.mjs.map