diff --git a/src/framework/openapi.spec.loader.ts b/src/framework/openapi.spec.loader.ts index 9ce6cbee..7d0383df 100644 --- a/src/framework/openapi.spec.loader.ts +++ b/src/framework/openapi.spec.loader.ts @@ -5,6 +5,7 @@ import { OpenAPIV3, OpenAPIFrameworkArgs, } from './types'; +import { stripExamples } from './openapi/strip.examples'; export interface Spec { apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; @@ -103,6 +104,8 @@ export class OpenApiSpecLoader { routes.sort(sortRoutes); + stripExamples(apiDoc); + serial = serial + 1; return { apiDoc, diff --git a/src/framework/openapi/strip.examples.ts b/src/framework/openapi/strip.examples.ts new file mode 100644 index 00000000..4036c77d --- /dev/null +++ b/src/framework/openapi/strip.examples.ts @@ -0,0 +1,208 @@ +import { OpenAPIV3 } from '../types'; + +export function stripExamples( + document: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, +): void { + stripExamplesFromPaths(document.paths); + stripExamplesFromComponents(document.components); + + if (isDocumentV3_1(document)) { + stripExamplesFromPaths(document.components?.pathItems); + stripExamplesFromPaths(document.webhooks); + } +} + +function stripExamplesFromPaths(path?: OpenAPIV3.PathsObject): void { + if (hasNoExamples(path)) return; + forEachValue(path, (pathItem) => stripExamplesFromPathItem(pathItem)); +} + +function stripExamplesFromComponents( + components?: OpenAPIV3.ComponentsObject, +): void { + if (hasNoExamples(components)) return; + + delete components.examples; + + stripExamplesFromSchema(components.schemas); + stripExamplesFromResponses(components.responses); + stripExamplesFromHeaders(components.headers); + stripExamplesFromCallbacks(components.callbacks); + + forEachValue(components.requestBodies, (requestBody) => + stripExamplesFromRequestBody(requestBody), + ); + + if (components.parameters !== undefined) { + stripExamplesFromParameters( + Object.entries(components.parameters).map( + ([_key, parameter]) => parameter, + ), + ); + } +} + +function stripExamplesFromPathItem( + pathItem?: OpenAPIV3.ReferenceObject | OpenAPIV3.PathItemObject, +): void { + // Explicitly not checking whether pathItem is a ReferenceObject, as + // there is no way to differentiate them. Attempt to remove all example + // properties either way. + if (pathItem === undefined) return; + + ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'].forEach( + (method) => { + stripExamplesFromOperation(pathItem[method]); + }, + ); + + if ('parameters' in pathItem) { + stripExamplesFromParameters(pathItem.parameters); + } +} + +function stripExamplesFromSchema( + schema?: OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject, +): void { + if (hasNoExamples(schema)) return; + + if (schema.type !== 'array') { + stripExamplesFromBaseSchema(schema); + return; + } + + if ('items' in schema) { + stripExamplesFromSchema(schema.items); + } else { + stripExamplesFromSchema(schema.not); + (['allOf', 'oneOf', 'anyOf'] as const).forEach((property) => { + schema[property].forEach((childObject) => + stripExamplesFromSchema(childObject), + ); + }); + } +} + +function stripExamplesFromBaseSchema( + baseSchema?: OpenAPIV3.BaseSchemaObject, +): void { + if (hasNoExamples(baseSchema)) return; + + if (typeof baseSchema.additionalProperties !== 'boolean') { + stripExamplesFromSchema(baseSchema.additionalProperties); + } + + forEachValue(baseSchema.properties, (schema) => + stripExamplesFromSchema(schema), + ); +} + +function stripExamplesFromOperation( + operation?: OpenAPIV3.OperationObject, +): void { + if (hasNoExamples(operation)) return; + stripExamplesFromParameters(operation.parameters); + stripExamplesFromRequestBody(operation.requestBody); + stripExamplesFromResponses(operation.responses); + stripExamplesFromCallbacks(operation.callbacks); +} + +function stripExamplesFromRequestBody( + requestBody?: OpenAPIV3.ReferenceObject | OpenAPIV3.RequestBodyObject, +): void { + if (hasNoExamples(requestBody)) return; + stripExamplesFromContent(requestBody.content); +} + +function stripExamplesFromResponses( + responses?: OpenAPIV3.ReferenceObject | OpenAPIV3.ResponsesObject, +): void { + if (hasNoExamples(responses)) return; + forEachValue(responses, (response) => { + if ('$ref' in response) { + return; + } + stripExamplesFromHeaders(response.headers); + stripExamplesFromContent(response.content); + }); +} + +function stripExamplesFromEncoding(encoding?: OpenAPIV3.EncodingObject): void { + if (hasNoExamples(encoding)) return; + stripExamplesFromHeaders(encoding.headers); +} + +function stripExamplesFromHeaders(headers?: { + [header: string]: OpenAPIV3.ReferenceObject | OpenAPIV3.HeaderObject; +}): void { + if (hasNoExamples(headers)) return; + forEachValue(headers, (header) => stripExamplesFromParameterBase(header)); +} + +function stripExamplesFromContent(content?: { + [media: string]: OpenAPIV3.MediaTypeObject; +}): void { + forEachValue(content, (mediaTypeObject) => { + if (hasNoExamples(mediaTypeObject)) return; + + delete mediaTypeObject.example; + delete mediaTypeObject.examples; + + stripExamplesFromSchema(mediaTypeObject.schema); + forEachValue(mediaTypeObject.encoding, (encoding) => + stripExamplesFromEncoding(encoding), + ); + }); +} + +function stripExamplesFromParameters( + parameters?: Array, +): void { + if (hasNoExamples(parameters)) return; + parameters.forEach((parameter) => stripExamplesFromParameterBase(parameter)); +} + +function stripExamplesFromParameterBase( + parameterBase?: OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterBaseObject, +): void { + if (hasNoExamples(parameterBase)) return; + + delete parameterBase.example; + delete parameterBase.examples; + + stripExamplesFromSchema(parameterBase.schema); + stripExamplesFromContent(parameterBase.content); +} + +function stripExamplesFromCallbacks(callbacks?: { + [callback: string]: OpenAPIV3.ReferenceObject | OpenAPIV3.CallbackObject; +}): void { + if (hasNoExamples(callbacks)) return; + + forEachValue(callbacks, (callback) => { + if ('$ref' in callback) { + return; + } + stripExamplesFromPaths(callback); + }); +} + +function isDocumentV3_1( + document: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, +): document is OpenAPIV3.DocumentV3_1 { + return document.openapi.startsWith('3.1.'); +} + +function hasNoExamples( + object: T | OpenAPIV3.ReferenceObject | undefined, +): object is OpenAPIV3.ReferenceObject | undefined { + return object === undefined || '$ref' in object; +} + +function forEachValue( + dictionary: { [key: string]: Value } | undefined, + perform: (value: Value) => void, +): void { + if (dictionary === undefined) return; + Object.entries(dictionary).forEach(([_key, value]) => perform(value)); +} diff --git a/src/framework/types.ts b/src/framework/types.ts index b75adc31..0bf58026 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -1,7 +1,7 @@ import * as ajv from 'ajv'; import * as multer from 'multer'; import { FormatsPluginOptions } from 'ajv-formats'; -import { Request, Response, NextFunction, RequestHandler } from 'express'; +import { Request, Response, NextFunction } from 'express'; import { RouteMetadata } from './openapi.spec.loader'; import AjvDraft4 from 'ajv-draft-04'; import Ajv2020 from 'ajv/dist/2020'; @@ -301,7 +301,7 @@ export namespace OpenAPIV3 { export interface HeaderObject extends ParameterBaseObject { } - interface ParameterBaseObject { + export interface ParameterBaseObject { description?: string; required?: boolean; deprecated?: boolean; @@ -342,7 +342,7 @@ export namespace OpenAPIV3 { discriminator?: DiscriminatorObject; } - interface BaseSchemaObject { + export interface BaseSchemaObject { // JSON schema allowed properties, adjusted for OpenAPI type?: T; title?: string; diff --git a/src/middlewares/openapi.request.validator.ts b/src/middlewares/openapi.request.validator.ts index c675e0d6..28303b4b 100644 --- a/src/middlewares/openapi.request.validator.ts +++ b/src/middlewares/openapi.request.validator.ts @@ -42,8 +42,6 @@ export class RequestValidator { ) { this.middlewareCache = {}; this.apiDoc = apiDoc; - // Examples not needed for validation - delete this.apiDoc.components?.examples; this.requestOpts.allowUnknownQueryParameters = options.allowUnknownQueryParameters; diff --git a/test/ignore.examples.spec.ts b/test/ignore.examples.spec.ts new file mode 100644 index 00000000..ee39c6bc --- /dev/null +++ b/test/ignore.examples.spec.ts @@ -0,0 +1,143 @@ +import * as request from 'supertest'; +import { createApp } from './common/app'; +import * as packageJson from '../package.json'; + +describe(packageJson.name, () => { + let app = null; + + before(async () => { + // set up express app + app = await createApp( + { + apiSpec: apiSpec(), + validateRequests: true, + validateResponses: true, + }, + 3001, + (app) => { + app.post('/ping', (req: any, res: any) => { + res.json({ + id: req.body.id, + message: 'Pong!', + }); + }); + }, + false, + ); + }); + + after(() => { + app.server.close(); + }); + + it('should not throw an error when more than one example uses the same the value for a property "id"', async () => + request(app) + .post('/ping') + .send({ id: 'id', message: 'Ping!' }) + .expect(200)); +}); + +function apiSpec(): any { + return { + openapi: '3.0.0', + info: { + version: 'v1', + title: 'Validation Error', + description: + 'A test spec that triggers an validation error on identical id fields in examples.', + }, + paths: { + '/ping': { + post: { + description: 'ping then pong!', + operationId: 'ping', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Data', + }, + examples: { + request1: { + summary: 'Request 1', + value: { + id: 'Some_ID_A', + message: 'Ping!', + }, + }, + request2: { + summary: 'Request 2', + value: { + id: 'Some_ID_A', + message: 'Ping!', + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Data', + }, + examples: { + response1: { + summary: 'Response 1', + value: { + id: 'Some_ID_B', + message: 'Pong!', + }, + }, + response2: { + summary: 'Response 2', + value: { + id: 'Some_ID_B', + message: 'Pong!', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Data: { + required: ['id', 'message'], + properties: { + id: { + type: 'string', + }, + message: { + type: 'string', + }, + }, + }, + }, + examples: { + example1: { + summary: 'Example 1', + value: { + id: 'Some_ID_C', + message: 'Example!', + }, + }, + response2: { + summary: 'Example 2', + value: { + id: 'Some_ID_C', + message: 'Example!', + }, + }, + }, + }, + }; +}