Skip to content

Commit

Permalink
feat: add top level discriminator support #458
Browse files Browse the repository at this point in the history
  • Loading branch information
cdimascio committed Nov 15, 2020
1 parent a3e1cc0 commit 4e29f62
Show file tree
Hide file tree
Showing 6 changed files with 358 additions and 6 deletions.
20 changes: 19 additions & 1 deletion src/middlewares/openapi.request.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,24 @@ export class RequestValidator {
);
}

const discriminator = (<any>validator?.schemaBody)?.properties?.body
?._discriminator;
let discriminatorValidator = null;
if (discriminator) {
const { options, property, validators } = discriminator;
const discriminatorValue = req.body[property]; // TODO may not alwasy be in this position
if (options.find((o) => o.option === discriminatorValue)) {
discriminatorValidator = validators[discriminatorValue];
} else {
throw new BadRequest({
path: req.path,
message: `'${property}' should be equal to one of the allowed values: ${options
.map((o) => o.option)
.join(', ')}.`,
});
}
}

const cookies = req.cookies
? {
...req.cookies,
Expand All @@ -148,7 +166,7 @@ export class RequestValidator {
body: req.body,
};
const valid = validator.validatorGeneral(data);
const validBody = validator.validatorBody(data);
const validBody = discriminatorValidator ?? validator.validatorBody(data);

if (valid && validBody) {
next();
Expand Down
85 changes: 85 additions & 0 deletions src/middlewares/parsers/request.schema.preprocessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export class RequestSchemaPreprocessor {

const contentEntries = Object.entries(requestBody.content);
for (const [_, mediaTypeObject] of contentEntries) {
this.handleDiscriminator(mediaTypeObject);
this.cleanseContentSchema(mediaTypeObject);
}
}
Expand Down Expand Up @@ -104,6 +105,75 @@ export class RequestSchemaPreprocessor {
return content.schema;
}

private handleDiscriminator(content: OpenAPIV3.MediaTypeObject) {
const schemaObj = content.schema.hasOwnProperty('$ref')
? <SchemaObject>this.ajv.getSchema(content.schema['$ref'])?.schema
: <SchemaObject>content.schema;

if (schemaObj.discriminator) {
// has discriminator
console.log('has disciminator');
// check oneOf require discriminator
this.discriminatorTraverse(null, schemaObj, {});
console.log('complet discriminator traversal');
} else {
return;
}
}

private discriminatorTraverse(parent: Schema, schema: Schema, o: any = {}) {
const schemaObj = schema.hasOwnProperty('$ref')
? <SchemaObject>this.ajv.getSchema(schema['$ref'])?.schema
: <SchemaObject>schema;

const xOf = schemaObj.oneOf ? 'oneOf' : 'anyOf';
if (schemaObj?.discriminator?.propertyName && !o.discriminator) {
// TODO discriminator can be used for anyOf too!
const options = schemaObj[xOf].map((refObject) => {
const option = this.findKey(
schemaObj.discriminator.mapping,
(value) => value === refObject['$ref'],
);
const ref = this.getKeyFromRef(refObject['$ref']);
return { option: option || ref, ref };
});
o.options = options;
o.discriminator = schemaObj.discriminator?.propertyName;
}
o.properties = { ...(o.properties ?? {}), ...(schemaObj.properties ?? {}) };
o.required = Array.from(
new Set((o.required ?? []).concat(schemaObj.required ?? [])),
);

if (schemaObj[xOf]) {
schemaObj[xOf].forEach((s) =>
this.discriminatorTraverse(schemaObj, s, o),
);
} else if (schemaObj) {
const ancestor: any = parent;
const newSchema = JSON.parse(JSON.stringify(schemaObj));
newSchema.properties = {
...(o.properties ?? {}),
...(newSchema.properties ?? {}),
};
newSchema.required = o.required;
const option =
this.findKey(
ancestor.discriminator?.mapping,
(value) => value === schema['$ref'], // TODO what about non-refs, it explodes now
) || this.getKeyFromRef(schema['$ref']); // TODO what about non-refs, it explodes now
if (newSchema.required.length === 0) {
delete newSchema.required;
}
ancestor._discriminator ??= {
validators: {},
options: o.options,
property: o.discriminator,
};
ancestor._discriminator.validators[option] = this.ajv.compile(newSchema);
}
}

private traverse(schema: Schema, f: (p, s) => void) {
const schemaObj = schema.hasOwnProperty('$ref')
? <SchemaObject>this.ajv.getSchema(schema['$ref'])?.schema
Expand All @@ -121,4 +191,19 @@ export class RequestSchemaPreprocessor {
});
}
}

