From fda99f0abb514c9cf7e10a656b6f09582277f322 Mon Sep 17 00:00:00 2001 From: Maximilian Mayer Date: Tue, 4 Mar 2025 15:05:54 +0100 Subject: [PATCH 01/12] feat: initial implementation with bugs --- src/framework/types.ts | 69 +- .../parsers/schema.preprocessor.ts | 1204 ++++++++++------- 2 files changed, 775 insertions(+), 498 deletions(-) diff --git a/src/framework/types.ts b/src/framework/types.ts index b75adc31..e0ef13e3 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -7,7 +7,7 @@ import AjvDraft4 from 'ajv-draft-04'; import Ajv2020 from 'ajv/dist/2020'; export { OpenAPIFrameworkArgs }; -export type AjvInstance = AjvDraft4 | Ajv2020 +export type AjvInstance = AjvDraft4 | Ajv2020; export type BodySchema = | OpenAPIV3.ReferenceObject @@ -48,7 +48,7 @@ export interface Options extends ajv.Options { ajvFormats?: FormatsPluginOptions; } -export interface RequestValidatorOptions extends Options, ValidateRequestOpts { } +export interface RequestValidatorOptions extends Options, ValidateRequestOpts {} export type ValidateRequestOpts = { /** @@ -125,32 +125,42 @@ export class SerDesSingleton implements SerDes { serialize: param.serialize, }; } -}; +} export type SerDesMap = { - [format: string]: SerDes + [format: string]: SerDes; }; -type Primitive = undefined | null | boolean | string | number | Function +type Primitive = undefined | null | boolean | string | number | Function; -type Immutable = - T extends Primitive ? T : - T extends Array ? ReadonlyArray : - T extends Map ? ReadonlyMap : Readonly +type Immutable = T extends Primitive + ? T + : T extends Array + ? ReadonlyArray + : T extends Map + ? ReadonlyMap + : Readonly; -type DeepImmutable = - T extends Primitive ? T : - T extends Array ? DeepImmutableArray : - T extends Map ? DeepImmutableMap : DeepImmutableObject +type DeepImmutable = T extends Primitive + ? T + : T extends Array + ? DeepImmutableArray + : T extends Map + ? DeepImmutableMap + : DeepImmutableObject; interface DeepImmutableArray extends ReadonlyArray> {} -interface DeepImmutableMap extends ReadonlyMap, DeepImmutable> {} +interface DeepImmutableMap + extends ReadonlyMap, DeepImmutable> {} type DeepImmutableObject = { - readonly [K in keyof T]: DeepImmutable -} + readonly [K in keyof T]: DeepImmutable; +}; export interface OpenApiValidatorOpts { - apiSpec: DeepImmutable | DeepImmutable | string; + apiSpec: + | DeepImmutable + | DeepImmutable + | string; validateApiSpec?: boolean; validateResponses?: boolean | ValidateResponseOpts; validateRequests?: boolean | ValidateRequestOpts; @@ -204,18 +214,19 @@ export namespace OpenAPIV3 { externalDocs?: ExternalDocumentationObject; } - interface ComponentsV3_1 extends ComponentsObject { - pathItems?: { [path: string]: PathItemObject | ReferenceObject } + export interface ComponentsV3_1 extends ComponentsObject { + pathItems?: { [path: string]: PathItemObject | ReferenceObject }; } - export interface DocumentV3_1 extends Omit { + export interface DocumentV3_1 + extends Omit { openapi: `3.1.${string}`; paths?: DocumentV3['paths']; info: InfoObjectV3_1; components: ComponentsV3_1; webhooks: { - [name: string]: PathItemObject | ReferenceObject - } + [name: string]: PathItemObject | ReferenceObject; + }; } export interface InfoObject { @@ -299,7 +310,7 @@ export namespace OpenAPIV3 { in: string; } - export interface HeaderObject extends ParameterBaseObject { } + export interface HeaderObject extends ParameterBaseObject {} interface ParameterBaseObject { description?: string; @@ -323,14 +334,18 @@ export namespace OpenAPIV3 { | 'integer'; export type ArraySchemaObjectType = 'array'; - export type SchemaObject = ArraySchemaObject | NonArraySchemaObject | CompositionSchemaObject; + export type SchemaObject = + | ArraySchemaObject + | NonArraySchemaObject + | CompositionSchemaObject; - export interface ArraySchemaObject extends BaseSchemaObject { + export interface ArraySchemaObject + extends BaseSchemaObject { items: ReferenceObject | SchemaObject; } - export interface NonArraySchemaObject extends BaseSchemaObject { - } + export interface NonArraySchemaObject + extends BaseSchemaObject {} export interface CompositionSchemaObject extends BaseSchemaObject { // JSON schema allowed properties, adjusted for OpenAPI diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index 36b0e315..7dd5f41e 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -1,93 +1,15 @@ -import Ajv from 'ajv'; -import ajv = require('ajv'); -import * as cloneDeep from 'lodash.clonedeep'; -import * as _get from 'lodash.get'; -import { createRequestAjv } from '../../framework/ajv'; import { OpenAPIV3, - SerDesMap, Options, + SerDesMap, ValidateResponseOpts, } from '../../framework/types'; +import { createRequestAjv } from '../../framework/ajv'; +import Ajv from 'ajv'; +import * as _get from 'lodash.get'; +import * as cloneDeep from 'lodash.clonedeep'; -interface TraversalStates { - req: TraversalState; - res: TraversalState; -} - -interface TraversalState { - discriminator: object; - kind: 'req' | 'res'; - path: string[]; -} - -interface TopLevelPathNodes { - requestBodies: Root[]; - requestParameters: Root[]; - responses: Root[]; -} -interface TopLevelSchemaNodes extends TopLevelPathNodes { - schemas: Root[]; - requestBodies: Root[]; - responses: Root[]; -} - -class Node { - public readonly path: string[]; - public readonly parent: P; - public readonly schema: T; - constructor(parent: P, schema: T, path: string[]) { - this.path = path; - this.parent = parent; - this.schema = schema; - } -} -type SchemaObjectNode = Node; - -function isParameterObject( - node: ParameterObject | ReferenceObject, -): node is ParameterObject { - return !(node as ReferenceObject).$ref; -} -function isReferenceObject( - node: ArraySchemaObject | NonArraySchemaObject | ReferenceObject, -): node is ReferenceObject { - return !!(node as ReferenceObject).$ref; -} -function isArraySchemaObject( - node: ArraySchemaObject | NonArraySchemaObject | ReferenceObject, -): node is ArraySchemaObject { - return !!(node as ArraySchemaObject).items; -} -function isNonArraySchemaObject( - node: ArraySchemaObject | NonArraySchemaObject | ReferenceObject, -): node is NonArraySchemaObject { - return !isArraySchemaObject(node) && !isReferenceObject(node); -} - -class Root extends Node { - constructor(schema: T, path: string[]) { - super(null, schema, path); - } -} - -type ArraySchemaObject = OpenAPIV3.ArraySchemaObject; -type NonArraySchemaObject = OpenAPIV3.NonArraySchemaObject; -type OperationObject = OpenAPIV3.OperationObject; -type ParameterObject = OpenAPIV3.ParameterObject; -type ReferenceObject = OpenAPIV3.ReferenceObject; -type SchemaObject = OpenAPIV3.SchemaObject; -type Schema = ReferenceObject | SchemaObject; - -if (!Array.prototype['flatMap']) { - // polyfill flatMap - // TODO remove me when dropping node 10 support - Array.prototype['flatMap'] = function (lambda) { - return Array.prototype.concat.apply([], this.map(lambda)); - }; - Object.defineProperty(Array.prototype, 'flatMap', { enumerable: false }); -} -export const httpMethods = new Set([ +const HttpMethods = [ 'get', 'put', 'post', @@ -96,253 +18,432 @@ export const httpMethods = new Set([ 'head', 'patch', 'trace', -]); -export class SchemaPreprocessor { - private ajv: Ajv; - private apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; - private apiDocRes: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; - private serDesMap: SerDesMap; - private responseOpts: ValidateResponseOpts; - private resolvedSchemaCache = new Map(); - +] as const; + +export const httpMethods = new Set(HttpMethods); + +type VisitorObjects = { + document: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; + components: OpenAPIV3.ComponentsObject; + componentsV3_1: OpenAPIV3.ComponentsV3_1; + pathItem: OpenAPIV3.PathItemObject | OpenAPIV3.ReferenceObject; + schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject; + operation: OpenAPIV3.OperationObject; + requestBody: OpenAPIV3.RequestBodyObject | OpenAPIV3.ReferenceObject; + response: OpenAPIV3.ResponseObject | OpenAPIV3.ReferenceObject; + encoding: OpenAPIV3.EncodingObject; + header: OpenAPIV3.HeaderObject | OpenAPIV3.ReferenceObject; + mediaType: OpenAPIV3.MediaTypeObject; + parameter: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject; + callback: OpenAPIV3.CallbackObject | OpenAPIV3.ReferenceObject; +}; + +type VisitorTypes = keyof VisitorObjects; +type OpenAPIObject = VisitorObjects[VisitorTypes]; + +/** A union of all property keys of `Object` that have type `DesiredType`. */ +type KeysWithMatchingValue = { + [Key in keyof Object]: Object[Key] extends DesiredKey + ? DesiredKey extends Object[Key] + ? Key + : never + : never; +}[keyof Object]; + +/** A wrapper type for `KeysWithMatchingValue` that fixes usage when `Object` is an optional type union. */ +type KeysWithExactType = Object extends unknown + ? KeysWithMatchingValue + : never; + +/** All `VisitorTypes` that also support `OpenAPIV3.ReferenceObject`s. */ +type VisitorTypesWithReference = { + [Key in keyof VisitorObjects]: VisitorObjects[Key] extends OpenAPIV3.ReferenceObject + ? OpenAPIV3.ReferenceObject extends VisitorObjects[Key] + ? Key + : never + : VisitorObjects[Key] extends OpenAPIV3.ReferenceObject | infer _Other + ? OpenAPIV3.ReferenceObject extends VisitorObjects[Key] + ? Key + : never + : never; +}[keyof VisitorObjects]; + +class VisitorNode { constructor( - apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, - ajvOptions: Options, - validateResponsesOpts: ValidateResponseOpts, - ) { - this.ajv = createRequestAjv(apiDoc, ajvOptions); - this.apiDoc = apiDoc; - this.serDesMap = ajvOptions.serDesMap; - this.responseOpts = validateResponsesOpts; + public type: NodeType, + public object: VisitorObjects[NodeType] | undefined, + public path: string[], + ) {} + + static fromParent< + ParentType extends VisitorTypes, + NodeType extends VisitorTypes, + PropertyKey extends KeysWithExactType< + VisitorObjects[ParentType], + VisitorObjects[NodeType] + >, + >( + parent: VisitorNode, + type: NodeType, + propertyPath?: PropertyKey, + ): VisitorNode { + propertyPath = propertyPath ?? (type as unknown as PropertyKey); + + return new VisitorNode( + type, + parent.object[propertyPath] as unknown as VisitorObjects[NodeType], + [...parent.path, propertyPath], + ); } - public preProcess() { - const componentSchemas = this.gatherComponentSchemaNodes(); - let r; - - if (this.apiDoc.paths) { - r = this.gatherSchemaNodesFromPaths(); - } - - // Now that we've processed paths, clone a response spec if we are validating responses - this.apiDocRes = !!this.responseOpts ? cloneDeep(this.apiDoc) : null; - - if (this.apiDoc.components) { - this.removeExamples(this.apiDoc.components); + static fromParentDict< + ParentType extends VisitorTypes, + NodeType extends VisitorTypes, + DictKey extends KeysWithExactType< + VisitorObjects[ParentType], + { [key: string]: VisitorObjects[NodeType] } + >, + >( + parent: VisitorNode, + type: NodeType, + dictPath: DictKey, + ): VisitorNode[] { + if (parent.object[dictPath] === undefined) { + return []; } - const schemaNodes = { - schemas: componentSchemas, - requestBodies: r?.requestBodies, - responses: r?.responses, - requestParameters: r?.requestParameters, + const nodes: VisitorNode[] = []; + const dict = parent.object[dictPath] as unknown as { + [key: string]: VisitorObjects[NodeType]; }; - // Traverse the schemas - if (r) { - this.traverseSchemas(schemaNodes, (parent, schema, opts) => - this.schemaVisitor(parent, schema, opts), - ); - } + forEachValue(dict, (value, key) => { + nodes.push(new VisitorNode(type, value, [...parent.path, dictPath, key])); + }); - return { - apiDoc: this.apiDoc, - apiDocRes: this.apiDocRes, - }; + return nodes; } - private gatherComponentSchemaNodes(): Root[] { - const nodes = []; - const componentSchemaMap = this.apiDoc?.components?.schemas ?? []; - for (const [id, s] of Object.entries(componentSchemaMap)) { - const schema = this.resolveSchema(s); - this.apiDoc.components.schemas[id] = schema; - const path = ['components', 'schemas', id]; - const node = new Root(schema, path); - nodes.push(node); + static fromParentArray< + ParentType extends VisitorTypes, + NodeType extends VisitorTypes, + ArrayKey extends KeysWithExactType< + VisitorObjects[ParentType], + Array + >, + >( + parent: VisitorNode, + type: NodeType, + arrayPath: ArrayKey, + ): VisitorNode[] { + if (parent.object[arrayPath] === undefined) { + return []; } + + const nodes: VisitorNode[] = []; + const array = parent.object[arrayPath] as unknown as Array< + VisitorObjects[NodeType] + >; + + array.forEach((value, index) => { + nodes.push( + new VisitorNode(type, value, [...parent.path, arrayPath, `${index}`]), + ); + }); + return nodes; } +} - private gatherSchemaNodesFromPaths(): TopLevelPathNodes { - const requestBodySchemas = []; - const requestParameterSchemas = []; - const responseSchemas = []; - - for (const [p, pi] of Object.entries(this.apiDoc.paths)) { - const pathItem = this.resolveSchema(pi); - - // Since OpenAPI 3.1, paths can be a #ref to reusable path items - // The following line mutates the paths item to dereference the reference, so that we can process as a POJO, as we would if it wasn't a reference - this.apiDoc.paths[p] = pathItem; - - for (const method of Object.keys(pathItem)) { - if (httpMethods.has(method)) { - const operation = pathItem[method]; - // Adds path declared parameters to the schema's parameters list - this.preprocessPathLevelParameters(method, pathItem); - const path = ['paths', p, method]; - const node = new Root(operation, path); - const requestBodies = this.extractRequestBodySchemaNodes(node); - const responseBodies = this.extractResponseSchemaNodes(node); - const requestParameters = - this.extractRequestParameterSchemaNodes(node); - - requestBodySchemas.push(...requestBodies); - responseSchemas.push(...responseBodies); - requestParameterSchemas.push(...requestParameters); - } - } - } - - return { - requestBodies: requestBodySchemas, - requestParameters: requestParameterSchemas, - responses: responseSchemas, +type VisitorState = { + request: State<'request'>; + response: State<'response'>; +}; + +type State = { + type: Type; + discriminator: { + discriminator?: string; + options?: { option: any; ref: any }[]; + properties?: { + [p: string]: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject; }; + required?: string[]; + }; + path: string[]; + originalObject?: OpenAPIObject; +}; + +export class SchemaPreprocessor< + OpenAPISchema extends OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, +> { + private ajv: Ajv; + //private apiDocRes: OpenAPISchema | undefined; + private readonly serDesMap: SerDesMap; + private resolvedSchemaCache = new Map(); + + constructor( + private apiDoc: OpenAPISchema, + ajvOptions: Options, + private responseOptions: ValidateResponseOpts | undefined, + ) { + this.ajv = createRequestAjv(this.apiDoc, ajvOptions); + this.serDesMap = ajvOptions.serDesMap; } - /** - * Traverse the schema starting at each node in nodes - * @param nodes the nodes to traverse - * @param visit a function to invoke per node - */ - private traverseSchemas(nodes: TopLevelSchemaNodes, visit) { - const seen = new Set(); - const recurse = (parent, node, opts: TraversalStates) => { - const schema = node.schema; + public preProcess(): { apiDoc: OpenAPISchema; apiDocRes: OpenAPISchema } { + const root = new VisitorNode('document', this.apiDoc, []); - if (!schema || seen.has(schema)) return; + this.traverseSchema(root); - seen.add(schema); + return { + apiDoc: this.apiDoc, + apiDocRes: cloneDeep(this.apiDoc), // TODO: Should be response doc + }; + } - if (schema.$ref) { - const resolvedSchema = this.resolveSchema(schema); - const path = schema.$ref.split('/').slice(1); + private traverseSchema(root: VisitorNode<'document'>): void { + const seenObjects = new Set(); + + const traverse = < + ParentType extends VisitorTypes, + NodeType extends VisitorTypes, + >( + parent: VisitorNode, + node: VisitorNode, + state: VisitorState, + ) => { + try { + if (node.object === undefined || seenObjects.has(node.object)) { + return; + } - (opts).req.originalSchema = schema; - (opts).res.originalSchema = schema; + if (isReferenceNode(node) && isReferenceObject(node.object)) { + const resolvedObject = this.resolveObject( + node.object, + ); - visit(parent, node, opts); - recurse(node, new Node(schema, resolvedSchema, path), opts); - return; - } + // Resolve reference object in parent, then process again with resolved schema + // As every object (aka schema) is 'pass-by-reference', this will update the actual apiDoc. + parent.object[node.path[-1]] = resolvedObject; + node.object = resolvedObject; - // Save the original schema so we can check if it was a $ref - (opts).req.originalSchema = schema; - (opts).res.originalSchema = schema; + return traverse(parent, node as VisitorNode, state); + } - visit(parent, node, opts); + seenObjects.add(node.object); + + state.request.originalObject = node.object; + state.response.originalObject = node.object; + + this.visitNode(parent, node, state); + + let children: VisitorNode[]; + + if (hasNodeType(node, 'document')) { + children = this.getChildrenForDocument(node); + } else if (hasNodeType(node, 'components')) { + children = this.getChildrenForComponents(node); + } else if (hasNodeType(node, 'componentsV3_1')) { + children = this.getChildrenForComponentsV3_1(node); + } else if (hasNodeType(node, 'pathItem')) { + children = this.getChildrenForPathItem(node); + } else if (hasNodeType(node, 'schema')) { + children = this.getChildrenForSchema(node); + } else if (hasNodeType(node, 'operation')) { + children = this.getChildrenForOperation(node); + } else if (hasNodeType(node, 'requestBody')) { + children = this.getChildrenForRequestBody(node); + } else if (hasNodeType(node, 'response')) { + children = this.getChildrenForResponse(node); + } else if (hasNodeType(node, 'encoding')) { + children = this.getChildrenForEncoding(node); + } else if (hasNodeType(node, 'header')) { + children = this.getChildrenForHeader(node); + } else if (hasNodeType(node, 'mediaType')) { + children = this.getChildrenForMediaType(node); + } else if (hasNodeType(node, 'parameter')) { + children = this.getChildrenForParameter(node); + } else if (hasNodeType(node, 'callback')) { + children = this.getChildrenForCallback(node); + } else { + throw new Error( + `No strategy to traverse node with type ${node.type}.`, + ); + } - if (schema.allOf) { - schema.allOf.forEach((s, i) => { - const child = new Node(node, s, [...node.path, 'allOf', i + '']); - recurse(node, child, opts); - }); - } else if (schema.oneOf) { - schema.oneOf.forEach((s, i) => { - const child = new Node(node, s, [...node.path, 'oneOf', i + '']); - recurse(node, child, opts); - }); - } else if (schema.anyOf) { - schema.anyOf.forEach((s, i) => { - const child = new Node(node, s, [...node.path, 'anyOf', i + '']); - recurse(node, child, opts); + children.forEach((child) => { + // cloning state to isolate against sub-objects affecting each other's state + traverse(node as VisitorNode, child, cloneDeep(state)); }); - } else if (schema.type === 'array' && schema.items) { - const child = new Node(node, schema.items, [...node.path, 'items']); - recurse(node, child, opts); - } else if (schema.properties) { - Object.entries(schema.properties).forEach(([id, cschema]) => { - const path = [...node.path, 'properties', id]; - const child = new Node(node, cschema, path); - recurse(node, child, opts); - }); - } else if (schema.additionalProperties) { - const child = new Node(node, schema.additionalProperties, [ - ...node.path, - 'additionalProperties', - ]); - recurse(node, child, opts); + } catch (error) { + throw error; } }; - const initOpts = (): TraversalStates => ({ - req: { discriminator: {}, kind: 'req', path: [] }, - res: { discriminator: {}, kind: 'res', path: [] }, + traverse(undefined, root, { + request: { type: 'request', discriminator: {}, path: [] }, + response: { type: 'response', discriminator: {}, path: [] }, }); + } - for (const node of nodes.schemas) { - recurse(null, node, initOpts()); + private visitNode< + ParentType extends VisitorTypes, + NodeType extends VisitorTypes, + >( + parent: VisitorNode | undefined, + node: VisitorNode, + state: VisitorState, + ): void { + this.removeExamples(node); + + if (hasNodeType(node, 'pathItem')) { + this.preProcessPathParameters(node.object); + } else if (hasNodeType(node, 'schema')) { + this.preProcessSchema( + hasNodeType(parent, 'schema') ? parent : undefined, + node, + state, + ); } + } - for (const node of nodes.requestBodies) { - recurse(null, node, initOpts()); + private removeExamples( + node: VisitorNode, + ): void { + if (isReferenceObject(node.object)) { + throw new Error('Object should have been unwrapped.'); } - for (const node of nodes.responses) { - recurse(null, node, initOpts()); + if (hasNodeType(node, 'components')) { + delete node.object.examples; + } else if ( + hasNodeType(node, 'mediaType') || + hasNodeType(node, 'header') || + hasNodeType(node, 'parameter') || + hasNodeType(node, 'schema') + ) { + delete node.object.example; + delete node.object.examples; } + } - for (const node of nodes.requestParameters) { - recurse(null, node, initOpts()); + /** + * add path level parameters to the schema's parameters list + * @param pathItem + */ + private preProcessPathParameters(pathItem: VisitorObjects['pathItem']): void { + if (isReferenceObject(pathItem)) { + throw new Error('Object should have been unwrapped.'); } + + const parameters = pathItem.parameters ?? []; + if (parameters.length === 0) return; + + HttpMethods.forEach((method) => { + const operation = pathItem[method]; + + if (operation === undefined || operation === parameters) return; + + operation.parameters = operation.parameters ?? []; + + const match = ( + pathParam: OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject, + opParam: OpenAPIV3.ReferenceObject | OpenAPIV3.OperationObject, + ) => + // if name or ref exists and are equal + (opParam['name'] && opParam['name'] === pathParam['name']) || + (opParam['$ref'] && opParam['$ref'] === pathParam['$ref']); + + // Add Path level query param to list ONLY if there is not already an operation-level query param by the same name. + for (const param of parameters) { + if ( + !operation.parameters.some((operationParam) => + match(param, operationParam), + ) + ) { + operation.parameters.push(param); + } + } + }); } - private schemaVisitor( - parent: SchemaObjectNode, - node: SchemaObjectNode, - opts: TraversalStates, - ) { - const pschemas = [parent?.schema]; - const nschemas = [node.schema]; - - if (this.apiDocRes) { - const p = _get(this.apiDocRes, parent?.path); - const n = _get(this.apiDocRes, node?.path); - pschemas.push(p); - nschemas.push(n); + private preProcessSchema( + parent: VisitorNode<'schema'> | undefined, + node: VisitorNode<'schema'>, + state: VisitorState, + ): void { + if (isReferenceObject(parent?.object) || isReferenceObject(node.object)) { + throw new Error('Object should have been unwrapped.'); + } + + const parentSchemas = [parent?.object]; + const nodeSchemas = [node.object]; + + /* + // TODO: Should be response doc + if (this.apiDoc) { + const parentResponseSchema = _get(this.apiDoc, parent?.path); + const nodeResponseSchema = _get(this.apiDoc, node.path); + parentSchemas.push(parentResponseSchema); + nodeSchemas.push(nodeResponseSchema); } + */ // visit the node in both the request and response schema - for (let i = 0; i < nschemas.length; i++) { - const kind = i === 0 ? 'req' : 'res'; - const pschema = pschemas[i]; - const nschema = nschemas[i]; - const options = opts[kind]; + for (let i = 0; i < nodeSchemas.length; i++) { + const kind = i === 0 ? 'request' : 'response'; + + const parentSchema = parentSchemas[i]; + const nodeSchema = nodeSchemas[i]; + + const options = state[kind]; options.path = node.path; - if (nschema) { - // This null check should no longer be necessary - this.handleSerDes(pschema, nschema, options); - this.handleReadonly(pschema, nschema, options); - this.handleWriteonly(pschema, nschema, options); - this.processDiscriminator(pschema, nschema, options); - this.removeSchemaExamples(pschema, nschema, options); + this.handleSerDes(nodeSchema); + + if (options.type === 'request') { + this.handleReadonly(parentSchema, nodeSchema, options); + } else { + this.handleWriteonly(parentSchema, nodeSchema, options); } + + this.processDiscriminator(parentSchema, nodeSchema, options); } } - private processDiscriminator(parent: Schema, schema: Schema, opts: any = {}) { - const o = opts.discriminator; - const schemaObj = schema; - const xOf = schemaObj.oneOf ? 'oneOf' : schemaObj.anyOf ? 'anyOf' : null; + private processDiscriminator( + parentSchema: VisitorObjects['schema'], + nodeSchema: VisitorObjects['schema'], + state: State<'request' | 'response'>, + ): void { + const o = state.discriminator; + const schemaObj = nodeSchema; + const xOf = + schemaObj.oneOf !== undefined + ? 'oneOf' + : schemaObj.anyOf + ? 'anyOf' + : undefined; - if (xOf && schemaObj.discriminator?.propertyName && !o.discriminator) { - const options = schemaObj[xOf].flatMap((refObject) => { + if ( + xOf && + schemaObj.discriminator?.propertyName !== undefined && + o.discriminator === undefined + ) { + o.options = schemaObj[xOf].flatMap((refObject) => { if (refObject['$ref'] === undefined) { return []; } - const keys = this.findKeys( + const keys = findKeys( schemaObj.discriminator.mapping, (value) => value === refObject['$ref'], ); - const ref = this.getKeyFromRef(refObject['$ref']); + const ref = getKeyFromRef(refObject['$ref']); return keys.length > 0 ? keys.map((option) => ({ option, ref })) : [{ option: ref, ref }]; }); - o.options = options; o.discriminator = schemaObj.discriminator?.propertyName; o.properties = { ...(o.properties ?? {}), @@ -364,16 +465,16 @@ export class SchemaPreprocessor { new Set((o.required ?? []).concat(schemaObj.required ?? [])), ); - const ancestor: any = parent; - const ref = opts.originalSchema.$ref; + const ancestor: any = parentSchema; + const ref = state.originalObject['$ref']; if (!ref) return; - const options = this.findKeys( + const options = findKeys( ancestor.discriminator?.mapping, (value) => value === ref, ); - const refName = this.getKeyFromRef(ref); + const refName = getKeyFromRef(ref); if (options.length === 0 && ref) { options.push(refName); } @@ -443,265 +544,426 @@ export class SchemaPreprocessor { * * See [`createAjv`](../../framework/ajv/index.ts) for custom keyword definitions. * - * @param {object} parent - parent schema - * @param {object} schema - schema - * @param {object} state - traversal state + * @param {object} nodeSchema - schema */ - private handleSerDes( - parent: SchemaObject, - schema: SchemaObject, - state: TraversalState, - ) { + private handleSerDes(nodeSchema: VisitorObjects['schema']): void { + if (isReferenceObject(nodeSchema)) { + throw new Error('Object should have been unwrapped.'); + } + if ( - schema.type === 'string' && - !!schema.format && - this.serDesMap[schema.format] + nodeSchema.type === 'string' && + !!nodeSchema.format && + this.serDesMap[nodeSchema.format] ) { - const serDes = this.serDesMap[schema.format]; - (schema)['x-eov-type'] = schema.type; - if ('nullable' in schema) { + const serDes = this.serDesMap[nodeSchema.format]; + (nodeSchema)['x-eov-type'] = nodeSchema.type; + if ('nullable' in nodeSchema) { // Ajv requires `type` keyword with `nullable` (regardless of value). - (schema).type = ['string', 'number', 'boolean', 'object', 'array']; + (nodeSchema).type = [ + 'string', + 'number', + 'boolean', + 'object', + 'array', + ]; } else { - delete schema.type; + delete nodeSchema.type; } if (serDes.deserialize) { - schema['x-eov-req-serdes'] = serDes; + nodeSchema['x-eov-req-serdes'] = serDes; } if (serDes.serialize) { - schema['x-eov-res-serdes'] = serDes; + nodeSchema['x-eov-res-serdes'] = serDes; } } } - private removeSchemaExamples( - parent: OpenAPIV3.SchemaObject, - schema: OpenAPIV3.SchemaObject, - opts, - ) { - this.removeExamples(parent); - this.removeExamples(schema); - } - - private removeExamples( - object: OpenAPIV3.SchemaObject | OpenAPIV3.MediaTypeObject, - ): void { - delete object?.example; - delete object?.examples; - } - private handleReadonly( - parent: OpenAPIV3.SchemaObject, - schema: OpenAPIV3.SchemaObject, - opts, + parentSchema: VisitorObjects['schema'] | undefined, + nodeSchema: VisitorObjects['schema'], + state: State<'request'>, ) { - if (opts.kind === 'res') return; + if (isReferenceObject(parentSchema) || isReferenceObject(nodeSchema)) { + throw new Error('Object should have been unwrapped.'); + } - const required = parent?.required ?? []; - const prop = opts?.path?.[opts?.path?.length - 1]; + const required = parentSchema?.required ?? []; + const prop = state?.path?.[state?.path?.length - 1]; const index = required.indexOf(prop); - if (schema.readOnly && index > -1) { + if (nodeSchema.readOnly && index > -1) { // remove required if readOnly - parent.required = required + parentSchema.required = required .slice(0, index) .concat(required.slice(index + 1)); - if (parent.required.length === 0) { - delete parent.required; + if (parentSchema.required.length === 0) { + delete parentSchema.required; } } } private handleWriteonly( - parent: OpenAPIV3.SchemaObject, - schema: OpenAPIV3.SchemaObject, - opts, + parentSchema: VisitorObjects['schema'] | undefined, + nodeSchema: VisitorObjects['schema'], + state: State<'response'>, ) { - if (opts.kind === 'req') return; + if (isReferenceObject(parentSchema) || isReferenceObject(nodeSchema)) { + throw new Error('Object should have been unwrapped.'); + } - const required = parent?.required ?? []; - const prop = opts?.path?.[opts?.path?.length - 1]; + const required = parentSchema?.required ?? []; + const prop = state?.path?.[state?.path?.length - 1]; const index = required.indexOf(prop); - if (schema.writeOnly && index > -1) { + if (nodeSchema.writeOnly && index > -1) { // remove required if writeOnly - parent.required = required + parentSchema.required = required .slice(0, index) .concat(required.slice(index + 1)); - if (parent.required.length === 0) { - delete parent.required; + if (parentSchema.required.length === 0) { + delete parentSchema.required; } } } - /** - * extract all requestBodies' schemas from an operation - * @param op - */ - private extractRequestBodySchemaNodes( - node: Root, - ): Root[] { - const op = node.schema; - const bodySchema = this.resolveSchema( - op.requestBody, - ); - op.requestBody = bodySchema; + private resolveObject( + object: VisitorObjects[ObjectType] | undefined, + ): + | Exclude + | undefined { + if (!object) return undefined; - if (!bodySchema?.content) return []; + const ref = object['$ref']; - const result: Root[] = []; - const contentEntries = Object.entries(bodySchema.content); - for (const [type, mediaTypeObject] of contentEntries) { - const mediaTypeSchema = this.resolveSchema( - mediaTypeObject.schema, - ); - op.requestBody.content[type].schema = mediaTypeSchema; + if (ref && this.resolvedSchemaCache.has(ref)) { + return this.resolvedSchemaCache.get(ref) as Exclude< + VisitorObjects[ObjectType], + OpenAPIV3.ReferenceObject + >; + } + + let res = (ref ? this.ajv.getSchema(ref)?.schema : object) as Exclude< + VisitorObjects[ObjectType], + OpenAPIV3.ReferenceObject + >; - // TODO replace with visitor - this.removeExamples(op.requestBody.content[type]); + if (ref && !res) { + const path = ref.split('/').join('.'); + const p = path.substring(path.indexOf('.') + 1); + res = _get(this.apiDoc, p); + } - const path = [...node.path, 'requestBody', 'content', type, 'schema']; - result.push(new Root(mediaTypeSchema, path)); + if (ref) { + this.resolvedSchemaCache.set(ref, res); } - return result; + + return res; } - private extractResponseSchemaNodes( - node: Root, - ): Root[] { - const op = node.schema; - const responses = op.responses; - - if (!responses) return []; - - const schemas: Root[] = []; - for (const [statusCode, response] of Object.entries(responses)) { - const rschema = this.resolveSchema(response); - if (!rschema) { - // issue #553 - // TODO the schema failed to resolve. - // This can occur with multi-file specs - // improve resolution, so that rschema resolves (use json ref parser?) - continue; - } - responses[statusCode] = rschema; - - if (rschema.content) { - for (const [type, mediaType] of Object.entries(rschema.content)) { - const schema = this.resolveSchema(mediaType?.schema); - if (schema) { - rschema.content[type].schema = schema; - const path = [ - ...node.path, - 'responses', - statusCode, - 'content', - type, - 'schema', - ]; - - // TODO replace with visitor - this.removeExamples(rschema.content[type]); - - schemas.push(new Root(schema, path)); - } - } - } + private getChildrenForDocument( + parent: VisitorNode<'document'>, + ): VisitorNode[] { + const children = []; + + children.push(...VisitorNode.fromParentDict(parent, 'pathItem', 'paths')); + + if (isDocumentV3_1(parent.object)) { + children.push( + VisitorNode.fromParent(parent, 'componentsV3_1', 'components'), + ); + children.push(VisitorNode.fromParentDict(parent, 'pathItem', 'webhooks')); + } else { + children.push(VisitorNode.fromParent(parent, 'components')); } - return schemas; + + return children; + } + + private getChildrenForComponents( + parent: VisitorNode<'components'>, + ): VisitorNode[] { + const children = []; + + children.push(...VisitorNode.fromParentDict(parent, 'schema', 'schemas')); + children.push( + ...VisitorNode.fromParentDict(parent, 'response', 'responses'), + ); + children.push(...VisitorNode.fromParentDict(parent, 'header', 'headers')); + children.push( + ...VisitorNode.fromParentDict(parent, 'callback', 'callbacks'), + ); + children.push( + ...VisitorNode.fromParentDict(parent, 'requestBody', 'requestBodies'), + ); + children.push( + ...VisitorNode.fromParentDict(parent, 'parameter', 'parameters'), + ); + + return children; } - private extractRequestParameterSchemaNodes( - operationNode: Root, - ): Root[] { - return (operationNode.schema.parameters ?? []).flatMap((node) => { - const parameterObject = isParameterObject(node) ? node : undefined; + private getChildrenForComponentsV3_1( + parent: VisitorNode<'componentsV3_1'>, + ): VisitorNode[] { + const children = []; - // TODO replace with visitor - // TODO This does not handle JSON query parameters - this.removeExamples(parameterObject); + children.push( + ...VisitorNode.fromParentDict(parent, 'pathItem', 'pathItems'), + ); + // process components V3.1 also like normal components + children.push(new VisitorNode('components', parent.object, parent.path)); - if (!parameterObject?.schema) return []; + return children; + } - const schema = isNonArraySchemaObject(parameterObject.schema) - ? parameterObject.schema - : undefined; - if (!schema) return []; - - return new Root(schema, [ - ...operationNode.path, - 'parameters', - parameterObject.name, - parameterObject.in, - ]); + private getChildrenForPathItem( + parent: VisitorNode<'pathItem'>, + ): VisitorNode[] { + if (isReferenceObject(parent.object)) { + throw new Error('Object should have been unwrapped.'); + } + + const children = []; + + HttpMethods.forEach((method) => { + if (method in parent.object) + children.push( + new VisitorNode('operation', parent.object[method], [ + ...parent.path, + method, + ]), + ); }); + + children.push( + ...VisitorNode.fromParentArray(parent, 'parameter', 'parameters'), + ); + + return children; } - private resolveSchema(schema): T { - if (!schema) return null; - const ref = schema?.['$ref']; - if (ref && this.resolvedSchemaCache.has(ref)) { - return this.resolvedSchemaCache.get(ref) as T; + private getChildrenForSchema( + parent: VisitorNode<'schema'>, + ): VisitorNode[] { + if (isReferenceObject(parent.object)) { + throw new Error('Object should have been unwrapped.'); } - let res = (ref ? this.ajv.getSchema(ref)?.schema : schema) as T; - if (ref && !res) { - const path = ref.split('/').join('.'); - const p = path.substring(path.indexOf('.') + 1); - res = _get(this.apiDoc, p); + + const children = []; + + if (typeof parent.object.additionalProperties !== 'boolean') { + // constructing this manually, as the type of additional properties includes boolean + children.push( + new VisitorNode('schema', parent.object.additionalProperties, [ + ...parent.path, + 'additionalProperties', + ]), + ); } - if (ref) { - this.resolvedSchemaCache.set(ref, res); + children.push( + ...VisitorNode.fromParentDict(parent, 'schema', 'properties'), + ); + + if (parent.object.type === 'array' && 'items' in parent.object) { + children.push(VisitorNode.fromParent(parent, 'schema', 'items')); + } else { + if ('not' in parent.object) { + children.push(VisitorNode.fromParent(parent, 'schema', 'not')); + } + + (['allOf', 'oneOf', 'anyOf'] as const).forEach((property) => { + children.push( + ...VisitorNode.fromParentArray(parent, 'schema', property), + ); + }); } - return res; + + return children; } - /** - * add path level parameters to the schema's parameters list - * @param pathItemKey - * @param pathItem - */ - private preprocessPathLevelParameters( - pathItemKey: string, - pathItem: OpenAPIV3.PathItemObject, - ) { - const parameters = pathItem.parameters ?? []; - if (parameters.length === 0) return; + private getChildrenForOperation( + parent: VisitorNode<'operation'>, + ): VisitorNode[] { + const children = []; - const v = this.resolveSchema( - pathItem[pathItemKey], + children.push( + ...VisitorNode.fromParentArray(parent, 'parameter', 'parameters'), ); - if (v === parameters) return; - v.parameters = v.parameters || []; - - const match = ( - pathParam: OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject, - opParam: OpenAPIV3.ReferenceObject | OpenAPIV3.OperationObject, - ) => - // if name or ref exists and are equal - (opParam['name'] && opParam['name'] === pathParam['name']) || - (opParam['$ref'] && opParam['$ref'] === pathParam['$ref']); - - // Add Path level query param to list ONLY if there is not already an operation-level query param by the same name. - for (const param of parameters) { - if (!v.parameters.some((vparam) => match(param, vparam))) { - v.parameters.push(param); - } - } + + children.push(VisitorNode.fromParent(parent, 'requestBody')); + children.push( + ...VisitorNode.fromParentDict(parent, 'response', 'responses'), + ); + children.push( + ...VisitorNode.fromParentDict(parent, 'callback', 'callbacks'), + ); + + return children; } - private findKeys(object, searchFunc): string[] { - const matches = []; - if (!object) { - return matches; - } - const keys = Object.keys(object); - for (let i = 0; i < keys.length; i++) { - if (searchFunc(object[keys[i]])) { - matches.push(keys[i]); - } + private getChildrenForRequestBody( + parent: VisitorNode<'requestBody'>, + ): VisitorNode[] { + const children = []; + + children.push( + ...VisitorNode.fromParentDict(parent, 'mediaType', 'content'), + ); + + return children; + } + + private getChildrenForResponse( + parent: VisitorNode<'response'>, + ): VisitorNode[] { + const children = []; + + children.push(...VisitorNode.fromParentDict(parent, 'header', 'headers')); + children.push( + ...VisitorNode.fromParentDict(parent, 'mediaType', 'content'), + ); + + return children; + } + + private getChildrenForEncoding( + parent: VisitorNode<'encoding'>, + ): VisitorNode[] { + const children = []; + + children.push(...VisitorNode.fromParentDict(parent, 'header', 'headers')); + + return children; + } + + private getChildrenForParameterBase( + parent: VisitorNode<'parameter' | 'header'>, + ): VisitorNode[] { + const children = []; + + children.push(VisitorNode.fromParent(parent, 'schema')); + children.push( + ...VisitorNode.fromParentDict(parent, 'mediaType', 'content'), + ); + + return children; + } + + private getChildrenForHeader( + parent: VisitorNode<'header'>, + ): VisitorNode[] { + const children = []; + + children.push(...this.getChildrenForParameterBase(parent)); + + return children; + } + + private getChildrenForMediaType( + parent: VisitorNode<'mediaType'>, + ): VisitorNode[] { + const children = []; + + children.push(VisitorNode.fromParent(parent, 'schema')); + children.push( + ...VisitorNode.fromParentDict(parent, 'encoding', 'encoding'), + ); + + return children; + } + + private getChildrenForParameter( + parent: VisitorNode<'parameter'>, + ): VisitorNode[] { + const children = []; + + children.push(...this.getChildrenForParameterBase(parent)); + children.push(VisitorNode.fromParent(parent, 'schema')); + children.push( + ...VisitorNode.fromParentDict(parent, 'mediaType', 'content'), + ); + + return children; + } + + private getChildrenForCallback( + parent: VisitorNode<'callback'>, + ): VisitorNode[] { + if (isReferenceObject(parent.object)) { + throw new Error('Object should have been unwrapped.'); } + + const children = []; + + forEachValue(parent.object, (pathItem, key) => { + children.push( + new VisitorNode('pathItem', pathItem, [...parent.path, key]), + ); + }); + + return children; + } +} + +function isDocumentV3_1( + document: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, +): document is OpenAPIV3.DocumentV3_1 { + return document.openapi.startsWith('3.1.'); +} + +function isReferenceNode( + node: VisitorNode, +): node is VisitorNode { + return [ + 'pathItem', + 'schema', + 'requestBody', + 'response', + 'header', + 'parameter', + 'callback', + ].includes(node.type); +} + +function isReferenceObject( + object: VisitorObjects[SchemaType] | undefined, +): object is OpenAPIV3.ReferenceObject { + return object !== undefined && '$ref' in object && !!object.$ref; +} + +function hasNodeType( + node: VisitorNode | undefined, + type: ObjectType, +): node is VisitorNode { + return node?.type === type; +} + +function forEachValue( + object: { [key: string]: Value }, + perform: (value: Value, key: string) => void, +): void { + Object.entries(object).forEach(([key, value]) => perform(value, key)); +} + +function findKeys( + object: { [value: string]: string }, + searchFunc: (key: string) => boolean, +): string[] { + const matches: string[] = []; + + if (!object) { return matches; } - getKeyFromRef(ref) { - return ref.split('/components/schemas/')[1]; + const keys = Object.keys(object); + for (let i = 0; i < keys.length; i++) { + if (searchFunc(object[keys[i]])) { + matches.push(keys[i]); + } } + + return matches; +} + +function getKeyFromRef(ref: string) { + return ref.split('/components/schemas/')[1]; } From 997940d5c1394a4de02700da4b60148ed1b9c9ea Mon Sep 17 00:00:00 2001 From: Maximilian Mayer Date: Tue, 4 Mar 2025 15:43:13 +0100 Subject: [PATCH 02/12] improved state tracking --- .../parsers/schema.preprocessor.ts | 89 ++++++++++--------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index 7dd5f41e..b4f9f27a 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -68,7 +68,19 @@ type VisitorTypesWithReference = { : never; }[keyof VisitorObjects]; +type DiscriminatorState = { + discriminator?: string; + options?: { option: any; ref: any }[]; + properties?: { + [p: string]: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject; + }; + required?: string[]; +}; + class VisitorNode { + public discriminator: DiscriminatorState = {}; + public originalRef?: string; + constructor( public type: NodeType, public object: VisitorObjects[NodeType] | undefined, @@ -162,16 +174,7 @@ type VisitorState = { type State = { type: Type; - discriminator: { - discriminator?: string; - options?: { option: any; ref: any }[]; - properties?: { - [p: string]: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject; - }; - required?: string[]; - }; path: string[]; - originalObject?: OpenAPIObject; }; export class SchemaPreprocessor< @@ -219,6 +222,8 @@ export class SchemaPreprocessor< } if (isReferenceNode(node) && isReferenceObject(node.object)) { + node.originalRef = node.object.$ref; + const resolvedObject = this.resolveObject( node.object, ); @@ -233,9 +238,6 @@ export class SchemaPreprocessor< seenObjects.add(node.object); - state.request.originalObject = node.object; - state.response.originalObject = node.object; - this.visitNode(parent, node, state); let children: VisitorNode[]; @@ -282,8 +284,8 @@ export class SchemaPreprocessor< }; traverse(undefined, root, { - request: { type: 'request', discriminator: {}, path: [] }, - response: { type: 'response', discriminator: {}, path: [] }, + request: { type: 'request', path: [] }, + response: { type: 'response', path: [] }, }); } @@ -408,17 +410,17 @@ export class SchemaPreprocessor< this.handleWriteonly(parentSchema, nodeSchema, options); } - this.processDiscriminator(parentSchema, nodeSchema, options); + this.processDiscriminator(parent, node); } } private processDiscriminator( - parentSchema: VisitorObjects['schema'], - nodeSchema: VisitorObjects['schema'], - state: State<'request' | 'response'>, + parent: VisitorNode<'schema'> | undefined, + node: VisitorNode<'schema'>, ): void { - const o = state.discriminator; - const schemaObj = nodeSchema; + const nodeState = node.discriminator; + const schemaObj = node.object; + const xOf = schemaObj.oneOf !== undefined ? 'oneOf' @@ -426,12 +428,8 @@ export class SchemaPreprocessor< ? 'anyOf' : undefined; - if ( - xOf && - schemaObj.discriminator?.propertyName !== undefined && - o.discriminator === undefined - ) { - o.options = schemaObj[xOf].flatMap((refObject) => { + if (xOf && schemaObj.discriminator?.propertyName !== undefined) { + nodeState.options = schemaObj[xOf].flatMap((refObject) => { if (refObject['$ref'] === undefined) { return []; } @@ -444,29 +442,31 @@ export class SchemaPreprocessor< ? keys.map((option) => ({ option, ref })) : [{ option: ref, ref }]; }); - o.discriminator = schemaObj.discriminator?.propertyName; - o.properties = { - ...(o.properties ?? {}), + nodeState.discriminator = schemaObj.discriminator?.propertyName; + nodeState.properties = { + ...(nodeState.properties ?? {}), ...(schemaObj.properties ?? {}), }; - o.required = Array.from( - new Set((o.required ?? []).concat(schemaObj.required ?? [])), + nodeState.required = Array.from( + new Set((nodeState.required ?? []).concat(schemaObj.required ?? [])), ); } if (xOf) return; - if (o.discriminator) { - o.properties = { - ...(o.properties ?? {}), + const parentState = parent?.discriminator; + + if (parent && parentState && parentState.discriminator) { + parentState.properties = { + ...(parentState.properties ?? {}), ...(schemaObj.properties ?? {}), }; - o.required = Array.from( - new Set((o.required ?? []).concat(schemaObj.required ?? [])), + parentState.required = Array.from( + new Set((parentState.required ?? []).concat(schemaObj.required ?? [])), ); - const ancestor: any = parentSchema; - const ref = state.originalObject['$ref']; + const ancestor: any = parent.object; + const ref = node.originalRef; if (!ref) return; @@ -483,14 +483,14 @@ export class SchemaPreprocessor< const newSchema = JSON.parse(JSON.stringify(schemaObj)); const newProperties = { - ...(o.properties ?? {}), + ...(parentState.properties ?? {}), ...(newSchema.properties ?? {}), }; if (Object.keys(newProperties).length > 0) { newSchema.properties = newProperties; } - newSchema.required = o.required; + newSchema.required = parentState.required; if (newSchema.required.length === 0) { delete newSchema.required; } @@ -500,8 +500,8 @@ export class SchemaPreprocessor< enumerable: false, value: ancestor._discriminator ?? { validators: {}, - options: o.options, - property: o.discriminator, + options: parentState.options, + property: parentState.discriminator, }, }); @@ -510,9 +510,10 @@ export class SchemaPreprocessor< this.ajv.compile(newSchema); } } + //reset data - o.properties = {}; - delete o.required; + //parentState.properties = {}; + //delete parentState.required; } } From 5623da57e5eb2a00bf5651985bd1bfc8a0f4a981 Mon Sep 17 00:00:00 2001 From: Maximilian Mayer Date: Tue, 4 Mar 2025 20:08:07 +0100 Subject: [PATCH 03/12] fix schema replacement --- src/middlewares/parsers/schema.preprocessor.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index b4f9f27a..895dba07 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -230,7 +230,15 @@ export class SchemaPreprocessor< // Resolve reference object in parent, then process again with resolved schema // As every object (aka schema) is 'pass-by-reference', this will update the actual apiDoc. - parent.object[node.path[-1]] = resolvedObject; + const lastPathComponent = node.path[node.path.length - 1]; + if (isInteger(lastPathComponent)) { + const arrayName = node.path[node.path.length - 2]; + const index = parseInt(lastPathComponent); + parent.object[arrayName][index] = resolvedObject; + } else { + parent.object[lastPathComponent] = resolvedObject; + } + node.object = resolvedObject; return traverse(parent, node as VisitorNode, state); @@ -968,3 +976,7 @@ function findKeys( function getKeyFromRef(ref: string) { return ref.split('/components/schemas/')[1]; } + +function isInteger(str: string): boolean { + return /^\d+$/.test(str); +} From ee41b3fc4ea4c2118b0908fa0d59956f68b2df02 Mon Sep 17 00:00:00 2001 From: Maximilian Mayer Date: Tue, 4 Mar 2025 22:35:10 +0100 Subject: [PATCH 04/12] fix path naming for path items --- .../parsers/schema.preprocessor.ts | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index 895dba07..e2fc9401 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -735,12 +735,7 @@ export class SchemaPreprocessor< HttpMethods.forEach((method) => { if (method in parent.object) - children.push( - new VisitorNode('operation', parent.object[method], [ - ...parent.path, - method, - ]), - ); + children.push(VisitorNode.fromParent(parent, 'operation', method)); }); children.push( @@ -844,8 +839,8 @@ export class SchemaPreprocessor< return children; } - private getChildrenForParameterBase( - parent: VisitorNode<'parameter' | 'header'>, + private getChildrenForHeader( + parent: VisitorNode<'header'>, ): VisitorNode[] { const children = []; @@ -857,16 +852,6 @@ export class SchemaPreprocessor< return children; } - private getChildrenForHeader( - parent: VisitorNode<'header'>, - ): VisitorNode[] { - const children = []; - - children.push(...this.getChildrenForParameterBase(parent)); - - return children; - } - private getChildrenForMediaType( parent: VisitorNode<'mediaType'>, ): VisitorNode[] { @@ -883,10 +868,19 @@ export class SchemaPreprocessor< private getChildrenForParameter( parent: VisitorNode<'parameter'>, ): VisitorNode[] { + if (isReferenceObject(parent.object)) { + throw new Error('Object should have been unwrapped.'); + } + const children = []; - children.push(...this.getChildrenForParameterBase(parent)); - children.push(VisitorNode.fromParent(parent, 'schema')); + children.push( + new VisitorNode('schema', parent.object.schema, [ + ...parent.path.slice(0, parent.path.length - 1), + parent.object.name, + parent.object.in, + ]), + ); children.push( ...VisitorNode.fromParentDict(parent, 'mediaType', 'content'), ); From 5e8b5dc9754fcba327e5e423461b7d6fd5da6ba9 Mon Sep 17 00:00:00 2001 From: Maximilian Mayer Date: Tue, 4 Mar 2025 23:05:29 +0100 Subject: [PATCH 05/12] small fix to document parsing order --- src/middlewares/parsers/schema.preprocessor.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index e2fc9401..407acf54 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -673,17 +673,19 @@ export class SchemaPreprocessor< ): VisitorNode[] { const children = []; - children.push(...VisitorNode.fromParentDict(parent, 'pathItem', 'paths')); - if (isDocumentV3_1(parent.object)) { children.push( VisitorNode.fromParent(parent, 'componentsV3_1', 'components'), ); - children.push(VisitorNode.fromParentDict(parent, 'pathItem', 'webhooks')); + children.push( + ...VisitorNode.fromParentDict(parent, 'pathItem', 'webhooks'), + ); } else { children.push(VisitorNode.fromParent(parent, 'components')); } + children.push(...VisitorNode.fromParentDict(parent, 'pathItem', 'paths')); + return children; } From 8528ff201a82a235102289b85dfc3a29dac39262 Mon Sep 17 00:00:00 2001 From: Maximilian Mayer Date: Tue, 4 Mar 2025 23:22:18 +0100 Subject: [PATCH 06/12] make sure all schemas are unreferenced --- src/middlewares/parsers/schema.preprocessor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index 407acf54..55607e21 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -217,7 +217,7 @@ export class SchemaPreprocessor< state: VisitorState, ) => { try { - if (node.object === undefined || seenObjects.has(node.object)) { + if (node.object === undefined) { return; } @@ -244,6 +244,8 @@ export class SchemaPreprocessor< return traverse(parent, node as VisitorNode, state); } + if (seenObjects.has(node.object)) return; + seenObjects.add(node.object); this.visitNode(parent, node, state); From bdf63158a46f3562b9c153f6349dd85261683e44 Mon Sep 17 00:00:00 2001 From: Maximilian Mayer Date: Tue, 4 Mar 2025 23:34:00 +0100 Subject: [PATCH 07/12] remove old code --- src/middlewares/parsers/schema.preprocessor.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index 55607e21..f4a64bcf 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -520,10 +520,6 @@ export class SchemaPreprocessor< this.ajv.compile(newSchema); } } - - //reset data - //parentState.properties = {}; - //delete parentState.required; } } From 127a606f471eeb324c238d2dedf19769e2e9babe Mon Sep 17 00:00:00 2001 From: Maximilian Mayer Date: Wed, 5 Mar 2025 00:41:06 +0100 Subject: [PATCH 08/12] fix incorrect reference back-filling --- .../parsers/schema.preprocessor.ts | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index f4a64bcf..ef9f6f58 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -85,6 +85,7 @@ class VisitorNode { public type: NodeType, public object: VisitorObjects[NodeType] | undefined, public path: string[], + public pathFromParent?: string[], ) {} static fromParent< @@ -130,7 +131,14 @@ class VisitorNode { }; forEachValue(dict, (value, key) => { - nodes.push(new VisitorNode(type, value, [...parent.path, dictPath, key])); + nodes.push( + new VisitorNode( + type, + value, + [...parent.path, dictPath, key], + [dictPath, key], + ), + ); }); return nodes; @@ -159,7 +167,12 @@ class VisitorNode { array.forEach((value, index) => { nodes.push( - new VisitorNode(type, value, [...parent.path, arrayPath, `${index}`]), + new VisitorNode( + type, + value, + [...parent.path, arrayPath, `${index}`], + [arrayPath, `${index}`], + ), ); }); @@ -230,12 +243,17 @@ export class SchemaPreprocessor< // Resolve reference object in parent, then process again with resolved schema // As every object (aka schema) is 'pass-by-reference', this will update the actual apiDoc. - const lastPathComponent = node.path[node.path.length - 1]; - if (isInteger(lastPathComponent)) { - const arrayName = node.path[node.path.length - 2]; - const index = parseInt(lastPathComponent); - parent.object[arrayName][index] = resolvedObject; + if (node.pathFromParent && node.pathFromParent.length > 0) { + const pathLength = node.pathFromParent.length; + + let object = parent.object; + for (let i = 0; i < pathLength - 1; i++) { + object = object[node.pathFromParent[i]]; + } + + object[node.pathFromParent[pathLength - 1]] = resolvedObject; } else { + const lastPathComponent = node.path[node.path.length - 1]; parent.object[lastPathComponent] = resolvedObject; } @@ -874,6 +892,9 @@ export class SchemaPreprocessor< const children = []; + /* + // TODO: Probably not correctly resolved in case of schema ref!!! + // path calculation is taken from the old code children.push( new VisitorNode('schema', parent.object.schema, [ ...parent.path.slice(0, parent.path.length - 1), @@ -881,6 +902,8 @@ export class SchemaPreprocessor< parent.object.in, ]), ); + */ + children.push(VisitorNode.fromParent(parent, 'schema')); children.push( ...VisitorNode.fromParentDict(parent, 'mediaType', 'content'), ); From 1de5b2c7363d1700ca35f05c5759d5ded24c3eb0 Mon Sep 17 00:00:00 2001 From: Maximilian Mayer Date: Wed, 5 Mar 2025 00:55:17 +0100 Subject: [PATCH 09/12] fix circular references --- src/middlewares/parsers/schema.preprocessor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index ef9f6f58..5f49ffd9 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -234,6 +234,8 @@ export class SchemaPreprocessor< return; } + if (seenObjects.has(node.object)) return; + if (isReferenceNode(node) && isReferenceObject(node.object)) { node.originalRef = node.object.$ref; @@ -262,8 +264,6 @@ export class SchemaPreprocessor< return traverse(parent, node as VisitorNode, state); } - if (seenObjects.has(node.object)) return; - seenObjects.add(node.object); this.visitNode(parent, node, state); From 347e3061d52f79115840aca5cd0c0220a60478ed Mon Sep 17 00:00:00 2001 From: Maximilian Mayer Date: Wed, 5 Mar 2025 01:32:41 +0100 Subject: [PATCH 10/12] properly process discriminators --- src/middlewares/parsers/schema.preprocessor.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index 5f49ffd9..a82b9678 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -243,6 +243,17 @@ export class SchemaPreprocessor< node.object, ); + if (seenObjects.has(resolvedObject)) { + if (hasNodeType(node, 'schema')) { + this.processDiscriminator( + hasNodeType(parent, 'schema') ? parent : undefined, + node, + ); + } + + return; + } + // Resolve reference object in parent, then process again with resolved schema // As every object (aka schema) is 'pass-by-reference', this will update the actual apiDoc. if (node.pathFromParent && node.pathFromParent.length > 0) { From e14103fb2fc020d96933fbd996254d40d2414bc5 Mon Sep 17 00:00:00 2001 From: Maximilian Mayer Date: Wed, 5 Mar 2025 03:20:02 +0100 Subject: [PATCH 11/12] fix circular reference detection --- .../parsers/schema.preprocessor.ts | 74 +++++++++++++------ 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index a82b9678..de3f028b 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -78,15 +78,21 @@ type DiscriminatorState = { }; class VisitorNode { - public discriminator: DiscriminatorState = {}; public originalRef?: string; + public traversedObjects: Set; // track circular references + public discriminator: DiscriminatorState = {}; constructor( public type: NodeType, public object: VisitorObjects[NodeType] | undefined, - public path: string[], + traversedObjects: Set = new Set(), + public path: string[] = [], public pathFromParent?: string[], - ) {} + ) { + // copy traversed object to not affect other children of the same parent + this.traversedObjects = new Set(traversedObjects); + this.traversedObjects.add(object); + } static fromParent< ParentType extends VisitorTypes, @@ -105,6 +111,7 @@ class VisitorNode { return new VisitorNode( type, parent.object[propertyPath] as unknown as VisitorObjects[NodeType], + parent.traversedObjects, [...parent.path, propertyPath], ); } @@ -135,6 +142,7 @@ class VisitorNode { new VisitorNode( type, value, + parent.traversedObjects, [...parent.path, dictPath, key], [dictPath, key], ), @@ -170,6 +178,7 @@ class VisitorNode { new VisitorNode( type, value, + parent.traversedObjects, [...parent.path, arrayPath, `${index}`], [arrayPath, `${index}`], ), @@ -208,7 +217,7 @@ export class SchemaPreprocessor< } public preProcess(): { apiDoc: OpenAPISchema; apiDocRes: OpenAPISchema } { - const root = new VisitorNode('document', this.apiDoc, []); + const root = new VisitorNode('document', this.apiDoc); this.traverseSchema(root); @@ -234,23 +243,33 @@ export class SchemaPreprocessor< return; } - if (seenObjects.has(node.object)) return; - + // resolve references if (isReferenceNode(node) && isReferenceObject(node.object)) { node.originalRef = node.object.$ref; + // TODO: Seemingly we do not want to "unreference" these schema properties. + // Find way to implement this more elegantly. + if ( + node.pathFromParent && + ['allOf', 'oneOf', 'anyOf'].includes(node.pathFromParent[0]) && + hasNodeType(node, 'schema') + ) { + this.processDiscriminator( + hasNodeType(parent, 'schema') ? parent : undefined, + node, + ); + return; + } + const resolvedObject = this.resolveObject( node.object, ); - if (seenObjects.has(resolvedObject)) { - if (hasNodeType(node, 'schema')) { - this.processDiscriminator( - hasNodeType(parent, 'schema') ? parent : undefined, - node, - ); - } - + // stop when detecting circular references + if ( + resolvedObject === undefined || + node.traversedObjects.has(resolvedObject) + ) { return; } @@ -271,10 +290,9 @@ export class SchemaPreprocessor< } node.object = resolvedObject; - - return traverse(parent, node as VisitorNode, state); } + if (seenObjects.has(node.object)) return; seenObjects.add(node.object); this.visitNode(parent, node, state); @@ -748,7 +766,14 @@ export class SchemaPreprocessor< ...VisitorNode.fromParentDict(parent, 'pathItem', 'pathItems'), ); // process components V3.1 also like normal components - children.push(new VisitorNode('components', parent.object, parent.path)); + children.push( + new VisitorNode( + 'components', + parent.object, + parent.traversedObjects, + parent.path, + ), + ); return children; } @@ -786,10 +811,12 @@ export class SchemaPreprocessor< if (typeof parent.object.additionalProperties !== 'boolean') { // constructing this manually, as the type of additional properties includes boolean children.push( - new VisitorNode('schema', parent.object.additionalProperties, [ - ...parent.path, - 'additionalProperties', - ]), + new VisitorNode( + 'schema', + parent.object.additionalProperties, + parent.traversedObjects, + [...parent.path, 'additionalProperties'], + ), ); } children.push( @@ -933,7 +960,10 @@ export class SchemaPreprocessor< forEachValue(parent.object, (pathItem, key) => { children.push( - new VisitorNode('pathItem', pathItem, [...parent.path, key]), + new VisitorNode('pathItem', pathItem, parent.traversedObjects, [ + ...parent.path, + key, + ]), ); }); From 10c787d06d46cd1942a87c926e2f2460c397e559 Mon Sep 17 00:00:00 2001 From: max-at-silverflow Date: Wed, 5 Mar 2025 14:55:39 +0100 Subject: [PATCH 12/12] refactored preprocessor --- .../parsers/schema.preprocessor.ts | 599 ++++++++++-------- test/ignore.examples.spec.ts | 143 +++++ 2 files changed, 471 insertions(+), 271 deletions(-) create mode 100644 test/ignore.examples.spec.ts diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index de3f028b..93b087af 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -19,9 +19,13 @@ const HttpMethods = [ 'patch', 'trace', ] as const; +const xOfObjects = ['allOf', 'oneOf', 'anyOf'] as const; +// compatibility with other code export const httpMethods = new Set(HttpMethods); +type Document = OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; + type VisitorObjects = { document: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; components: OpenAPIV3.ComponentsObject; @@ -62,10 +66,10 @@ type VisitorTypesWithReference = { ? Key : never : VisitorObjects[Key] extends OpenAPIV3.ReferenceObject | infer _Other - ? OpenAPIV3.ReferenceObject extends VisitorObjects[Key] - ? Key - : never - : never; + ? OpenAPIV3.ReferenceObject extends VisitorObjects[Key] + ? Key + : never + : never; }[keyof VisitorObjects]; type DiscriminatorState = { @@ -84,7 +88,8 @@ class VisitorNode { constructor( public type: NodeType, - public object: VisitorObjects[NodeType] | undefined, + public object: VisitorObjects[NodeType], + public responseObject: VisitorObjects[NodeType] | undefined, traversedObjects: Set = new Set(), public path: string[] = [], public pathFromParent?: string[], @@ -94,6 +99,87 @@ class VisitorNode { this.traversedObjects.add(object); } + public resolve( + parent: VisitorNode, + resolver: ObjectResolver, + responseResolver: ObjectResolver | undefined, + ): boolean { + if ( + !isReferenceNode(this) || + !isReferenceObject(this.object) || + (this.responseObject !== undefined && + !isReferenceObject(this.responseObject)) + ) { + throw new Error('Cannot resolve node that is not referenced.'); + } + + if (this.responseObject !== undefined && responseResolver === undefined) { + throw new Error( + 'Response resolver is required when response object exists.', + ); + } + + const resolvedObject = resolver.resolveObject( + this.object, + ); + const resolvedResponseObject = responseResolver?.resolveObject< + typeof this.type + >(this.responseObject as OpenAPIV3.ReferenceObject | undefined); + + // stop when detecting circular references + if ( + resolvedObject === undefined || + this.traversedObjects.has(resolvedObject) + ) { + return false; + } + + this.object = resolvedObject; + this.responseObject = resolvedResponseObject; + + // cast valid as we just resolved the object of this node + parent.resolveChild(this as VisitorNode); + + return true; + } + + public resolveChild( + child: VisitorNode, + ): void { + function resolveParentWithPath( + path: string[], + object: VisitorObjects[NodeType] | undefined, + childObject: VisitorObjects[ChildType] | undefined, + ): void { + if (object === undefined) return; + + const pathLength = path.length; + for (let i = 0; i < pathLength - 1; i++) { + object = object[path[i]]; + } + + object[path[pathLength - 1]] = childObject; + } + + // Resolve reference object in parent, then process again with resolved schema + // As every object (aka schema) is 'pass-by-reference', this will update the actual apiDoc. + if (child.pathFromParent && child.pathFromParent.length > 0) { + resolveParentWithPath(child.pathFromParent, this.object, child.object); + resolveParentWithPath( + child.pathFromParent, + this.responseObject, + child.responseObject, + ); + } else { + const lastPathComponent = child.path[child.path.length - 1]; + + this.object[lastPathComponent] = child.object; + if (this.responseObject !== undefined) { + this.responseObject[lastPathComponent] = child.responseObject; + } + } + } + static fromParent< ParentType extends VisitorTypes, NodeType extends VisitorTypes, @@ -105,15 +191,26 @@ class VisitorNode { parent: VisitorNode, type: NodeType, propertyPath?: PropertyKey, - ): VisitorNode { + ): VisitorNode[] { propertyPath = propertyPath ?? (type as unknown as PropertyKey); - return new VisitorNode( - type, - parent.object[propertyPath] as unknown as VisitorObjects[NodeType], - parent.traversedObjects, - [...parent.path, propertyPath], - ); + const object = parent.object[ + propertyPath + ] as unknown as VisitorObjects[NodeType]; + + if (object === undefined) return []; + + return [ + new VisitorNode( + type, + object, + parent.responseObject?.[ + propertyPath + ] as unknown as VisitorObjects[NodeType], + parent.traversedObjects, + [...parent.path, propertyPath], + ), + ]; } static fromParentDict< @@ -128,20 +225,19 @@ class VisitorNode { type: NodeType, dictPath: DictKey, ): VisitorNode[] { - if (parent.object[dictPath] === undefined) { - return []; - } - const nodes: VisitorNode[] = []; const dict = parent.object[dictPath] as unknown as { [key: string]: VisitorObjects[NodeType]; }; - forEachValue(dict, (value, key) => { + if (dict === undefined) return []; + + forEachValue(dict, (object, key) => { nodes.push( new VisitorNode( type, - value, + object, + parent.responseObject?.[dictPath]?.[key], parent.traversedObjects, [...parent.path, dictPath, key], [dictPath, key], @@ -164,20 +260,19 @@ class VisitorNode { type: NodeType, arrayPath: ArrayKey, ): VisitorNode[] { - if (parent.object[arrayPath] === undefined) { - return []; - } - const nodes: VisitorNode[] = []; const array = parent.object[arrayPath] as unknown as Array< VisitorObjects[NodeType] >; - array.forEach((value, index) => { + if (array === undefined) return []; + + array.forEach((object, index) => { nodes.push( new VisitorNode( type, - value, + object, + parent.responseObject?.[arrayPath]?.[index], parent.traversedObjects, [...parent.path, arrayPath, `${index}`], [arrayPath, `${index}`], @@ -189,41 +284,78 @@ class VisitorNode { } } -type VisitorState = { - request: State<'request'>; - response: State<'response'>; -}; +class ObjectResolver { + public ajv: Ajv; + private resolvedObjectCache = new Map(); -type State = { - type: Type; - path: string[]; -}; + constructor(private apiDoc: OpenAPISchema, ajvOptions: Options) { + this.ajv = createRequestAjv(this.apiDoc, ajvOptions); + } + + public resolveObject( + object: OpenAPIV3.ReferenceObject | undefined, + ): VisitorObjects[ObjectType] | undefined { + if (!object) return undefined; + + const ref = object.$ref; + + if (ref && this.resolvedObjectCache.has(ref)) { + return this.resolvedObjectCache.get(ref) as Exclude< + VisitorObjects[ObjectType], + OpenAPIV3.ReferenceObject + >; + } + + let res = (ref ? this.ajv.getSchema(ref)?.schema : object) as Exclude< + VisitorObjects[ObjectType], + OpenAPIV3.ReferenceObject + >; + + if (ref && !res) { + const path = ref.split('/').join('.'); + const p = path.substring(path.indexOf('.') + 1); + res = _get(this.apiDoc, p); + } + + if (ref) { + this.resolvedObjectCache.set(ref, res); + } + + return res; + } +} + +export class SchemaPreprocessor { + private readonly resolver: ObjectResolver; + + private readonly apiDocRes: OpenAPISchema | undefined; + private readonly responseResolver: ObjectResolver | undefined; -export class SchemaPreprocessor< - OpenAPISchema extends OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, -> { - private ajv: Ajv; - //private apiDocRes: OpenAPISchema | undefined; private readonly serDesMap: SerDesMap; - private resolvedSchemaCache = new Map(); constructor( private apiDoc: OpenAPISchema, ajvOptions: Options, private responseOptions: ValidateResponseOpts | undefined, ) { - this.ajv = createRequestAjv(this.apiDoc, ajvOptions); + this.resolver = new ObjectResolver(this.apiDoc, ajvOptions); + + this.apiDocRes = !!this.responseOptions ? cloneDeep(this.apiDoc) : null; + if (this.apiDocRes) { + this.responseResolver = new ObjectResolver(this.apiDocRes, ajvOptions); + } + this.serDesMap = ajvOptions.serDesMap; } public preProcess(): { apiDoc: OpenAPISchema; apiDocRes: OpenAPISchema } { - const root = new VisitorNode('document', this.apiDoc); + const root = new VisitorNode('document', this.apiDoc, this.apiDocRes); this.traverseSchema(root); return { apiDoc: this.apiDoc, - apiDocRes: cloneDeep(this.apiDoc), // TODO: Should be response doc + apiDocRes: this.apiDocRes, }; } @@ -236,114 +368,86 @@ export class SchemaPreprocessor< >( parent: VisitorNode, node: VisitorNode, - state: VisitorState, ) => { - try { - if (node.object === undefined) { - return; - } + // should technically not be required + if (node.object === undefined) { + return; + } - // resolve references - if (isReferenceNode(node) && isReferenceObject(node.object)) { - node.originalRef = node.object.$ref; - - // TODO: Seemingly we do not want to "unreference" these schema properties. - // Find way to implement this more elegantly. - if ( - node.pathFromParent && - ['allOf', 'oneOf', 'anyOf'].includes(node.pathFromParent[0]) && - hasNodeType(node, 'schema') - ) { - this.processDiscriminator( - hasNodeType(parent, 'schema') ? parent : undefined, - node, - ); - return; - } - - const resolvedObject = this.resolveObject( - node.object, - ); + // resolve references + if (isReferenceNode(node) && isReferenceObject(node.object)) { + node.originalRef = node.object.$ref; - // stop when detecting circular references - if ( - resolvedObject === undefined || - node.traversedObjects.has(resolvedObject) - ) { - return; - } - - // Resolve reference object in parent, then process again with resolved schema - // As every object (aka schema) is 'pass-by-reference', this will update the actual apiDoc. - if (node.pathFromParent && node.pathFromParent.length > 0) { - const pathLength = node.pathFromParent.length; - - let object = parent.object; - for (let i = 0; i < pathLength - 1; i++) { - object = object[node.pathFromParent[i]]; - } - - object[node.pathFromParent[pathLength - 1]] = resolvedObject; - } else { - const lastPathComponent = node.path[node.path.length - 1]; - parent.object[lastPathComponent] = resolvedObject; - } - - node.object = resolvedObject; + // To handle discriminators correctly, we cannot resolve xOF schemas. + // Instead, just process discriminator. + if ( + node.pathFromParent && + (xOfObjects as readonly string[]).includes( + node.pathFromParent[0], // this works, as these nodes are always constructed from arrays + ) && + hasNodeType(node, 'schema') + ) { + this.processDiscriminator( + hasNodeType(parent, 'schema') ? parent : undefined, + node, + ); + return; } - if (seenObjects.has(node.object)) return; - seenObjects.add(node.object); - - this.visitNode(parent, node, state); - - let children: VisitorNode[]; - - if (hasNodeType(node, 'document')) { - children = this.getChildrenForDocument(node); - } else if (hasNodeType(node, 'components')) { - children = this.getChildrenForComponents(node); - } else if (hasNodeType(node, 'componentsV3_1')) { - children = this.getChildrenForComponentsV3_1(node); - } else if (hasNodeType(node, 'pathItem')) { - children = this.getChildrenForPathItem(node); - } else if (hasNodeType(node, 'schema')) { - children = this.getChildrenForSchema(node); - } else if (hasNodeType(node, 'operation')) { - children = this.getChildrenForOperation(node); - } else if (hasNodeType(node, 'requestBody')) { - children = this.getChildrenForRequestBody(node); - } else if (hasNodeType(node, 'response')) { - children = this.getChildrenForResponse(node); - } else if (hasNodeType(node, 'encoding')) { - children = this.getChildrenForEncoding(node); - } else if (hasNodeType(node, 'header')) { - children = this.getChildrenForHeader(node); - } else if (hasNodeType(node, 'mediaType')) { - children = this.getChildrenForMediaType(node); - } else if (hasNodeType(node, 'parameter')) { - children = this.getChildrenForParameter(node); - } else if (hasNodeType(node, 'callback')) { - children = this.getChildrenForCallback(node); - } else { - throw new Error( - `No strategy to traverse node with type ${node.type}.`, - ); + const resolved = node.resolve( + parent, + this.resolver, + this.responseResolver, + ); + + if (!resolved) { + return; } + } - children.forEach((child) => { - // cloning state to isolate against sub-objects affecting each other's state - traverse(node as VisitorNode, child, cloneDeep(state)); - }); - } catch (error) { - throw error; + if (seenObjects.has(node.object)) return; + seenObjects.add(node.object); + + this.visitNode(parent, node); + + let children: VisitorNode[]; + + if (hasNodeType(node, 'document')) { + children = this.getChildrenForDocument(node); + } else if (hasNodeType(node, 'components')) { + children = this.getChildrenForComponents(node); + } else if (hasNodeType(node, 'componentsV3_1')) { + children = this.getChildrenForComponentsV3_1(node); + } else if (hasNodeType(node, 'pathItem')) { + children = this.getChildrenForPathItem(node); + } else if (hasNodeType(node, 'schema')) { + children = this.getChildrenForSchema(node); + } else if (hasNodeType(node, 'operation')) { + children = this.getChildrenForOperation(node); + } else if (hasNodeType(node, 'requestBody')) { + children = this.getChildrenForRequestBody(node); + } else if (hasNodeType(node, 'response')) { + children = this.getChildrenForResponse(node); + } else if (hasNodeType(node, 'encoding')) { + children = this.getChildrenForEncoding(node); + } else if (hasNodeType(node, 'header')) { + children = this.getChildrenForHeader(node); + } else if (hasNodeType(node, 'mediaType')) { + children = this.getChildrenForMediaType(node); + } else if (hasNodeType(node, 'parameter')) { + children = this.getChildrenForParameter(node); + } else if (hasNodeType(node, 'callback')) { + children = this.getChildrenForCallback(node); + } else { + throw new Error(`No strategy to traverse node with type ${node.type}.`); } + + children.forEach((child) => { + traverse(node as VisitorNode, child); + }); }; - traverse(undefined, root, { - request: { type: 'request', path: [] }, - response: { type: 'response', path: [] }, - }); + traverse(undefined, root); } private visitNode< @@ -352,7 +456,6 @@ export class SchemaPreprocessor< >( parent: VisitorNode | undefined, node: VisitorNode, - state: VisitorState, ): void { this.removeExamples(node); @@ -362,7 +465,6 @@ export class SchemaPreprocessor< this.preProcessSchema( hasNodeType(parent, 'schema') ? parent : undefined, node, - state, ); } } @@ -370,12 +472,19 @@ export class SchemaPreprocessor< private removeExamples( node: VisitorNode, ): void { - if (isReferenceObject(node.object)) { + if ( + isReferenceObject(node.object) || + isReferenceObject(node.responseObject) + ) { throw new Error('Object should have been unwrapped.'); } if (hasNodeType(node, 'components')) { delete node.object.examples; + + if (node.responseObject) { + delete node.responseObject.examples; + } } else if ( hasNodeType(node, 'mediaType') || hasNodeType(node, 'header') || @@ -384,6 +493,11 @@ export class SchemaPreprocessor< ) { delete node.object.example; delete node.object.examples; + + if (node.responseObject) { + delete node.responseObject.example; + delete node.responseObject.examples; + } } } @@ -430,45 +544,29 @@ export class SchemaPreprocessor< private preProcessSchema( parent: VisitorNode<'schema'> | undefined, node: VisitorNode<'schema'>, - state: VisitorState, ): void { - if (isReferenceObject(parent?.object) || isReferenceObject(node.object)) { + if ( + isReferenceObject(parent?.object) || + isReferenceObject(parent?.responseObject) || + isReferenceObject(node.object) || + isReferenceObject(node.responseObject) + ) { throw new Error('Object should have been unwrapped.'); } - const parentSchemas = [parent?.object]; - const nodeSchemas = [node.object]; - - /* - // TODO: Should be response doc - if (this.apiDoc) { - const parentResponseSchema = _get(this.apiDoc, parent?.path); - const nodeResponseSchema = _get(this.apiDoc, node.path); - parentSchemas.push(parentResponseSchema); - nodeSchemas.push(nodeResponseSchema); - } - */ - - // visit the node in both the request and response schema - for (let i = 0; i < nodeSchemas.length; i++) { - const kind = i === 0 ? 'request' : 'response'; - - const parentSchema = parentSchemas[i]; - const nodeSchema = nodeSchemas[i]; + this.handleSerDes(node.object); + this.handleSerDes(node.responseObject); - const options = state[kind]; - options.path = node.path; - - this.handleSerDes(nodeSchema); - - if (options.type === 'request') { - this.handleReadonly(parentSchema, nodeSchema, options); - } else { - this.handleWriteonly(parentSchema, nodeSchema, options); - } + // NOTE: only using request schemas, not response schemas! + this.handleReadonly(parent?.object, node.object, node.path); + // NOTE: only using response schemas, not request schemas! + this.handleWriteonly( + parent?.responseObject, + node.responseObject, + node.path, + ); - this.processDiscriminator(parent, node); - } + this.processDiscriminator(parent, node); } private processDiscriminator( @@ -482,8 +580,8 @@ export class SchemaPreprocessor< schemaObj.oneOf !== undefined ? 'oneOf' : schemaObj.anyOf - ? 'anyOf' - : undefined; + ? 'anyOf' + : undefined; if (xOf && schemaObj.discriminator?.propertyName !== undefined) { nodeState.options = schemaObj[xOf].flatMap((refObject) => { @@ -564,7 +662,7 @@ export class SchemaPreprocessor< for (const option of options) { ancestor._discriminator.validators[option] = - this.ajv.compile(newSchema); + this.resolver.ajv.compile(newSchema); } } } @@ -598,52 +696,42 @@ export class SchemaPreprocessor< * * See [`createAjv`](../../framework/ajv/index.ts) for custom keyword definitions. * - * @param {object} nodeSchema - schema + * @param {object} schema - schema */ - private handleSerDes(nodeSchema: VisitorObjects['schema']): void { - if (isReferenceObject(nodeSchema)) { - throw new Error('Object should have been unwrapped.'); - } + private handleSerDes(schema: OpenAPIV3.SchemaObject | undefined): void { + if (schema === undefined) return; if ( - nodeSchema.type === 'string' && - !!nodeSchema.format && - this.serDesMap[nodeSchema.format] + schema.type === 'string' && + !!schema.format && + this.serDesMap[schema.format] ) { - const serDes = this.serDesMap[nodeSchema.format]; - (nodeSchema)['x-eov-type'] = nodeSchema.type; - if ('nullable' in nodeSchema) { + const serDes = this.serDesMap[schema.format]; + + (schema)['x-eov-type'] = schema.type; + + if ('nullable' in schema) { // Ajv requires `type` keyword with `nullable` (regardless of value). - (nodeSchema).type = [ - 'string', - 'number', - 'boolean', - 'object', - 'array', - ]; + (schema).type = ['string', 'number', 'boolean', 'object', 'array']; } else { - delete nodeSchema.type; + delete schema.type; } if (serDes.deserialize) { - nodeSchema['x-eov-req-serdes'] = serDes; + schema['x-eov-req-serdes'] = serDes; } if (serDes.serialize) { - nodeSchema['x-eov-res-serdes'] = serDes; + schema['x-eov-res-serdes'] = serDes; } } } private handleReadonly( - parentSchema: VisitorObjects['schema'] | undefined, - nodeSchema: VisitorObjects['schema'], - state: State<'request'>, + parentSchema: OpenAPIV3.SchemaObject | undefined, + nodeSchema: OpenAPIV3.SchemaObject, + path: string[], ) { - if (isReferenceObject(parentSchema) || isReferenceObject(nodeSchema)) { - throw new Error('Object should have been unwrapped.'); - } - const required = parentSchema?.required ?? []; - const prop = state?.path?.[state?.path?.length - 1]; + const prop = path[path.length - 1]; const index = required.indexOf(prop); if (nodeSchema.readOnly && index > -1) { // remove required if readOnly @@ -657,18 +745,14 @@ export class SchemaPreprocessor< } private handleWriteonly( - parentSchema: VisitorObjects['schema'] | undefined, - nodeSchema: VisitorObjects['schema'], - state: State<'response'>, + parentSchema: OpenAPIV3.SchemaObject | undefined, + nodeSchema: OpenAPIV3.SchemaObject, + path: string[], ) { - if (isReferenceObject(parentSchema) || isReferenceObject(nodeSchema)) { - throw new Error('Object should have been unwrapped.'); - } - const required = parentSchema?.required ?? []; - const prop = state?.path?.[state?.path?.length - 1]; + const prop = path[path.length - 1]; const index = required.indexOf(prop); - if (nodeSchema.writeOnly && index > -1) { + if (nodeSchema?.writeOnly && index > -1) { // remove required if writeOnly parentSchema.required = required .slice(0, index) @@ -679,40 +763,6 @@ export class SchemaPreprocessor< } } - private resolveObject( - object: VisitorObjects[ObjectType] | undefined, - ): - | Exclude - | undefined { - if (!object) return undefined; - - const ref = object['$ref']; - - if (ref && this.resolvedSchemaCache.has(ref)) { - return this.resolvedSchemaCache.get(ref) as Exclude< - VisitorObjects[ObjectType], - OpenAPIV3.ReferenceObject - >; - } - - let res = (ref ? this.ajv.getSchema(ref)?.schema : object) as Exclude< - VisitorObjects[ObjectType], - OpenAPIV3.ReferenceObject - >; - - if (ref && !res) { - const path = ref.split('/').join('.'); - const p = path.substring(path.indexOf('.') + 1); - res = _get(this.apiDoc, p); - } - - if (ref) { - this.resolvedSchemaCache.set(ref, res); - } - - return res; - } - private getChildrenForDocument( parent: VisitorNode<'document'>, ): VisitorNode[] { @@ -720,13 +770,13 @@ export class SchemaPreprocessor< if (isDocumentV3_1(parent.object)) { children.push( - VisitorNode.fromParent(parent, 'componentsV3_1', 'components'), + ...VisitorNode.fromParent(parent, 'componentsV3_1', 'components'), ); children.push( ...VisitorNode.fromParentDict(parent, 'pathItem', 'webhooks'), ); } else { - children.push(VisitorNode.fromParent(parent, 'components')); + children.push(...VisitorNode.fromParent(parent, 'components')); } children.push(...VisitorNode.fromParentDict(parent, 'pathItem', 'paths')); @@ -770,6 +820,7 @@ export class SchemaPreprocessor< new VisitorNode( 'components', parent.object, + parent.responseObject, parent.traversedObjects, parent.path, ), @@ -789,7 +840,7 @@ export class SchemaPreprocessor< HttpMethods.forEach((method) => { if (method in parent.object) - children.push(VisitorNode.fromParent(parent, 'operation', method)); + children.push(...VisitorNode.fromParent(parent, 'operation', method)); }); children.push( @@ -802,18 +853,25 @@ export class SchemaPreprocessor< private getChildrenForSchema( parent: VisitorNode<'schema'>, ): VisitorNode[] { - if (isReferenceObject(parent.object)) { + if ( + isReferenceObject(parent.object) || + isReferenceObject(parent.responseObject) + ) { throw new Error('Object should have been unwrapped.'); } const children = []; - if (typeof parent.object.additionalProperties !== 'boolean') { + if ( + typeof parent.object.additionalProperties !== 'boolean' && + typeof parent.responseObject?.additionalProperties !== 'boolean' + ) { // constructing this manually, as the type of additional properties includes boolean children.push( new VisitorNode( 'schema', parent.object.additionalProperties, + parent.responseObject?.additionalProperties, parent.traversedObjects, [...parent.path, 'additionalProperties'], ), @@ -824,13 +882,13 @@ export class SchemaPreprocessor< ); if (parent.object.type === 'array' && 'items' in parent.object) { - children.push(VisitorNode.fromParent(parent, 'schema', 'items')); + children.push(...VisitorNode.fromParent(parent, 'schema', 'items')); } else { if ('not' in parent.object) { - children.push(VisitorNode.fromParent(parent, 'schema', 'not')); + children.push(...VisitorNode.fromParent(parent, 'schema', 'not')); } - (['allOf', 'oneOf', 'anyOf'] as const).forEach((property) => { + xOfObjects.forEach((property) => { children.push( ...VisitorNode.fromParentArray(parent, 'schema', property), ); @@ -849,7 +907,7 @@ export class SchemaPreprocessor< ...VisitorNode.fromParentArray(parent, 'parameter', 'parameters'), ); - children.push(VisitorNode.fromParent(parent, 'requestBody')); + children.push(...VisitorNode.fromParent(parent, 'requestBody')); children.push( ...VisitorNode.fromParentDict(parent, 'response', 'responses'), ); @@ -900,7 +958,7 @@ export class SchemaPreprocessor< ): VisitorNode[] { const children = []; - children.push(VisitorNode.fromParent(parent, 'schema')); + children.push(...VisitorNode.fromParent(parent, 'schema')); children.push( ...VisitorNode.fromParentDict(parent, 'mediaType', 'content'), ); @@ -913,7 +971,7 @@ export class SchemaPreprocessor< ): VisitorNode[] { const children = []; - children.push(VisitorNode.fromParent(parent, 'schema')); + children.push(...VisitorNode.fromParent(parent, 'schema')); children.push( ...VisitorNode.fromParentDict(parent, 'encoding', 'encoding'), ); @@ -941,7 +999,7 @@ export class SchemaPreprocessor< ]), ); */ - children.push(VisitorNode.fromParent(parent, 'schema')); + children.push(...VisitorNode.fromParent(parent, 'schema')); children.push( ...VisitorNode.fromParentDict(parent, 'mediaType', 'content'), ); @@ -960,10 +1018,13 @@ export class SchemaPreprocessor< forEachValue(parent.object, (pathItem, key) => { children.push( - new VisitorNode('pathItem', pathItem, parent.traversedObjects, [ - ...parent.path, - key, - ]), + new VisitorNode( + 'pathItem', + pathItem, + parent.responseObject[key], + parent.traversedObjects, + [...parent.path, key], + ), ); }); @@ -994,7 +1055,7 @@ function isReferenceNode( function isReferenceObject( object: VisitorObjects[SchemaType] | undefined, ): object is OpenAPIV3.ReferenceObject { - return object !== undefined && '$ref' in object && !!object.$ref; + return !!object && '$ref' in object && !!object.$ref; } function hasNodeType( @@ -1034,7 +1095,3 @@ function findKeys( function getKeyFromRef(ref: string) { return ref.split('/components/schemas/')[1]; } - -function isInteger(str: string): boolean { - return /^\d+$/.test(str); -} 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!', + }, + }, + }, + }, + }; +}