Skip to content

Commit

Permalink
top level discriminator support (oneOf, anyOf) for request bodies (#461)
Browse files Browse the repository at this point in the history
* chore: update change log

* feat: add top level discriminator support #458

* validate one of

* continue if no discriminator option available found
  • Loading branch information
cdimascio authored Nov 15, 2020
1 parent af26e9c commit 17f4c47
Show file tree
Hide file tree
Showing 7 changed files with 431 additions and 7 deletions.
8 changes: 8 additions & 0 deletions CHANGE_HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## 4.6.0 (2020-11-15)

* chore: increment minor version ([af26e9c](https://github.com/cdimascio/express-openapi-validator/commit/af26e9c))
* Allow ignorePaths to take function or regexp (#459) ([9b855d1](https://github.com/cdimascio/express-openapi-validator/commit/9b855d1)), closes [#459](https://github.com/cdimascio/express-openapi-validator/issues/459)
* test: return 204 instead of 200 ([6a88a9c](https://github.com/cdimascio/express-openapi-validator/commit/6a88a9c))



## 4.5.0 (2020-11-09)

* chore: increment minor version ([80de114](https://github.com/cdimascio/express-openapi-validator/commit/80de114))
Expand Down
31 changes: 29 additions & 2 deletions src/middlewares/openapi.request.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,16 +147,26 @@ export class RequestValidator {
cookies,
body: req.body,
};
const schemaBody = <any>validator?.schemaBody;
const discriminator = schemaBody?.properties?.body?._discriminator;
const discriminatorValdiator = this.discriminatorValidator(
req,
discriminator,
);

const validatorBody = discriminatorValdiator ?? validator.validatorBody;
const valid = validator.validatorGeneral(data);
const validBody = validator.validatorBody(data);
const validBody = validatorBody(
discriminatorValdiator ? data.body : data,
);

if (valid && validBody) {
next();
} else {
const errors = augmentAjvErrors(
[]
.concat(validator.validatorGeneral.errors ?? [])
.concat(validator.validatorBody.errors ?? []),
.concat(validatorBody.errors ?? []),
);
const err = ajvErrorsToValidatorError(400, errors);
const message = this.ajv.errorsText(errors, { dataVar: 'request' });
Expand All @@ -170,6 +180,23 @@ export class RequestValidator {
};
}

private discriminatorValidator(req, discriminator) {
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)) {
return 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(', ')}.`,
});
}
}
return null;
}
private processQueryParam(query: object, schema, whiteList: string[] = []) {
const entries = Object.entries(schema.properties ?? {});
let keys = [];
Expand Down
87 changes: 87 additions & 0 deletions src/middlewares/parsers/request.schema.preprocessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export class RequestSchemaPreprocessor {
const contentEntries = Object.entries(requestBody.content);
for (const [_, mediaTypeObject] of contentEntries) {
this.cleanseContentSchema(mediaTypeObject);
this.handleDiscriminator(mediaTypeObject);
}
}

Expand Down Expand Up @@ -104,6 +105,77 @@ 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) {
this.discriminatorTraverse(null, schemaObj, {});
}
}

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 option =
this.findKey(
ancestor.discriminator?.mapping,
(value) => value === schema['$ref'],
) || this.getKeyFromRef(schema['$ref']);

if (option) {
const newSchema = JSON.parse(JSON.stringify(schemaObj));
newSchema.properties = {
...(o.properties ?? {}),
...(newSchema.properties ?? {}),
};
newSchema.required = o.required;
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,
);
}
//reset data
o.properties = {};
delete o.required;
}
}

private traverse(schema: Schema, f: (p, s) => void) {
const schemaObj = schema.hasOwnProperty('$ref')
? <SchemaObject>this.ajv.getSchema(schema['$ref'])?.schema
Expand All @@ -121,4 +193,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];
}
}
163 changes: 163 additions & 0 deletions test/one.of.2.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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('one.of.2.spec', () => {
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}/discriminator_implied`, (req, res) =>
res.json(req.body),
);
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('/discriminator_implied', () => {
it('should return 200 for dog', async () =>
request(app)
.post(`${app.basePath}/discriminator_implied`)
.set('content-type', 'application/json')
.send({
pet_type: 'DogObject',
bark: true,
breed: 'Dingo',
})
.expect(200));

it('should return 400 for dog with cat props', async () =>
request(app)
.post(`${app.basePath}/discriminator_implied`)
.set('content-type', 'application/json')
.send({
pet_type: 'DogObject',
hunts: true,
age: 3,
})
.expect(400)
.then((r) => {
expect(r.body.message).to.include("required property 'bark'");
}));

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

describe('/pets', () => {
it('should return 400 a bad discriminator', async () =>
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: cat, dog');
}));

it('should return 200 for dog', async () =>
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 () =>
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 () =>
request(app)
.post(`${app.basePath}/pets_all`)
.set('content-type', 'application/json')
.send({
pet_type: 'dog',
bark: true,
breed: 'Dingo',
})
.expect(400)
.then((r) => {
const e = r.body;
expect(e.message).to.include(
'to one of the allowed values: Cat, Dog',
);
}));

it('should return 200 for Dog', async () =>
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 () =>
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
Loading

0 comments on commit 17f4c47

Please sign in to comment.