Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/framework/ajv/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -39,6 +39,7 @@ export class AjvOptions {
allowUnknownQueryParameters,
coerceTypes,
removeAdditional,
discriminator,
};
}

Expand Down
3 changes: 2 additions & 1 deletion src/framework/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,6 +61,7 @@ export type ValidateRequestOpts = {
allowUnknownQueryParameters?: boolean;
coerceTypes?: boolean | 'array';
removeAdditional?: boolean | 'all' | 'failing';
discriminator?: boolean;
};

export type ValidateResponseOpts = {
Expand Down
30 changes: 30 additions & 0 deletions test/ajv.options.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});
222 changes: 222 additions & 0 deletions test/discriminator.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The step property is marked as optional in the TypeScript type definition but is required in the OpenAPI schema (line 179 in discriminator.yaml). This inconsistency could lead to confusion and validation errors.

Suggested change
step?: string;
step: string;

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed, check one more time

descriptionTranslationKey?: string;
description?: string;
};
}
| {
type: 'update_screen';
data: {
id: string;
props: {
key?: string;
titleTranslationKey?: string;
type?: 'normal' | 'intermediate';
step: string | null;
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('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);
}));
});
});
Loading