UNPKG

@readme/openapi-parser

Version:

Swagger 2.0 and OpenAPI 3.x parser and validator for Node and browsers

840 lines (828 loc) 29.3 kB
// src/index.ts import { $RefParser, dereferenceInternal, MissingPointerError } from "@apidevtools/json-schema-ref-parser"; // src/lib/index.ts var pathParameterTemplateRegExp = /\{([^/}]+)}/g; var supportedHTTPMethods = ["get", "post", "put", "delete", "patch", "options", "head", "trace"]; var swaggerHTTPMethods = ["get", "put", "post", "delete", "options", "head", "patch"]; function isSwagger(schema) { return "swagger" in schema && schema.swagger !== void 0; } function isOpenAPI(schema) { return "openapi" in schema && schema.openapi !== void 0; } function isOpenAPI30(schema) { return "openapi" in schema && schema.openapi !== void 0 && schema.openapi.startsWith("3.0"); } function isOpenAPI31(schema) { return "openapi" in schema && schema.openapi !== void 0 && schema.openapi.startsWith("3.1"); } function getSpecificationName(api) { return isSwagger(api) ? "Swagger" : "OpenAPI"; } // src/util.ts import { getJsonSchemaRefParserDefaultOptions } from "@apidevtools/json-schema-ref-parser"; // src/repair.ts function fixServers(server, path) { if (server && "url" in server && server.url && server.url.startsWith("/")) { try { const inUrl = new URL(path); server.url = `${inUrl.protocol}//${inUrl.hostname}${server.url}`; } catch { } } } function fixOasRelativeServers(schema, filePath) { if (!schema || !isOpenAPI(schema) || !filePath || !filePath.startsWith("http:") && !filePath.startsWith("https:")) { return; } if (schema.servers) { schema.servers.map((server) => fixServers(server, filePath)); } ["paths", "webhooks"].forEach((component) => { if (component in schema) { const schemaElement = schema.paths || {}; Object.keys(schemaElement).forEach((path) => { const pathItem = schemaElement[path] || {}; Object.keys(pathItem).forEach((opItem) => { const pathItemElement = pathItem[opItem]; if (!pathItemElement) { return; } if (opItem === "servers" && Array.isArray(pathItemElement)) { pathItemElement.forEach((server) => { fixServers(server, filePath); }); return; } if (supportedHTTPMethods.includes(opItem) && typeof pathItemElement === "object" && "servers" in pathItemElement && Array.isArray(pathItemElement.servers)) { pathItemElement.servers.forEach((server) => { fixServers(server, filePath); }); } }); }); } }); } // src/util.ts function repairSchema(schema, filePath) { if (isOpenAPI(schema)) { fixOasRelativeServers(schema, filePath); } } function normalizeArguments(api) { return { path: typeof api === "string" ? api : "", schema: typeof api === "object" ? api : void 0 }; } function convertOptionsForParser(options) { const parserOptions = getJsonSchemaRefParserDefaultOptions(); return { ...parserOptions, dereference: { ...parserOptions.dereference, circular: options?.dereference && "circular" in options.dereference ? options.dereference.circular : parserOptions.dereference.circular, onCircular: options?.dereference?.onCircular || parserOptions.dereference.onCircular, onDereference: options?.dereference?.onDereference || parserOptions.dereference.onDereference, // OpenAPI 3.1 allows for `summary` and `description` properties at the same level as a `$ref` // pointer to be preserved when that `$ref` pointer is dereferenced. The default behavior of // `json-schema-ref-parser` is to discard these properties but this option allows us to // override that behavior. preservedProperties: ["summary", "description"] }, resolve: { ...parserOptions.resolve, external: options?.resolve && "external" in options.resolve ? options.resolve.external : parserOptions.resolve.external, file: options?.resolve && "file" in options.resolve ? options.resolve.file : parserOptions.resolve.file, http: { ...typeof parserOptions.resolve.http === "object" ? parserOptions.resolve.http : {}, timeout: options?.resolve?.http && "timeout" in options.resolve.http ? options.resolve.http.timeout : 5e3 } }, timeoutMs: options?.timeoutMs }; } // src/validators/schema.ts import betterAjvErrors from "@readme/better-ajv-errors"; import { openapi } from "@readme/openapi-schemas"; import Ajv from "ajv/dist/2020.js"; import AjvDraft4 from "ajv-draft-04"; // src/lib/reduceAjvErrors.ts function reduceAjvErrors(errors) { const flattened = /* @__PURE__ */ new Map(); errors.forEach((err) => { if (["must have required property '$ref'", "must match exactly one schema in oneOf"].includes(err.message)) { return; } if (!flattened.size) { flattened.set(err.instancePath, err); return; } else if (flattened.has(err.instancePath)) { return; } let shouldRecordError = true; flattened.forEach((flat) => { if (flat.instancePath.includes(err.instancePath)) { shouldRecordError = false; } }); if (shouldRecordError) { flattened.set(err.instancePath, err); } }); if (!flattened.size) { return errors; } return [...flattened.values()]; } // src/validators/schema.ts var LARGE_SPEC_ERROR_CAP = 20; var LARGE_SPEC_SIZE_CAP = 5e6; function initializeAjv(draft04 = true) { const opts = { allErrors: true, strict: false, validateFormats: false }; if (draft04) { return new AjvDraft4(opts); } return new Ajv(opts); } function validateSchema(api, options = {}) { let ajv; let schema; const specificationName = getSpecificationName(api); if (isSwagger(api)) { schema = openapi.v2; ajv = initializeAjv(); } else if (isOpenAPI31(api)) { schema = openapi.v31legacy; const schemaDynamicRef = schema.$defs.schema; if ("$dynamicAnchor" in schemaDynamicRef) { delete schemaDynamicRef.$dynamicAnchor; } schema.$defs.components.properties.schemas.additionalProperties = schemaDynamicRef; schema.$defs.header.dependentSchemas.schema.properties.schema = schemaDynamicRef; schema.$defs["media-type"].properties.schema = schemaDynamicRef; schema.$defs.parameter.properties.schema = schemaDynamicRef; ajv = initializeAjv(false); } else { schema = openapi.v3; ajv = initializeAjv(); } const isValid = ajv.validate(schema, api); if (isValid) { return { valid: true, warnings: [], specification: specificationName }; } let additionalErrors = 0; let reducedErrors = reduceAjvErrors(ajv.errors); if (reducedErrors.length >= LARGE_SPEC_ERROR_CAP) { try { if (JSON.stringify(api).length >= LARGE_SPEC_SIZE_CAP) { additionalErrors = reducedErrors.length - 20; reducedErrors = reducedErrors.slice(0, 20); } } catch (error) { } } try { const errors = betterAjvErrors(schema, api, reducedErrors, { format: "cli-array", colorize: options?.validate?.errors?.colorize || false, indent: 2 }); return { valid: false, errors, warnings: [], additionalErrors, specification: specificationName }; } catch (err) { return { valid: false, errors: [{ message: err.message }], warnings: [], additionalErrors, specification: specificationName }; } } // src/validators/spec/index.ts var SpecificationValidator = class { errors = []; warnings = []; reportError(message) { this.errors.push({ message }); } reportWarning(message) { this.warnings.push({ message }); } }; // src/validators/spec/openapi.ts var OpenAPISpecificationValidator = class extends SpecificationValidator { api; rules; constructor(api, rules) { super(); this.api = api; this.rules = rules; } run() { const operationIds = []; Object.keys(this.api.paths || {}).forEach((pathName) => { const path = this.api.paths[pathName]; const pathId = `/paths${pathName}`; if (path && pathName.startsWith("/")) { this.validatePath(path, pathId, operationIds); } }); if (isOpenAPI30(this.api)) { if (this.api.components) { Object.keys(this.api.components).forEach((componentType) => { Object.keys(this.api.components[componentType]).forEach((componentName) => { if (!/^[a-zA-Z0-9.\-_]+$/.test(componentName)) { const componentId = `/components/${componentType}/${componentName}`; this.reportError( `\`${componentId}\` has an invalid name. Component names should match against: /^[a-zA-Z0-9.-_]+$/` ); } }); }); } } if (isOpenAPI31(this.api)) { if (!Object.keys(this.api.paths || {}).length && !Object.keys(this.api.webhooks || {}).length) { this.reportError("OpenAPI 3.1 definitions must contain at least one entry in either `paths` or `webhook`."); } } } /** * Validates the given path. * */ validatePath(path, pathId, operationIds) { supportedHTTPMethods.forEach((operationName) => { const operation = path[operationName]; const operationId = `${pathId}/${operationName}`; if (operation) { const declaredOperationId = operation.operationId; if (declaredOperationId) { if (!operationIds.includes(declaredOperationId)) { operationIds.push(declaredOperationId); } else if (this.rules["duplicate-operation-id"] === "warning") { this.reportWarning(`The operationId \`${declaredOperationId}\` is duplicated and should be made unique.`); } else { this.reportError(`The operationId \`${declaredOperationId}\` is duplicated and must be made unique.`); } } this.validateParameters(path, pathId, operation, operationId); Object.keys(operation.responses || {}).forEach((responseCode) => { const response = operation.responses[responseCode]; const responseId = `${operationId}/responses/${responseCode}`; if (response && !("$ref" in response)) { this.validateResponse(response, responseId); } }); } }); } /** * Validates the parameters for the given operation. * */ validateParameters(path, pathId, operation, operationId) { const pathParams = path.parameters || []; const operationParams = operation.parameters || []; this.checkForDuplicates(pathParams, pathId); this.checkForDuplicates(operationParams, operationId); const params = pathParams.reduce((combinedParams, value) => { const duplicate = combinedParams.some((param) => { if ("$ref" in param || "$ref" in value) { return false; } return param.in === value.in && param.name === value.name; }); if (!duplicate) { combinedParams.push(value); } return combinedParams; }, operationParams.slice()); this.validatePathParameters(params, pathId, operationId); this.validateParameterTypes(params, operationId); } /** * Validates path parameters for the given path. * */ validatePathParameters(params, pathId, operationId) { const placeholders = [...new Set(pathId.match(pathParameterTemplateRegExp) || [])]; params.filter((param) => "in" in param).filter((param) => param.in === "path").forEach((param) => { if (param.required !== true) { if (this.rules["non-optional-path-parameters"] === "warning") { this.reportWarning( `Path parameters should not be optional. Set \`required=true\` for the \`${param.name}\` parameter at \`${operationId}\`.` ); } else { this.reportError( `Path parameters cannot be optional. Set \`required=true\` for the \`${param.name}\` parameter at \`${operationId}\`.` ); } } const match = placeholders.indexOf(`{${param.name}}`); if (match === -1) { const error = `\`${operationId}\` has a path parameter named \`${param.name}\`, but there is no corresponding \`{${param.name}}\` in the path string.`; if (this.rules["path-parameters-not-in-path"] === "warning") { this.reportWarning(error); } else { this.reportError(error); } } placeholders.splice(match, 1); }); if (placeholders.length > 0) { const list = new Intl.ListFormat("en", { style: "long", type: "conjunction" }).format( placeholders.map((placeholder) => `\`${placeholder}\``) ); const error = `\`${operationId}\` is missing path parameter(s) for ${list}.`; if (this.rules["path-parameters-not-in-parameters"] === "warning") { this.reportWarning(error); } else { this.reportError(error); } } } /** * Validates data types of parameters for the given operation. * */ validateParameterTypes(params, operationId) { params.forEach((param) => { if ("$ref" in param) { return; } if (!param.schema && param.content) { return; } else if ("$ref" in param.schema) { return; } const parameterId = `${operationId}/parameters/${param.name}`; this.validateSchema(param.schema, parameterId); }); } /** * Validates the given response object. * */ validateResponse(response, responseId) { Object.keys(response.headers || {}).forEach((headerName) => { const header = response.headers[headerName]; const headerId = `${responseId}/headers/${headerName}`; if ("$ref" in header) { return; } if (header.schema) { if (!("$ref" in header.schema)) { this.validateSchema(header.schema, headerId); } } else if (header.content) { Object.keys(header.content).forEach((mediaType) => { if (header.content[mediaType].schema) { if (!("$ref" in header.content[mediaType].schema)) { this.validateSchema(header.content[mediaType].schema || {}, `${headerId}/content/${mediaType}/schema`); } } }); } }); if (response.content) { Object.keys(response.content).forEach((mediaType) => { if (response.content[mediaType].schema) { if (!("$ref" in response.content[mediaType].schema)) { this.validateSchema(response.content[mediaType].schema || {}, `${responseId}/content/${mediaType}/schema`); } } }); } } /** * Validates the given Swagger schema object. * */ validateSchema(schema, schemaId) { if (schema.type === "array" && !schema.items) { if (this.rules["array-without-items"] === "warning") { this.reportWarning(`\`${schemaId}\` is an array, so it should include an \`items\` schema.`); } else { this.reportError(`\`${schemaId}\` is an array, so it must include an \`items\` schema.`); } } } /** * Checks the given parameter list for duplicates. * */ checkForDuplicates(params, schemaId) { for (let i = 0; i < params.length - 1; i++) { const outer = params[i]; for (let j = i + 1; j < params.length; j++) { const inner = params[j]; if ("$ref" in outer || "$ref" in inner) { continue; } if (outer.name === inner.name && outer.in === inner.in) { const error = `Found multiple \`${outer.in}\` parameters named \`${outer.name}\` in \`${schemaId}\`.`; if (this.rules["duplicate-non-request-body-parameters"] === "warning") { this.reportWarning(error); } else { this.reportError(error); } } } } } }; // src/validators/spec/swagger.ts var SwaggerSpecificationValidator = class extends SpecificationValidator { api; constructor(api) { super(); this.api = api; } run() { const operationIds = []; Object.keys(this.api.paths || {}).forEach((pathName) => { const path = this.api.paths[pathName]; const pathId = `/paths${pathName}`; if (path && pathName.startsWith("/")) { this.validatePath(path, pathId, operationIds); } }); Object.keys(this.api.definitions || {}).forEach((definitionName) => { const definition = this.api.definitions[definitionName]; const definitionId = `/definitions/${definitionName}`; if (!/^[a-zA-Z0-9.\-_]+$/.test(definitionName)) { this.reportError( `\`${definitionId}\` has an invalid name. Definition names should match against: /^[a-zA-Z0-9.-_]+$/` ); } this.validateRequiredPropertiesExist(definition, definitionId); }); } /** * Validates the given path. * */ validatePath(path, pathId, operationIds) { swaggerHTTPMethods.forEach((operationName) => { const operation = path[operationName]; const operationId = `${pathId}/${operationName}`; if (operation) { const declaredOperationId = operation.operationId; if (declaredOperationId) { if (!operationIds.includes(declaredOperationId)) { operationIds.push(declaredOperationId); } else { this.reportError(`The operationId \`${declaredOperationId}\` is duplicated and must be made unique.`); } } this.validateParameters(path, pathId, operation, operationId); Object.keys(operation.responses || {}).forEach((responseName) => { const response = operation.responses[responseName]; if ("$ref" in response || !response) { return; } const responseId = `${operationId}/responses/${responseName}`; this.validateResponse(responseName, response, responseId); }); } }); } /** * Validates the parameters for the given operation. * */ validateParameters(path, pathId, operation, operationId) { const pathParams = (path.parameters || []).filter((param) => !("$ref" in param)); const operationParams = (operation.parameters || []).filter( (param) => !("$ref" in param) ); this.checkForDuplicates(pathParams, pathId); this.checkForDuplicates(operationParams, operationId); const params = pathParams.reduce((combinedParams, value) => { const duplicate = combinedParams.some((param) => { if ("$ref" in param || "$ref" in value) { return false; } return param.in === value.in && param.name === value.name; }); if (!duplicate) { combinedParams.push(value); } return combinedParams; }, operationParams.slice()); this.validateBodyParameters(params, operationId); this.validatePathParameters(params, pathId, operationId); this.validateParameterTypes(params, operation, operationId); } /** * Validates body and formData parameters for the given operation. * */ validateBodyParameters(params, operationId) { const bodyParams = params.filter((param) => param.in === "body"); const formParams = params.filter((param) => param.in === "formData"); if (bodyParams.length > 1) { this.reportError(`\`${operationId}\` has ${bodyParams.length} body parameters. Only one is allowed.`); } else if (bodyParams.length > 0 && formParams.length > 0) { this.reportError( `\`${operationId}\` has \`body\` and \`formData\` parameters. Only one or the other is allowed.` ); } } /** * Validates path parameters for the given path. * */ validatePathParameters(params, pathId, operationId) { const placeholders = pathId.match(pathParameterTemplateRegExp) || []; for (let i = 0; i < placeholders.length; i++) { for (let j = i + 1; j < placeholders.length; j++) { if (placeholders[i] === placeholders[j]) { this.reportError(`\`${operationId}\` has multiple path placeholders named \`${placeholders[i]}\`.`); } } } params.filter((param) => param.in === "path").forEach((param) => { if (param.required !== true) { this.reportError( `Path parameters cannot be optional. Set \`required=true\` for the \`${param.name}\` parameter at \`${operationId}\`.` ); } const match = placeholders.indexOf(`{${param.name}}`); if (match === -1) { this.reportError( `\`${operationId}\` has a path parameter named \`${param.name}\`, but there is no corresponding \`{${param.name}}\` in the path string.` ); } placeholders.splice(match, 1); }); if (placeholders.length > 0) { const list = new Intl.ListFormat("en", { style: "long", type: "conjunction" }).format( placeholders.map((placeholder) => `\`${placeholder}\``) ); this.reportError(`\`${operationId}\` is missing path parameter(s) for ${list}.`); } } /** * Validates data types of parameters for the given operation. * */ validateParameterTypes(params, operation, operationId) { params.forEach((param) => { const parameterId = `${operationId}/parameters/${param.name}`; let schema; switch (param.in) { case "body": schema = param.schema; break; case "formData": schema = param; break; default: schema = param; } this.validateSchema(schema, parameterId); this.validateRequiredPropertiesExist(schema, parameterId); if (schema.type === "file") { const formData = /multipart\/(.*\+)?form-data/; const urlEncoded = /application\/(.*\+)?x-www-form-urlencoded/; const consumes = operation.consumes || this.api.consumes || []; const hasValidMimeType = consumes.some((consume) => { return formData.test(consume) || urlEncoded.test(consume); }); if (!hasValidMimeType) { this.reportError( `\`${operationId}\` has a file parameter, so it must consume \`multipart/form-data\` or \`application/x-www-form-urlencoded\`.` ); } } }); } /** * Validates the given response object. * */ validateResponse(code, response, responseId) { if (code !== "default") { if (typeof code === "number" && (code < 100 || code > 599) || typeof code === "string" && (Number(code) < 100 || Number(code) > 599)) { this.reportError(`\`${responseId}\` has an invalid response code: ${code}`); } } Object.keys(response.headers || {}).forEach((headerName) => { const header = response.headers[headerName]; const headerId = `${responseId}/headers/${headerName}`; this.validateSchema(header, headerId); }); if (response.schema) { if ("$ref" in response.schema) { return; } this.validateSchema(response.schema, `${responseId}/schema`); } } /** * Validates the given Swagger schema object. * */ validateSchema(schema, schemaId) { if (schema.type === "array" && !schema.items) { this.reportError(`\`${schemaId}\` is an array, so it must include an \`items\` schema.`); } } /** * Validates that the declared properties of the given Swagger schema object actually exist. * */ validateRequiredPropertiesExist(schema, schemaId) { function collectProperties(schemaObj, props) { if (schemaObj.properties) { Object.keys(schemaObj.properties).forEach((property) => { if (schemaObj.properties.hasOwnProperty(property)) { props[property] = schemaObj.properties[property]; } }); } if (schemaObj.allOf) { schemaObj.allOf.forEach((parent) => { collectProperties(parent, props); }); } } if (schema.required && Array.isArray(schema.required)) { const props = {}; collectProperties(schema, props); schema.required.forEach((requiredProperty) => { if (!props[requiredProperty]) { this.reportError( `Property \`${requiredProperty}\` is listed as required but does not exist in \`${schemaId}\`.` ); } }); } } /** * Checks the given parameter list for duplicates. * */ checkForDuplicates(params, schemaId) { for (let i = 0; i < params.length - 1; i++) { const outer = params[i]; for (let j = i + 1; j < params.length; j++) { const inner = params[j]; if (outer.name === inner.name && outer.in === inner.in) { this.reportError(`Found multiple \`${outer.in}\` parameters named \`${outer.name}\` in \`${schemaId}\`.`); } } } } }; // src/validators/spec.ts function validateSpec(api, rules) { let validator; const specificationName = getSpecificationName(api); if (isOpenAPI(api)) { validator = new OpenAPISpecificationValidator(api, rules.openapi); } else { validator = new SwaggerSpecificationValidator(api); } validator.run(); if (!validator.errors.length) { return { valid: true, warnings: validator.warnings, specification: specificationName }; } return { valid: false, errors: validator.errors, warnings: validator.warnings, additionalErrors: 0, specification: specificationName }; } // src/index.ts async function parse(api, options) { const args = normalizeArguments(api); const parserOptions = convertOptionsForParser(options); const parser = new $RefParser(); const schema = await parser.parse(args.path, args.schema, parserOptions); repairSchema(schema, args.path); return schema; } async function bundle(api, options) { const args = normalizeArguments(api); const parserOptions = convertOptionsForParser(options); const parser = new $RefParser(); await parser.bundle(args.path, args.schema, parserOptions); repairSchema(parser.schema, args.path); return parser.schema; } async function dereference(api, options) { const args = normalizeArguments(api); const parserOptions = convertOptionsForParser(options); const parser = new $RefParser(); await parser.dereference(args.path, args.schema, parserOptions); repairSchema(parser.schema, args.path); return parser.schema; } async function validate(api, options) { const args = normalizeArguments(api); const parserOptions = convertOptionsForParser(options); let result; const circular$RefOption = parserOptions.dereference.circular; parserOptions.dereference.circular = "ignore"; const parser = new $RefParser(); try { await parser.dereference(args.path, args.schema, parserOptions); } catch (err) { if (err instanceof MissingPointerError) { return { valid: false, errors: [{ message: err.message }], warnings: [], additionalErrors: 0, specification: null }; } throw err; } if (!isSwagger(parser.schema) && !isOpenAPI(parser.schema)) { return { valid: false, errors: [{ message: "Supplied schema is not a valid API definition." }], warnings: [], additionalErrors: 0, specification: null }; } parserOptions.dereference.circular = circular$RefOption; result = validateSchema(parser.schema, options); if (!result.valid) { return result; } if (parser.$refs?.circular) { if (circular$RefOption === true) { dereferenceInternal(parser, parserOptions); } else if (circular$RefOption === false) { throw new ReferenceError( "The API contains circular references but the validator is configured to not permit them." ); } } const rules = options?.validate?.rules?.openapi; result = validateSpec(parser.schema, { openapi: { "array-without-items": rules?.["array-without-items"] || "error", "duplicate-non-request-body-parameters": rules?.["duplicate-non-request-body-parameters"] || "error", "duplicate-operation-id": rules?.["duplicate-operation-id"] || "error", "non-optional-path-parameters": rules?.["non-optional-path-parameters"] || "error", "path-parameters-not-in-parameters": rules?.["path-parameters-not-in-parameters"] || "error", "path-parameters-not-in-path": rules?.["path-parameters-not-in-path"] || "error" } }); return result; } function compileErrors(result) { const specName = result.specification || "API definition"; const status = !result.valid ? "failed" : "succeeded, but with warnings"; const message = [`${specName} schema validation ${status}.`]; if (result.valid === false) { if (result.errors.length) { message.push(...result.errors.map((err) => err.message)); } } if (result.warnings.length) { if (result.valid === false && result.errors.length) { message.push("We have also found some additional warnings:"); } message.push(...result.warnings.map((warn) => warn.message)); } if (result.valid === false && result.additionalErrors > 0) { message.push( `Plus an additional ${result.additionalErrors} errors. Please resolve the above and re-run validation to see more.` ); } return message.join("\n\n"); } export { bundle, compileErrors, dereference, parse, validate }; //# sourceMappingURL=index.js.map