From 86518307c08b4174b58c88a4f56687225a50c050 Mon Sep 17 00:00:00 2001 From: "n.totskii" Date: Wed, 27 Aug 2025 16:40:21 +0400 Subject: [PATCH 1/3] Added deep discriminator support --- src/framework/ajv/options.ts | 3 +- src/framework/types.ts | 3 +- test/ajv.options.spec.ts | 30 ++++ test/discriminator.spec.ts | 222 ++++++++++++++++++++++++++++++ test/resources/discriminator.yaml | 222 ++++++++++++++++++++++++++++++ 5 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 test/discriminator.spec.ts create mode 100644 test/resources/discriminator.yaml diff --git a/src/framework/ajv/options.ts b/src/framework/ajv/options.ts index bde6859c..deae86a4 100644 --- a/src/framework/ajv/options.ts +++ b/src/framework/ajv/options.ts @@ -30,7 +30,7 @@ export class AjvOptions { } get request(): RequestValidatorOptions { - const { allErrors, allowUnknownQueryParameters, coerceTypes, removeAdditional } = < + const { allErrors, allowUnknownQueryParameters, coerceTypes, removeAdditional, discriminator } = < ValidateRequestOpts >this.options.validateRequests; return { @@ -39,6 +39,7 @@ export class AjvOptions { allowUnknownQueryParameters, coerceTypes, removeAdditional, + discriminator }; } diff --git a/src/framework/types.ts b/src/framework/types.ts index b75adc31..c5c2b681 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 @@ -61,6 +61,7 @@ export type ValidateRequestOpts = { allowUnknownQueryParameters?: boolean; coerceTypes?: boolean | 'array'; removeAdditional?: boolean | 'all' | 'failing'; + discriminator?: boolean; }; export type ValidateResponseOpts = { diff --git a/test/ajv.options.spec.ts b/test/ajv.options.spec.ts index 70d9de2d..e3cc5af6 100644 --- a/test/ajv.options.spec.ts +++ b/test/ajv.options.spec.ts @@ -121,4 +121,34 @@ describe('AjvOptions', () => { expect(options.serDesMap['custom-1']).has.property('serialize'); expect(options.serDesMap['custom-1']).has.property('deserialize'); }); + + it('should handle discriminator parameter when not specified (undefined by default)', () => { + const ajv = new AjvOptions(baseOptions); + const options = ajv.request; + expect(options.discriminator).to.be.undefined; + }); + + it('should set discriminator to true when specified', () => { + const ajv = new AjvOptions({ + ...baseOptions, + validateRequests: { + ...baseOptions.validateRequests, + discriminator: true, + }, + }); + const options = ajv.request; + expect(options.discriminator).to.be.true; + }); + + it('should set discriminator to false when specified', () => { + const ajv = new AjvOptions({ + ...baseOptions, + validateRequests: { + ...baseOptions.validateRequests, + discriminator: false, + }, + }); + const options = ajv.request; + expect(options.discriminator).to.be.false; + }); }); diff --git a/test/discriminator.spec.ts b/test/discriminator.spec.ts new file mode 100644 index 00000000..2a9aac8b --- /dev/null +++ b/test/discriminator.spec.ts @@ -0,0 +1,222 @@ +import * as path from 'path'; +import { expect } from 'chai'; +import * as request from 'supertest'; +import { createApp } from './common/app'; +import { AppWithServer } from './common/app.common'; + +type Op = + | { + type: 'create_screen'; + data: { + key: string; + titleTranslationKey: string; + type: 'normal' | 'intermediate'; + step?: string; + descriptionTranslationKey?: string; + description?: string; + }; + } + | { + type: 'update_screen'; + data: { + id: string; + props: { + key?: string; + titleTranslationKey?: string; + type?: 'normal' | 'intermediate'; + step?: string; + descriptionTranslationKey?: string; + description?: string; + }; + }; + } + | { + type: 'create_question'; + data: { + key: string; + titleTranslationKey: string; + uiElementType: string; + valueType: string; + }; + } + | { + type: 'update_question'; + data: { + id: string; + props: { + key?: string; + titleTranslationKey?: string; + uiElementType?: string; + valueType?: string; + }; + }; + }; + +const postOps = (app: any, op: Op) => + request(app) + .post(`${app.basePath}/operations`) + .set('content-type', 'application/json') + .send({ operations: [op] }) + .expect(204); + +describe.only('Operation discriminator', () => { + let app: AppWithServer; + + before(async () => { + const apiSpec = path.join('test', 'resources', 'discriminator.yaml'); + app = await createApp( + { apiSpec, validateRequests: { discriminator: true, allErrors: true } }, + 3001, + (app) => { + app.post(`${app.basePath}/operations`, (req, res) => { + res.status(204).send(); + }); + }, + ); + }); + + after(() => { + app.server.close(); + }); + + describe('/operations', () => { + const cases: Array<[string, Op]> = [ + [ + 'create_screen', + { + type: 'create_screen', + data: { + key: 'test_screen', + titleTranslationKey: 'screen.test.title', + type: 'normal', + step: 'step1', + }, + }, + ], + [ + 'update_screen', + { + type: 'update_screen', + data: { + id: '550e8400-e29b-41d4-a716-446655440000', + props: { + key: 'updated_screen', + titleTranslationKey: 'screen.updated.title', + type: 'intermediate', + step: 'step2', + }, + }, + }, + ], + [ + 'create_question', + { + type: 'create_question', + data: { + key: 'test_question', + titleTranslationKey: 'question.test.title', + uiElementType: 'input', + valueType: 'string', + }, + }, + ], + [ + 'update_question', + { + type: 'update_question', + data: { + id: '550e8400-e29b-41d4-a716-446655440000', + props: { + key: 'updated_question', + titleTranslationKey: 'question.updated.title', + uiElementType: 'checkbox', + valueType: 'boolean', + }, + }, + }, + ], + ]; + + for (const [name, op] of cases) { + it(`should return 204 for valid ${name} operation`, async function () { + const res = await postOps(app, op); + expect(res.status).to.equal(204); + }); + } + + it('should return 400 for invalid discriminator type', async () => + request(app) + .post(`${app.basePath}/operations`) + .set('content-type', 'application/json') + .send({ + operations: [ + { + type: 'invalid_operation', + data: { + key: 'test', + titleTranslationKey: 'test', + type: 'normal', + step: 'step1', + }, + }, + ], + }) + .expect(400) + .then((r) => { + expect(r.body.errors).to.have.lengthOf(1); + + const [error] = r.body.errors; + + expect(error.path).to.include('/body/operations/0'); + expect(error.message).to.match( + /value of tag "type" must be in oneOf/, + ); + expect(error.errorCode).to.equal('discriminator.openapi.validation'); + })); + + it.only('should return 400 for create_screen operation with missing required fields', async () => + request(app) + .post(`${app.basePath}/operations`) + .set('content-type', 'application/json') + .send({ + operations: [ + { + type: 'create_screen', + data: { + key: 'test_screen', + // missing titleTranslationKey, type, step + }, + }, + ], + }) + .expect(400) + .then((r) => { + const expected = [ + { + path: '/body/operations/0/data/titleTranslationKey', + message: "must have required property 'titleTranslationKey'", + errorCode: 'required.openapi.validation', + }, + { + path: '/body/operations/0/data/type', + message: "must have required property 'type'", + errorCode: 'required.openapi.validation', + }, + { + path: '/body/operations/0/data/step', + message: "must have required property 'step'", + errorCode: 'required.openapi.validation', + }, + ]; + + const errors = r.body.errors.map(({ path, message, errorCode }) => ({ + path, + message, + errorCode, + })); + + expect(errors).to.have.lengthOf(expected.length); + expect(errors).to.have.deep.members(expected); + })); + }); +}); diff --git a/test/resources/discriminator.yaml b/test/resources/discriminator.yaml new file mode 100644 index 00000000..d3b60efc --- /dev/null +++ b/test/resources/discriminator.yaml @@ -0,0 +1,222 @@ +openapi: 3.0.0 +info: + title: Operation Discriminator Test API + description: 'API for testing discriminator functionality with Operation-like structure' + version: 1.0.0 + contact: {} +servers: + - url: /v1 +security: [] +tags: [] +paths: + /operations: + post: + tags: + - Operations + summary: Submit batch operations + description: This endpoint processes multiple operations in one atomic request + operationId: OperationsController_submitBatchOperations + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + operations: + type: array + minItems: 1 + description: Ordered list of operations to execute + items: + $ref: '#/components/schemas/Operation' + required: + - operations + responses: + '204': + description: No Content - operations successfully processed + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + message: + type: string + errors: + type: array + items: + type: object + '422': + description: Unprocessable Entity + '500': + description: Internal Server Error + +components: + schemas: + Operation: + oneOf: + - $ref: '#/components/schemas/CreateScreenOperation' + - $ref: '#/components/schemas/UpdateScreenOperation' + - $ref: '#/components/schemas/CreateQuestionOperation' + - $ref: '#/components/schemas/UpdateQuestionOperation' + discriminator: + propertyName: type + + CreateScreenOperation: + type: object + additionalProperties: false + required: + - type + - data + description: Operation that creates a new Screen entity + properties: + type: + type: string + enum: + - create_screen + description: Discriminator for this operation + data: + $ref: '#/components/schemas/ScreenIn' + + UpdateScreenOperation: + type: object + additionalProperties: false + required: + - type + - data + description: Operation that updates an existing Screen entity + properties: + type: + type: string + enum: + - update_screen + description: Discriminator for this operation + data: + type: object + additionalProperties: false + properties: + id: + $ref: '#/components/schemas/Identity' + props: + $ref: '#/components/schemas/ScreenIn' + + CreateQuestionOperation: + type: object + additionalProperties: false + required: + - type + - data + description: Operation that creates a new Question entity + properties: + type: + type: string + enum: + - create_question + data: + $ref: '#/components/schemas/QuestionIn' + + UpdateQuestionOperation: + type: object + additionalProperties: false + required: + - type + - data + description: Operation that updates an existing Question entity + properties: + type: + type: string + enum: + - update_question + data: + type: object + additionalProperties: false + properties: + id: + $ref: '#/components/schemas/Identity' + props: + $ref: '#/components/schemas/QuestionIn' + + Identity: + type: string + format: uuid + description: Unique identifier (UUID) + + Key: + type: string + description: Business-level identifier + minLength: 2 + maxLength: 100 + + TranslationKey: + type: string + description: i18n key for text + maxLength: 77 + minLength: 1 + + ScreenType: + type: string + description: Type of the screen + enum: + - normal + - intermediate + + Step: + type: string + description: Screen step in the questionnaire + maxLength: 200 + minLength: 1 + nullable: true + + ScreenIn: + type: object + additionalProperties: false + required: + - key + - titleTranslationKey + - type + - step + properties: + key: + $ref: '#/components/schemas/Key' + titleTranslationKey: + $ref: '#/components/schemas/TranslationKey' + type: + $ref: '#/components/schemas/ScreenType' + step: + $ref: '#/components/schemas/Step' + + UiElementType: + type: string + description: Presentation type + enum: + - input + - checkbox + - select + - radio + + QuestionValueType: + type: string + enum: + - string + - number + - boolean + description: Type of value that can be stored for this question + + QuestionIn: + type: object + properties: + key: + $ref: '#/components/schemas/Key' + titleTranslationKey: + $ref: '#/components/schemas/TranslationKey' + uiElementType: + $ref: '#/components/schemas/UiElementType' + valueType: + $ref: '#/components/schemas/QuestionValueType' + required: + - key + - titleTranslationKey + - uiElementType + - valueType From 00bfbbc8c454118112dfb321bae8d42cd6cb2dfb Mon Sep 17 00:00:00 2001 From: "n.totskii" Date: Wed, 27 Aug 2025 16:44:01 +0400 Subject: [PATCH 2/3] fixed typo --- test/discriminator.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/discriminator.spec.ts b/test/discriminator.spec.ts index 2a9aac8b..6d9ebeb3 100644 --- a/test/discriminator.spec.ts +++ b/test/discriminator.spec.ts @@ -59,7 +59,7 @@ const postOps = (app: any, op: Op) => .send({ operations: [op] }) .expect(204); -describe.only('Operation discriminator', () => { +describe('Operation discriminator', () => { let app: AppWithServer; before(async () => { @@ -174,7 +174,7 @@ describe.only('Operation discriminator', () => { expect(error.errorCode).to.equal('discriminator.openapi.validation'); })); - it.only('should return 400 for create_screen operation with missing required fields', async () => + it('should return 400 for create_screen operation with missing required fields', async () => request(app) .post(`${app.basePath}/operations`) .set('content-type', 'application/json') From 5ed0a498ec713e40dfd1ad0d087e192700c55ef1 Mon Sep 17 00:00:00 2001 From: "n.totskii" Date: Sat, 6 Sep 2025 15:40:32 +0400 Subject: [PATCH 3/3] fixed suggestions --- src/framework/ajv/options.ts | 2 +- test/discriminator.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/framework/ajv/options.ts b/src/framework/ajv/options.ts index deae86a4..900247c4 100644 --- a/src/framework/ajv/options.ts +++ b/src/framework/ajv/options.ts @@ -39,7 +39,7 @@ export class AjvOptions { allowUnknownQueryParameters, coerceTypes, removeAdditional, - discriminator + discriminator, }; } diff --git a/test/discriminator.spec.ts b/test/discriminator.spec.ts index 6d9ebeb3..ebfd9704 100644 --- a/test/discriminator.spec.ts +++ b/test/discriminator.spec.ts @@ -24,7 +24,7 @@ type Op = key?: string; titleTranslationKey?: string; type?: 'normal' | 'intermediate'; - step?: string; + step: string | null; descriptionTranslationKey?: string; description?: string; }; @@ -59,7 +59,7 @@ const postOps = (app: any, op: Op) => .send({ operations: [op] }) .expect(204); -describe('Operation discriminator', () => { +describe.only('Operation discriminator', () => { let app: AppWithServer; before(async () => {