private findKey(object, searchFunc) {
if (!object) {
return;
}
const keys = Object.keys(object);
for (let i = 0; i < keys.length; i++) {
if (searchFunc(object[keys[i]])) {
return keys[i];
}
}
}
getKeyFromRef(ref) {
return ref.split('/components/schemas/')[1];
}
}
121 changes: 121 additions & 0 deletions test/one.of.2.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import * as path from 'path';
import { expect } from 'chai';
import * as request from 'supertest';
import { createApp } from './common/app';
import * as packageJson from '../package.json';

describe(packageJson.name, () => {
let app = null;

before(async () => {
const apiSpec = path.join('test', 'resources', 'one.of.2.yaml');
app = await createApp(
{ apiSpec },
3005,
(app) => {
app.post(`${app.basePath}/pets`, (req, res) => {
res.json(req.body);
});
app.post(`${app.basePath}/pets_all`, (req, res) => {
res.json(req.body);
});
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status ?? 500).json({
message: err.message,
code: err.status ?? 500,
});
});
},
false,
);
});

after(() => {
app.server.close();
});

describe('/pets', () => {
it('should return 400 a bad discriminator', async () => {
return request(app)
.post(`${app.basePath}/pets`)
.set('content-type', 'application/json')
.send({
pet_type: 'DogObject',
bark: true,
breed: 'Dingo',
})
.expect(400)
.then((r) => {
const e = r.body;
expect(e.message).to.include('one of the allowed values: dog, cat');
});
});

it('should return 200 for dog', async () => {
return request(app)
.post(`${app.basePath}/pets`)
.set('content-type', 'application/json')
.send({
pet_type: 'dog',
bark: true,
breed: 'Dingo',
})
.expect(200);
});

it('should return 200 for cat', async () => {
return request(app)
.post(`${app.basePath}/pets`)
.set('content-type', 'application/json')
.send({
pet_type: 'cat',
hunts: true,
age: 3,
})
.expect(200);
});
});

describe('/pets_all', () => {
it('should return 400 a bad discriminator', async () => {
return request(app)
.post(`${app.basePath}/pets_all`)
.set('content-type', 'application/json')
.send({
pet_type: 'DogObject',
bark: true,
breed: 'Dingo',
})
.expect(400)
.then((r) => {
const e = r.body;
expect(e.message).to.include('to one of the allowed values: dog, cat');
});
});

it('should return 200 for dog', async () => {
return request(app)
.post(`${app.basePath}/pets_all`)
.set('content-type', 'application/json')
.send({
pet_type: 'dog',
bark: true,
breed: 'Dingo',
})
.expect(200);
});

it('should return 200 for cat', async () => {
return request(app)
.post(`${app.basePath}/pets_all`)
.set('content-type', 'application/json')
.send({
pet_type: 'cat',
hunts: true,
age: 3,
})
.expect(200);
});
});
});
17 changes: 14 additions & 3 deletions test/oneof.readonly.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,20 +46,31 @@ describe('one.of readonly', () => {
.expect(400)
.then((r) => {
const error = r.body;
console.log(error);
expect(error.message).to.include('to one of the allowed values: C, D');
}));

it('post type oneOf (without readonly id) should pass', async () =>
request(app)
.post(`${app.basePath}/one_of`)
.send({ type: 'C' })
.set('Content-Type', 'application/json')
.expect(200));

it('post type anyof without providing the single required readonly property should pass', async () =>
request(app)
.post(`${app.basePath}/one_of`)
.send({ type: 'C' }) // do not provide id
.set('Content-Type', 'application/json')
.expect(200));

it('post type oneOf (without readonly id) should pass', async () =>
it('should fail if posting anyof with bad discriminator', async () =>
request(app)
.post(`${app.basePath}/one_of`)
.send({ type: 'C' })
.send({ type: 'A' }) // do not provide id
.set('Content-Type', 'application/json')
.expect(200));
.expect(400)
.then((r) => {
expect(r.body.message).includes('to one of the allowed values: C, D');
}));
});
4 changes: 2 additions & 2 deletions test/oneof.readonly.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ paths:
discriminator:
propertyName: type
mapping:
A: '#/components/schemas/subC'
B: '#/components/schemas/subD'
C: '#/components/schemas/subC'
D: '#/components/schemas/subD'
responses:
200:
description: successful operation
Expand Down

0 comments on commit 4e29f62

Please sign in to comment.