diff --git a/README.md b/README.md index 27804ef0..ce12d53d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ - โœ”๏ธ request validation - โœ”๏ธ response validation (json only) - ๐Ÿ‘ฎ security validation / custom security functions -- ๐Ÿ‘ฝ 3rd party / custom formats +- ๐Ÿ‘ฝ 3rd party / custom formats / custom data serialization-deserialization - ๐Ÿงต optionally auto-map OpenAPI endpoints to Express handler functions - โœ‚๏ธ **\$ref** support; split specs over multiple files - ๐ŸŽˆ file upload @@ -490,12 +490,21 @@ OpenApiValidator.middleware({ validate: (value: any) => boolean, }], unknownFormats: ['phone-number', 'uuid'], + serDes: [{ + OpenApiValidator.baseSerDes.dateTime, + OpenApiValidator.baseSerDes.date, + { + format: 'mongo-objectid', + deserialize: (s) => new ObjectID(s), + serialize: (o) => o.toString(), + } + }], operationHandlers: false | 'operations/base/path' | { ... }, ignorePaths: /.*\/pets$/, fileUploader: { ... } | true | false, $refParser: { mode: 'bundle' - } + }, }); ``` @@ -703,6 +712,41 @@ Defines how the validator should behave if an unknown or custom format is encoun - `"ignore"` - to log warning during schema compilation and always pass validation. This option is not recommended, as it allows to mistype format name and it won't be validated without any error message. +### โ–ช๏ธ serDes (optional) + +Add custom mecanism in order to +- serialize object before sending the response +- AND/OR deserialize string to custom object (Date...) on request +Route function can focus on feature and doesn't have to cast data at request or before sending response. + +To `deserialize` on request and `serialize` on response, both functions must be defined and are launched when API `format` field is similar. +```javascript +serDes: [{ + OpenApiValidator.baseSerDes.dateTime, // used when 'format: date-time' + OpenApiValidator.baseSerDes.date, // used when 'format: date' + { + format: 'mongo-objectid', + deserialize: (s) => new ObjectID(s), + serialize: (o) => o.toString(), + } +}], +``` + +In order to ONLY `serialize` on response (and avoid to deserialize on request), the configuration must not define `deserialize` function. +```javascript +serDes: [{ + OpenApiValidator.baseSerDes.dateTimeSerializeOnly, // used when 'format: date-time' on response only + OpenApiValidator.baseSerDes.dateSerializeOnly, // used when 'format: date' on response only + { + format: 'mongo-objectid', + serialize: (o) => o.toString(), + } +}], +``` + +`format` field can be set with custom values (such as `mongo-objectid`). +You must add those custom formats in [unknownFormats](#unknownFormats-(optional)) setting. + ### โ–ช๏ธ operationHandlers (optional) Defines the base directory for operation handlers. This is used in conjunction with express-openapi-validator's OpenAPI vendor extensions, `x-eov-operation-id`, `x-eov-operation-handler` and OpenAPI's `operationId`. See [example](https://github.com/cdimascio/express-openapi-validator/tree/master/examples/3-eov-operations). diff --git a/src/framework/ajv/index.ts b/src/framework/ajv/index.ts index 9db8583b..1f33a6db 100644 --- a/src/framework/ajv/index.ts +++ b/src/framework/ajv/index.ts @@ -36,6 +36,23 @@ function createAjv( ajv.removeKeyword('const'); if (request) { + if (options.serDesMap) { + ajv.addKeyword('x-eov-serdes', { + modifying: true, + compile: (sch) => { + if (sch) { + return function validate(data, path, obj, propName) { + if (typeof data === 'object') return true; + if(!!sch.deserialize) { + obj[propName] = sch.deserialize(data); + } + return true; + }; + } + return () => true; + }, + }); + } ajv.removeKeyword('readOnly'); ajv.addKeyword('readOnly', { modifying: true, @@ -62,20 +79,23 @@ function createAjv( }); } else { // response - ajv.addKeyword('x-eov-serializer', { - modifying: true, - compile: (sch) => { - if (sch) { - const isDate = ['date', 'date-time'].includes(sch.format); - return function validate(data, path, obj, propName) { - if (typeof data === 'string' && isDate) return true - obj[propName] = sch.serialize(data); - return true; - }; - } - return () => true; - }, - }); + if (options.serDesMap) { + ajv.addKeyword('x-eov-serdes', { + modifying: true, + compile: (sch) => { + if (sch) { + return function validate(data, path, obj, propName) { + if (typeof data === 'string') return true; + if(!!sch.serialize) { + obj[propName] = sch.serialize(data); + } + return true; + }; + } + return () => true; + }, + }); + } ajv.removeKeyword('writeOnly'); ajv.addKeyword('writeOnly', { modifying: true, diff --git a/src/framework/base.serdes.ts b/src/framework/base.serdes.ts new file mode 100644 index 00000000..24bbf8bf --- /dev/null +++ b/src/framework/base.serdes.ts @@ -0,0 +1,35 @@ +import { SerDes } from './types'; + +export const dateTime : SerDes = { + format : 'date-time', + serialize: (d: Date) => { + return d && d.toISOString(); + }, + deserialize: (s: string) => { + return new Date(s); + } +}; + +export const dateTimeSerializeOnly : SerDes = { + format : 'date-time', + serialize: (d: Date) => { + return d && d.toISOString(); + }, +}; + +export const date : SerDes = { + format : 'date', + serialize: (d: Date) => { + return d && d.toISOString().split('T')[0]; + }, + deserialize: (s: string) => { + return new Date(s); + } +}; + +export const dateSerializeOnly : SerDes = { + format : 'date', + serialize: (d: Date) => { + return d && d.toISOString().split('T')[0]; + }, +}; diff --git a/src/framework/types.ts b/src/framework/types.ts index 994b70ca..47e69f9c 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -38,7 +38,7 @@ export interface MultipartOpts { export interface Options extends ajv.Options { // Specific options - schemaObjectMapper?: object; + serDesMap?: SerDesMap; } export interface RequestValidatorOptions extends Options, ValidateRequestOpts {} @@ -69,9 +69,14 @@ export type Format = { validate: (v: any) => boolean; }; -export type Serializer = { +export type SerDes = { format: string, - serialize: (o: unknown) => string; + serialize?: (o: unknown) => string; + deserialize?: (s: string) => unknown; +}; + +export type SerDesMap = { + [format: string]: SerDes }; export interface OpenApiValidatorOpts { @@ -83,6 +88,7 @@ export interface OpenApiValidatorOpts { securityHandlers?: SecurityHandlers; coerceTypes?: boolean | 'array'; unknownFormats?: true | string[] | 'ignore'; + serDes?: SerDes[]; formats?: Format[]; fileUploader?: boolean | multer.Options; multerOpts?: multer.Options; diff --git a/src/index.ts b/src/index.ts index fa391aa1..0fbf28b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,8 @@ export const error = { Forbidden, }; +export * as baseSerDes from './framework/base.serdes'; + function openapiValidator(options: OpenApiValidatorOpts) { const oav = new OpenApiValidator(options); exports.middleware._oav = oav; diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index 0ce96c5b..edac2eca 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -5,8 +5,8 @@ import * as _get from 'lodash.get'; import { createRequestAjv } from '../../framework/ajv'; import { OpenAPIV3, - Serializer, - ValidateResponseOpts, + SerDesMap, + Options, } from '../../framework/types'; interface TraversalStates { @@ -48,20 +48,6 @@ class Root extends Node { } } -const dateTime: Serializer = { - format: 'date-time', - serialize: (d: Date) => { - return d && d.toISOString(); - }, -}; - -const date: Serializer = { - format: 'date', - serialize: (d: Date) => { - return d && d.toISOString().split('T')[0]; - }, -}; - type SchemaObject = OpenAPIV3.SchemaObject; type ReferenceObject = OpenAPIV3.ReferenceObject; type Schema = ReferenceObject | SchemaObject; @@ -87,23 +73,22 @@ export class SchemaPreprocessor { private ajv: Ajv; private apiDoc: OpenAPIV3.Document; private apiDocRes: OpenAPIV3.Document; - private responseOpts: ValidateResponseOpts; + private serDesMap: SerDesMap; constructor( apiDoc: OpenAPIV3.Document, - ajvOptions: ajv.Options, - validateResponsesOpts: ValidateResponseOpts, + ajvOptions: Options, ) { this.ajv = createRequestAjv(apiDoc, ajvOptions); this.apiDoc = apiDoc; - this.responseOpts = validateResponsesOpts; + this.serDesMap = ajvOptions.serDesMap } public preProcess() { const componentSchemas = this.gatherComponentSchemaNodes(); const r = this.gatherSchemaNodesFromPaths(); - // Now that we've processed paths, clonse the spec - this.apiDocRes = !!this.responseOpts ? cloneDeep(this.apiDoc) : null; + // Now that we've processed paths, clone the spec + this.apiDocRes = !!this.serDesMap ? cloneDeep(this.apiDoc) : null; const schemaNodes = { schemas: componentSchemas, @@ -244,7 +229,9 @@ export class SchemaPreprocessor { const options = opts[kind]; options.path = node.path; - this.handleSerDes(pschema, nschema, options); + if(this.serDesMap) { + this.handleSerDes(pschema, nschema, options); + } this.handleReadonly(pschema, nschema, options); this.processDiscriminator(pschema, nschema, options); } @@ -336,20 +323,12 @@ export class SchemaPreprocessor { schema: SchemaObject, state: TraversalState, ) { - if (state.kind === 'res') { - if (schema.type === 'string' && !!schema.format) { - switch (schema.format) { - case 'date-time': - (schema).type = ['object', 'string']; - schema['x-eov-serializer'] = dateTime; - break; - case 'date': - (schema).type = ['object', 'string']; - schema['x-eov-serializer'] = date; - break; - } + //if (state.kind === 'res') { + if (schema.type === 'string' && !!schema.format && this.serDesMap[schema.format]) { + (schema).type = ['object', 'string']; + schema['x-eov-serdes'] = this.serDesMap[schema.format]; } - } + //} } private handleReadonly( diff --git a/src/openapi.validator.ts b/src/openapi.validator.ts index 7e559a73..0810f3f8 100644 --- a/src/openapi.validator.ts +++ b/src/openapi.validator.ts @@ -96,8 +96,7 @@ export class OpenApiValidator { const pContext = spec.then((spec) => { const apiDoc = spec.apiDoc; const ajvOpts = this.ajvOpts.preprocessor; - const resOpts = this.options.validateResponses as ValidateRequestOpts; - const sp = new SchemaPreprocessor(apiDoc, ajvOpts, resOpts).preProcess(); + const sp = new SchemaPreprocessor(apiDoc, ajvOpts).preProcess(); return { context: new OpenApiContext(spec, this.options.ignorePaths), responseApiDoc: sp.apiDocRes, @@ -393,7 +392,7 @@ class AjvOptions { } private baseOptions(): Options { - const { coerceTypes, unknownFormats, validateFormats } = this.options; + const { coerceTypes, unknownFormats, validateFormats, serDes } = this.options; return { nullable: true, coerceTypes, @@ -408,6 +407,10 @@ class AjvOptions { }; return acc; }, {}), + serDesMap : serDes ? serDes.reduce((map, obj) => { + map[obj.format]=obj; + return map; + }, {}) : null, }; } } diff --git a/test/resources/serdes.yaml b/test/resources/serdes.yaml new file mode 100644 index 00000000..b485586b --- /dev/null +++ b/test/resources/serdes.yaml @@ -0,0 +1,57 @@ +openapi: "3.0.0" +info: + title: "Test for allOf" + version: "1" +servers: + - url: /v1/ +paths: + /users/{id}: + get: + parameters: + - name: id + in: path + required: true + schema: + $ref: "#/components/schemas/ObjectId" + responses: + 200: + description: "" + content: + application/json: + schema: + $ref: "#/components/schemas/User" + /users: + post: + requestBody: + content : + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + 200: + description: "" + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + ObjectId: + type: string + format: mongo-objectid + pattern: '^[0-9a-fA-F]{24}$' + Date: + type: string + format: date + DateTime: + type: string + format: date-time + User: + type: object + properties: + id: + $ref: "#/components/schemas/ObjectId" + creationDateTime: + $ref: "#/components/schemas/DateTime" + creationDate: + $ref: "#/components/schemas/Date" diff --git a/test/serdes.spec.ts b/test/serdes.spec.ts new file mode 100644 index 00000000..67fb0d62 --- /dev/null +++ b/test/serdes.spec.ts @@ -0,0 +1,287 @@ +import * as path from 'path'; +import { expect } from 'chai'; +import * as request from 'supertest'; +import { createApp } from './common/app'; + +import { date, dateTime, dateSerializeOnly,dateTimeSerializeOnly } from '../src/framework/base.serdes'; + +const apiSpecPath = path.join('test', 'resources', 'serdes.yaml'); + +class ObjectID { + id : string; + + constructor(id: string = "5fdefd13a6640bb5fb5fa925") { + this.id= id; + } + + toString() { + return this.id; + } + +} + +describe('serdes', () => { + let app = null; + + before(async () => { + // set up express app + app = await createApp( + { + apiSpec: apiSpecPath, + validateRequests: { + coerceTypes: true + }, + validateResponses: { + coerceTypes: true + }, + serDes: [ + date, + dateTime, + { + format: "mongo-objectid", + deserialize: (s) => new ObjectID(s), + serialize: (o) => o.toString(), + }, + ], + unknownFormats: ['mongo-objectid'], + }, + 3005, + (app) => { + app.get([`${app.basePath}/users/:id?`], (req, res) => { + if(typeof req.params.id !== 'object') { + throw new Error("Should be deserialized to ObjectId object"); + } + let date = new Date("2020-12-20T07:28:19.213Z"); + res.json({ + id: req.params.id, + creationDateTime : date, + creationDate: date + }); + }); + app.post([`${app.basePath}/users`], (req, res) => { + if(typeof req.body.id !== 'object') { + throw new Error("Should be deserialized to ObjectId object"); + } + if(typeof req.body.creationDate !== 'object' || !(req.body.creationDate instanceof Date)) { + throw new Error("Should be deserialized to Date object"); + } + if(typeof req.body.creationDateTime !== 'object' || !(req.body.creationDateTime instanceof Date)) { + throw new Error("Should be deserialized to Date object"); + } + 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, + ); + return app + }); + + after(() => { + app.server.close(); + }); + + it('should control BAD id format and throw an error', async () => + request(app) + .get(`${app.basePath}/users/1234`) + .expect(400) + .then((r) => { + expect(r.body.message).to.equal('request.params.id should match pattern "^[0-9a-fA-F]{24}$"'); + })); + + it('should control GOOD id format and get a response in expected format', async () => + request(app) + .get(`${app.basePath}/users/5fdefd13a6640bb5fb5fa925`) + .expect(200) + .then((r) => { + expect(r.body.id).to.equal('5fdefd13a6640bb5fb5fa925'); + expect(r.body.creationDate).to.equal('2020-12-20'); + expect(r.body.creationDateTime).to.equal("2020-12-20T07:28:19.213Z"); + })); + + it('should POST also works with deserialize on request then serialize en response', async () => + request(app) + .post(`${app.basePath}/users`) + .send({ + id: '5fdefd13a6640bb5fb5fa925', + creationDateTime : '2020-12-20T07:28:19.213Z', + creationDate: '2020-12-20' + }) + .set('Content-Type', 'application/json') + .expect(200) + .then((r) => { + expect(r.body.id).to.equal('5fdefd13a6640bb5fb5fa925'); + expect(r.body.creationDate).to.equal('2020-12-20'); + expect(r.body.creationDateTime).to.equal("2020-12-20T07:28:19.213Z"); + })); + + it('should POST throw error on invalid schema ObjectId', async () => + request(app) + .post(`${app.basePath}/users`) + .send({ + id: '5fdefd13a6640bb5fb5fa', + creationDateTime : '2020-12-20T07:28:19.213Z', + creationDate: '2020-12-20' + }) + .set('Content-Type', 'application/json') + .expect(400) + .then((r) => { + expect(r.body.message).to.equal('request.body.id should match pattern "^[0-9a-fA-F]{24}$"'); + })); + + it('should POST throw error on invalid schema Date', async () => + request(app) + .post(`${app.basePath}/users`) + .send({ + id: '5fdefd13a6640bb5fb5fa925', + creationDateTime : '2020-12-20T07:28:19.213Z', + creationDate: '2020-1f-20' + }) + .set('Content-Type', 'application/json') + .expect(400) + .then((r) => { + expect(r.body.message).to.equal('request.body.creationDate should match format "date"'); + })); + +}); + + + +describe('serdes serialize request components only', () => { + let app = null; + + before(async () => { + // set up express app + app = await createApp( + { + apiSpec: apiSpecPath, + validateRequests: { + coerceTypes: true + }, + validateResponses: { + coerceTypes: true + }, + serDes: [ + dateSerializeOnly, + dateTimeSerializeOnly, + { + format: "mongo-objectid", + serialize: (o) => o.toString(), + }, + ], + unknownFormats: ['mongo-objectid'], + }, + 3005, + (app) => { + app.get([`${app.basePath}/users/:id?`], (req, res) => { + if(typeof req.params.id !== 'string') { + throw new Error("Should be not be deserialized to ObjectId object"); + } + let date = new Date("2020-12-20T07:28:19.213Z"); + res.json({ + id: new ObjectID(req.params.id), + creationDateTime : date, + creationDate: date + }); + }); + app.post([`${app.basePath}/users`], (req, res) => { + if(typeof req.body.id !== 'string') { + throw new Error("Should NOT be deserialized to ObjectId object"); + } + if(typeof req.body.creationDate !== 'string') { + throw new Error("Should NTO be deserialized to Date object"); + } + if(typeof req.body.creationDateTime !== 'string') { + throw new Error("Should NOT be deserialized to Date object"); + } + req.body.id = new ObjectID(req.body.id); + req.body.creationDateTime = new Date(req.body.creationDateTime); + // We let creationDate as String and it should also work (either in Date Object ou String 'date' format) + 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, + ); + return app + }); + + after(() => { + app.server.close(); + }); + + it('should control BAD id format and throw an error', async () => + request(app) + .get(`${app.basePath}/users/1234`) + .expect(400) + .then((r) => { + expect(r.body.message).to.equal('request.params.id should match pattern "^[0-9a-fA-F]{24}$"'); + })); + + it('should control GOOD id format and get a response in expected format', async () => + request(app) + .get(`${app.basePath}/users/5fdefd13a6640bb5fb5fa925`) + .expect(200) + .then((r) => { + expect(r.body.id).to.equal('5fdefd13a6640bb5fb5fa925'); + expect(r.body.creationDate).to.equal('2020-12-20'); + expect(r.body.creationDateTime).to.equal("2020-12-20T07:28:19.213Z"); + })); + + it('should POST also works with deserialize on request then serialize en response', async () => + request(app) + .post(`${app.basePath}/users`) + .send({ + id: '5fdefd13a6640bb5fb5fa925', + creationDateTime : '2020-12-20T07:28:19.213Z', + creationDate: '2020-12-20' + }) + .set('Content-Type', 'application/json') + .expect(200) + .then((r) => { + expect(r.body.id).to.equal('5fdefd13a6640bb5fb5fa925'); + expect(r.body.creationDate).to.equal('2020-12-20'); + expect(r.body.creationDateTime).to.equal("2020-12-20T07:28:19.213Z"); + })); + + it('should POST throw error on invalid schema ObjectId', async () => + request(app) + .post(`${app.basePath}/users`) + .send({ + id: '5fdefd13a6640bb5fb5fa', + creationDateTime : '2020-12-20T07:28:19.213Z', + creationDate: '2020-12-20' + }) + .set('Content-Type', 'application/json') + .expect(400) + .then((r) => { + expect(r.body.message).to.equal('request.body.id should match pattern "^[0-9a-fA-F]{24}$"'); + })); + + it('should POST throw error on invalid schema Date', async () => + request(app) + .post(`${app.basePath}/users`) + .send({ + id: '5fdefd13a6640bb5fb5fa925', + creationDateTime : '2020-12-20T07:28:19.213Z', + creationDate: '2020-1f-20' + }) + .set('Content-Type', 'application/json') + .expect(400) + .then((r) => { + expect(r.body.message).to.equal('request.body.creationDate should match format "date"'); + })); + +});