diff --git a/src/handlerbars.ts b/src/handlerbars.ts index 356c230..f2d4eef 100644 --- a/src/handlerbars.ts +++ b/src/handlerbars.ts @@ -33,7 +33,7 @@ export const createHandlebars = (jsonSchema: $Refs): typeof Handlebars => { return frame; }; - [array, code, collection, comparison, date, html, i18n, inflection, markdown, math, misc, number, object, path, regex, string, url].forEach( + [array, code, collection, comparison, date, html, i18n, inflection, object, markdown, math, misc, number, object, path, regex, string, url].forEach( helper => { handlebars.registerHelper(helper); }, @@ -221,7 +221,7 @@ export const createHandlebars = (jsonSchema: $Refs): typeof Handlebars => { handlebars.log = (level, ...messages) => { const levels = ['debug', 'info', 'warn', 'error']; - const actualLevel = typeof level === 'string' ? (levels.includes(level) ? level : 'info') : levels.at(level) ?? 'info'; + const actualLevel = typeof level === 'string' ? (levels.includes(level) ? level : 'info') : (levels.at(level) ?? 'info'); (handlebars.logger as any)['actualLogger'][actualLevel](...messages); }; diff --git a/src/optimizer/converter.ts b/src/optimizer/converter.ts index 0221580..cb508ed 100644 --- a/src/optimizer/converter.ts +++ b/src/optimizer/converter.ts @@ -89,6 +89,8 @@ export class Converter { return this.convertArray(parsedNode, parsedComponents, components); } else if (parsed.isUnion(parsedNode)) { return this.convertUnion(parsedNode, parsedComponents, components); + } else if (parsed.isXor(parsedNode)) { + return this.convertXor(parsedNode, parsedComponents, components); } else if (parsed.isComposite(parsedNode)) { return this.convertComposite(parsedNode, parsedComponents, components); } else if (parsed.isExclusion(parsedNode)) { @@ -136,9 +138,26 @@ export class Converter { if (parsedNode.definitions.length === 1) { return this.convertParsedNode(parsedNode.definitions[0], parsedComponents, components); } + + let discriminatorPropertyName: string | undefined; + let discriminatorMapping: Record | undefined; + + if ('discriminatorPropertyName' in parsedNode && parsedNode.discriminatorPropertyName) { + discriminatorPropertyName = parsedNode.discriminatorPropertyName; + + if (parsedNode.discriminatorMapping) { + discriminatorMapping = {}; + for (const [key, value] of Object.entries(parsedNode.discriminatorMapping)) { + discriminatorMapping[key] = this.convertParsedNode(value, parsedComponents, components); + } + } + } + return { ...parsedNode, definitions: parsedNode.definitions.map(p => this.convertParsedNode(p, parsedComponents, components)), + discriminatorPropertyName, + discriminatorMapping, }; } @@ -146,6 +165,10 @@ export class Converter { return this.compress(parsedNode, parsedComponents, components); } + private convertXor(parsedNode: parsed.Xor, parsedComponents: parsed.Components, components: optimized.Components) { + return this.compress(parsedNode, parsedComponents, components); + } + private convertComposite(parsedNode: parsed.Composite, parsedComponents: parsed.Components, components: optimized.Components) { return this.compress(parsedNode, parsedComponents, components); } diff --git a/src/optimizer/nodes/index.ts b/src/optimizer/nodes/index.ts index 80116e8..56c1205 100644 --- a/src/optimizer/nodes/index.ts +++ b/src/optimizer/nodes/index.ts @@ -11,6 +11,7 @@ export * from './header.js'; export * from './array.js'; export * from './union.js'; export * from './composite.js'; +export * from './xor.js'; export * from './exclusion.js'; export * from './request.js'; export * from './tag.js'; diff --git a/src/optimizer/nodes/union.ts b/src/optimizer/nodes/union.ts index feb75ed..b144459 100644 --- a/src/optimizer/nodes/union.ts +++ b/src/optimizer/nodes/union.ts @@ -2,6 +2,8 @@ import { OptimizedNode } from './optimized.node.js'; export interface Union extends OptimizedNode { definitions: OptimizedNode[]; + discriminatorPropertyName?: string; + discriminatorMapping?: Record; } export const isUnion = (value: OptimizedNode): value is Union => { diff --git a/src/optimizer/nodes/xor.ts b/src/optimizer/nodes/xor.ts new file mode 100644 index 0000000..3694134 --- /dev/null +++ b/src/optimizer/nodes/xor.ts @@ -0,0 +1,11 @@ +import { OptimizedNode } from './optimized.node.js'; + +export interface Xor extends OptimizedNode { + definitions: OptimizedNode[]; + discriminatorPropertyName?: string; + discriminatorMapping?: Record; +} + +export const isXor = (value: OptimizedNode): value is Xor => { + return value.type === 'xor'; +}; diff --git a/src/optimizer/openapi.optimizer.ts b/src/optimizer/openapi.optimizer.ts index 78d2460..35fb00c 100644 --- a/src/optimizer/openapi.optimizer.ts +++ b/src/optimizer/openapi.optimizer.ts @@ -343,7 +343,7 @@ export class OpenApiOptimizer { pathRegex = { ...pathRegex, [name]: definition.format ?? definition.type }; } }); - } else if (optimized.isComposite(found) || optimized.isUnion(found)) { + } else if (optimized.isComposite(found) || optimized.isUnion(found) || optimized.isXor(found)) { // todo need to recursively check the definitions found.definitions?.forEach(d => { diff --git a/src/parser/openapi.parser.ts b/src/parser/openapi.parser.ts index 79d17d6..dc6b21b 100644 --- a/src/parser/openapi.parser.ts +++ b/src/parser/openapi.parser.ts @@ -908,13 +908,40 @@ export abstract class OpenApiParser { const definitions = oneOf.map(x => this.parseSchema(x, schema.type)); + let discriminatorPropertyName: string | undefined; + let discriminatorMapping: Record | undefined; + + if (schema.discriminator) { + discriminatorPropertyName = schema.discriminator.propertyName; + + if (schema.discriminator.mapping) { + discriminatorMapping = {}; + for (const [key, value] of Object.entries(schema.discriminator.mapping)) { + // Parse the reference string into a ParsedNode + const parsedValue = this.parseDiscriminatorReference(value); + discriminatorMapping[key] = parsedValue; + } + } + } + return { - type: 'union', + type: 'xor', ...modifiers, definitions, + discriminatorPropertyName, + discriminatorMapping, }; } + private parseDiscriminatorReference(referenceString: string): ParsedNode { + // Create a reference object + const referenceObject: ReferenceObject = { + $ref: referenceString, + }; + + return this.createReference(referenceObject); + } + private parseNotObject(schema: ReferenceObject | SchemaObject, modifiers: Modifiers): Exclusion { return { type: 'exclusion', diff --git a/src/parser/organizer.ts b/src/parser/organizer.ts index 55edc84..bc621f5 100644 --- a/src/parser/organizer.ts +++ b/src/parser/organizer.ts @@ -207,6 +207,10 @@ export class Organizer { node.definitions.forEach(x => { this.traverseReferences(originalDocument, components, x); }); + } else if (parsed.isXor(node)) { + node.definitions.forEach(x => { + this.traverseReferences(originalDocument, components, x); + }); } } diff --git a/src/parser/parsed_nodes/index.ts b/src/parser/parsed_nodes/index.ts index 0a83e4b..affac6b 100644 --- a/src/parser/parsed_nodes/index.ts +++ b/src/parser/parsed_nodes/index.ts @@ -7,6 +7,7 @@ export * from './primative.js'; export * from './object.js'; export * from './composite.js'; export * from './union.js'; +export * from './xor.js'; export * from './exclusion.js'; export * from './array.js'; export * from './link.js'; diff --git a/src/parser/parsed_nodes/modifiers.ts b/src/parser/parsed_nodes/modifiers.ts index ae0edf5..41bd5ac 100644 --- a/src/parser/parsed_nodes/modifiers.ts +++ b/src/parser/parsed_nodes/modifiers.ts @@ -62,7 +62,7 @@ export const compareModifiers = (a: Modifiers, b: Modifiers): boolean => { //a.required === b.required && //a.enum === b.enum && a.nullable === b.nullable && - //a.discriminator === b.discriminator && + a.discriminator === b.discriminator && a.readOnly === b.readOnly && a.writeOnly === b.writeOnly && a.example === b.example && diff --git a/src/parser/parsed_nodes/union.ts b/src/parser/parsed_nodes/union.ts index 854934a..544e587 100644 --- a/src/parser/parsed_nodes/union.ts +++ b/src/parser/parsed_nodes/union.ts @@ -2,6 +2,8 @@ import { ParsedNode } from './parsed.node.js'; export interface Union extends ParsedNode { definitions: ParsedNode[]; + discriminatorPropertyName?: string; + discriminatorMapping?: Record; } export const isUnion = (value: ParsedNode): value is Union => { diff --git a/src/parser/parsed_nodes/xor.ts b/src/parser/parsed_nodes/xor.ts new file mode 100644 index 0000000..3ce524a --- /dev/null +++ b/src/parser/parsed_nodes/xor.ts @@ -0,0 +1,11 @@ +import { ParsedNode } from './parsed.node.js'; + +export interface Xor extends ParsedNode { + definitions: ParsedNode[]; + discriminatorPropertyName?: string; + discriminatorMapping?: Record; +} + +export const isXor = (value: ParsedNode): value is Xor => { + return value.type === 'xor'; +}; diff --git a/templates/server/model.expression.hbs b/templates/server/model.expression.hbs index f4f1770..f90ffbe 100644 --- a/templates/server/model.expression.hbs +++ b/templates/server/model.expression.hbs @@ -13,6 +13,7 @@ {{~#compare type "===" "reference"}}{{& prefix}}{{>lookupReference name=$ref type='models'}}{{& suffix}}{{/compare}} {{~#compare type "===" "composite"}}{{#if definitions}}{{#forEach definitions}}{{>model.expression .}}{{#unless isLast}}&{{/unless}}{{/forEach}}{{/if}}{{/compare}} {{~#compare type "===" "union"}}{{#if definitions}}{{#forEach definitions}}{{>model.expression .}}{{#unless isLast}}|{{/unless}}{{/forEach}}{{/if}}{{/compare}} +{{~#compare type "===" "xor"}}{{#if definitions}}{{#forEach definitions}}{{>model.expression .}}{{#unless isLast}}|{{/unless}}{{/forEach}}{{/if}}{{/compare}} {{~#compare type "===" "responseObject"}} { $status: {{status}}; diff --git a/templates/server/validation.declaration.hbs b/templates/server/validation.declaration.hbs index bf99b8f..45a1bce 100644 --- a/templates/server/validation.declaration.hbs +++ b/templates/server/validation.declaration.hbs @@ -1,6 +1,7 @@ -export const {{& replace (replace name (toRegex "(-|\.| )+" "g") "_") (toRegex "[_-]([a-z])" "gi") (function "(_, x)=>x.toUpperCase()") }}{{& suffix}}:utilities.ValidatorFn = ({{inputName}}: any): utilities.ValidatorResponse => { - let {{errorsName}} = new Map(); - const allowedProperties: string[] = []; - {{>validation.expression . inputName=./inputName errorsName=./errorsName}} - return { {{errorsName}}, allowedProperties}; -} +{{#if (and (compare type "===" "xor") discriminatorMapping)}} + {{>validation.declaration.xor.discriminator suffix=suffix inputName=inputName errorsName=errorsName}} +{{else or (compare type "===" "union") (compare type "===" "xor")}} + {{>validation.declaration.union suffix=suffix inputName=inputName errorsName=errorsName}} +{{else}} + {{>validation.declaration.single suffix=suffix inputName=inputName errorsName=errorsName}} +{{/if}} \ No newline at end of file diff --git a/templates/server/validation.declaration.single.hbs b/templates/server/validation.declaration.single.hbs new file mode 100644 index 0000000..bf99b8f --- /dev/null +++ b/templates/server/validation.declaration.single.hbs @@ -0,0 +1,6 @@ +export const {{& replace (replace name (toRegex "(-|\.| )+" "g") "_") (toRegex "[_-]([a-z])" "gi") (function "(_, x)=>x.toUpperCase()") }}{{& suffix}}:utilities.ValidatorFn = ({{inputName}}: any): utilities.ValidatorResponse => { + let {{errorsName}} = new Map(); + const allowedProperties: string[] = []; + {{>validation.expression . inputName=./inputName errorsName=./errorsName}} + return { {{errorsName}}, allowedProperties}; +} diff --git a/templates/server/validation.declaration.union.hbs b/templates/server/validation.declaration.union.hbs new file mode 100644 index 0000000..52ebaa7 --- /dev/null +++ b/templates/server/validation.declaration.union.hbs @@ -0,0 +1,55 @@ + +export const {{& replace (replace name (toRegex "(-|\.| )+" "g") "_") (toRegex "[_-]([a-z])" "gi") (function "(_, x)=>x.toUpperCase()") }}{{& suffix}}:utilities.ValidatorFn = ({{inputName}}: any): utilities.ValidatorResponse => { + const validatorArray = new Array(); + const validatorErrorArray = new Array>(); + const allowedPropertiesArray = new Array(); + + {{#each definitions}} + + validatorArray.push(({{../inputName}}) => { + let {{../errorsName}} = new Map(); + let allowedProperties = new Array(); + {{>validation.expression this inputName=../inputName errorsName=../errorsName}} + + validatorErrorArray.push(validationErrors); + allowedPropertiesArray.push(allowedProperties); + }); + + {{/each}} + for (let i = 0; i < validatorArray.length; ++i) { + const validatorResponse = validatorArray[i]({{inputName}}); + if (validatorResponse.validationErrors.size === 0) { + return validatorResponse; + } + } + // merge all errors and allowedProperties + const mergedValidationErrors = new Map(); + const mergedAllowedProperties: string[] = []; + + for (let i = 0; i < validatorArray.length; ++i) { + const validatorResponse = validatorArray[i]({{inputName}}); + // Early return if any validator has no errors + if (validatorResponse.validationErrors.size === 0) { + return {validationErrors: validatorResponse.validationErrors, allowedProperties: validatorResponse.allowedProperties}; + } + + for (const [key, value] of validatorResponse.validationErrors.entries()) { + if (mergedValidationErrors.has(key)) { + // Merge error messages for the same key + const existing = mergedValidationErrors.get(key); + if (Array.isArray(existing)) { + mergedValidationErrors.set(key, existing.concat(value)); + } else if (existing !== undefined) { + mergedValidationErrors.set(key, [existing, value].flat()); + } + } else { + mergedValidationErrors.set(key, value); + } + } + mergedAllowedProperties.push(...validatorResponse.allowedProperties); + } + return { + validationErrors: mergedValidationErrors, + allowedProperties: mergedAllowedProperties + } +}; \ No newline at end of file diff --git a/templates/server/validation.declaration.xor.discriminator.hbs b/templates/server/validation.declaration.xor.discriminator.hbs new file mode 100644 index 0000000..50c7b3a --- /dev/null +++ b/templates/server/validation.declaration.xor.discriminator.hbs @@ -0,0 +1,27 @@ + +type {{& replace (replace name (toRegex "(-|\.| )+" "g") "_") (toRegex "[_-]([a-z])" "gi") (function "(_, x)=>x.toUpperCase()") }}{{& suffix}}PropertyName = "base" | "inherited"; + +export const {{& replace (replace name (toRegex "(-|\.| )+" "g") "_") (toRegex "[_-]([a-z])" "gi") (function "(_, x)=>x.toUpperCase()") }}{{& suffix}}:utilities.ValidatorFn = ({{inputName}}: any): utilities.ValidatorResponse => { + const validatorMap = new Map(); + const validatorErrorMap = new Map<{{& replace (replace name (toRegex "(-|\.| )+" "g") "_") (toRegex "[_-]([a-z])" "gi") (function "(_, x)=>x.toUpperCase()") }}{{& suffix}}PropertyName, Map>(); + const allowedPropertiesMap = new Map<{{& replace (replace name (toRegex "(-|\.| )+" "g") "_") (toRegex "[_-]([a-z])" "gi") (function "(_, x)=>x.toUpperCase()") }}{{& suffix}}PropertyName, string[]>(); + + const discriminatorPropertyName = "{{ discriminatorPropertyName}}"; + {{#each discriminatorMapping}} + validatorMap.set('{{@key}}', ({{../inputName}}) => { + let {{../errorsName}} = new Map(); + let allowedProperties = new Array(); + {{>validation.expression this inputName=../inputName errorsName=../errorsName}} + + validatorErrorMap.set('{{@key}}', new Map(validatorResponse.validationErrors)); + allowedPropertiesMap.set('{{@key}}', validatorResponse.allowedProperties); + }); + + {{/each}} + const discriminatorValue = {{./inputName}}[discriminatorPropertyName]; + const validatorResponse = validatorMap.get(discriminatorValue)({{./inputName}}); + return { + validationErrors: validatorResponse.validationErrors, + allowedProperties: validatorResponse.allowedProperties + } +}; \ No newline at end of file diff --git a/templates/server/validation.expression.hbs b/templates/server/validation.expression.hbs index e761908..50ec09f 100644 --- a/templates/server/validation.expression.hbs +++ b/templates/server/validation.expression.hbs @@ -160,7 +160,7 @@ if({{safeName}} !== undefined) { {{/compare}} {{>format}} -{{else or (compare type "===" "composite") (compare type "===" "union")}} +{{else or (compare type "===" "composite") (compare type "===" "union") (compare type "===" "xor")}} {{#each definitions}}{{>validation.expression . inputName=../inputName errorsName=../errorsName}}{{/each}} {{/if~}} diff --git a/tests/data/petstore.yaml b/tests/data/petstore.yaml index 58d869a..3d21dec 100644 --- a/tests/data/petstore.yaml +++ b/tests/data/petstore.yaml @@ -604,11 +604,91 @@ paths: description: Invalid username supplied '404': description: User not found + '/multiple-bodies-any-of': + post: + tags: + - user + summary: Multiple bodies + description: '' + operationId: multipleBodies + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/BaseComponent' + - $ref: '#/components/schemas/InheritedComponent' + responses: + '201': + description: successful operation + content: + application/json: + schema: {} + '/multiple-bodies': + post: + tags: + - user + summary: Multiple bodies + description: '' + operationId: multipleBodies + requestBody: + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/BaseComponent' + - $ref: '#/components/schemas/InheritedComponent' + responses: + '201': + description: successful operation + content: + application/json: + schema: {} + '/multiple-bodies-with-discriminator': + post: + tags: + - user + summary: Multiple bodies + description: '' + operationId: multipleBodiesWithDiscriminator + requestBody: + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/BaseComponent' + - $ref: '#/components/schemas/InheritedComponent' + discriminator: + propertyName: type + mapping: + base: '#/components/schemas/BaseComponent' + inherited: '#/components/schemas/InheritedComponent' + responses: + '201': + description: successful operation + content: + application/json: + schema: {} externalDocs: description: Find out more about Swagger url: 'http://swagger.io' components: schemas: + BaseComponent: + type: object + properties: + type: + type: string + enum: + - base + - inherited + InheritedComponent: + allOf: + - $ref: '#/components/schemas/BaseComponent' + - type: object + properties: + name: + type: string Order: x-swagger-router-model: io.swagger.petstore.model.Order properties: diff --git a/tests/parse-schema.test.ts b/tests/parse-schema.test.ts index f512171..0b4b05d 100644 --- a/tests/parse-schema.test.ts +++ b/tests/parse-schema.test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { noopLogger } from '@flexbase/logger'; -import { parseSpec } from '../src/parse'; -import { OpenApiOptimizer } from '../src/optimizer/openapi.optimizer'; -import { Organizer } from '../src/parser/organizer'; +import { parseSpec } from '../src/parse.js'; +import { OpenApiOptimizer } from '../src/optimizer/openapi.optimizer.js'; +import { Organizer } from '../src/parser/organizer.js'; test('petstore schemas', async () => { const organizer = new Organizer(noopLogger); @@ -10,11 +10,61 @@ test('petstore schemas', async () => { const parsedDocument = await parseSpec('./tests/data/petstore.yaml', noopLogger); - expect(parsedDocument.paths).toHaveLength(13); + expect(parsedDocument.paths).toHaveLength(16); const documents = organizer.organizeByTags(parsedDocument); - const optimizedDocs = documents.map(doc => compiler.optimize(doc)); + const optimizedDocs = documents.map((doc: any) => compiler.optimize(doc)); expect(optimizedDocs).toHaveLength(3); }); + +test('discriminator parsing', async () => { + const parsedDocument = await parseSpec('./tests/data/petstore.yaml', noopLogger); + + // Find the path with discriminator + const discriminatorPath = parsedDocument.paths.find((path: any) => + path.name === '/multiple-bodies-with-discriminator' + ); + + expect(discriminatorPath).toBeDefined(); + + if (discriminatorPath && discriminatorPath.definition && 'operations' in discriminatorPath.definition) { + const postOperation = discriminatorPath.definition.operations.find((op: any) => op.method === 'post'); + expect(postOperation).toBeDefined(); + + if (postOperation && postOperation.request && 'definition' in postOperation.request) { + const requestBody = postOperation.request.definition; + expect(requestBody).toBeDefined(); + + // Check if the request body has content with discriminator + if ('content' in requestBody && requestBody.content) { + const jsonContent = requestBody.content.find((c: any) => c.name === 'application/json'); + expect(jsonContent).toBeDefined(); + + if (jsonContent && 'definition' in jsonContent.definition) { + const schema = jsonContent.definition.definition; + expect(schema).toBeDefined(); + + // Check if it's a xor (oneOf) with discriminator + if (schema && schema.type === 'xor') { + expect(schema.discriminatorPropertyName).toBeDefined(); + expect(schema.discriminatorPropertyName).toBe('type'); + expect(schema.discriminatorMapping).toBeDefined(); + expect(Object.keys(schema.discriminatorMapping)).toHaveLength(2); + + // Check the mapping keys + const mappingKeys = Object.keys(schema.discriminatorMapping); + expect(mappingKeys).toContain('base'); + expect(mappingKeys).toContain('inherited'); + + // Check that the mapping values are references + for (const value of Object.values(schema.discriminatorMapping)) { + expect((value as any).type).toBe('reference'); + } + } + } + } + } + } +});