Skip to content

Commit 1ca87c7

Browse files
authored
Add serDes setting : serialize and deserialize (#506)
* `serDes` setting allows to `serialize` response objects and `deserialize` request parameters or fields. It could resolve : #353 #465 #288 #246 Unit tests validate Date and MongoDb ObjectID. Developers have choice to : - only serialize response contents - also deserialize request strings to custom objects Frequent SerDes are defined in base.serdes.ts (date and date-time). Documentation updated with this setting * I don't know why there was a cloneDeep but it seems to be necessary in all cases. * Fix validation problems with oneOf and force default SerDes when no SerDes are defined (force DateTime and Date serialization). * Delete old code comments * New test : If I answer with an object which serialize fails (here because I answer with an ObjectID instead of Date and no toISOString function exists), a 500 error is thrown. * Add Date and date-time serialization by default in addition to other serDes settings. Custom settings can also override date and date-time formats in order to also deserialize date and/or date-time on requests * Add Date and date-time serialization by default in addition to other serDes settings. Custom settings can also override date and date-time formats in order to also deserialize date and/or date-time on requests * `serDes` option adaptation to be more user friendly Test OK Documentation is modified I also changed my https://github.com/pilerou/mongo-serdes-js with a 0.0.3 version which is compliant to the design : ```javascript serDes: [ OpenApiValidator.baseSerDes.date.serializer, OpenApiValidator.baseSerDes.dateTime, MongoSerDes.objectid, // this configuration if we want to deserialize objectid in request and serialize it in response MongoSerDes.uuid.serializer, // this configuration if we only want to serialize on response ], ``` * When we add custom formats in serDes, they are automatically added to unknownFormats * Rename OpenApiValidator.baseSerDes to OpenApiValidator.serdes
1 parent 4c7387a commit 1ca87c7

File tree

9 files changed

+594
-50
lines changed

9 files changed

+594
-50
lines changed

README.md

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
- ✔️ request validation
1616
- ✔️ response validation (json only)
1717
- 👮 security validation / custom security functions
18-
- 👽 3rd party / custom formats
18+
- 👽 3rd party / custom formats / custom data serialization-deserialization
1919
- 🧵 optionally auto-map OpenAPI endpoints to Express handler functions
2020
- ✂️ **\$ref** support; split specs over multiple files
2121
- 🎈 file upload
@@ -493,12 +493,21 @@ OpenApiValidator.middleware({
493493
validate: (value: any) => boolean,
494494
}],
495495
unknownFormats: ['phone-number', 'uuid'],
496+
serDes: [{
497+
OpenApiValidator.serdes.dateTime,
498+
OpenApiValidator.serdes.date,
499+
{
500+
format: 'mongo-objectid',
501+
deserialize: (s) => new ObjectID(s),
502+
serialize: (o) => o.toString(),
503+
},
504+
}],
496505
operationHandlers: false | 'operations/base/path' | { ... },
497506
ignorePaths: /.*\/pets$/,
498507
fileUploader: { ... } | true | false,
499508
$refParser: {
500509
mode: 'bundle'
501-
}
510+
},
502511
});
503512
```
504513

@@ -731,6 +740,53 @@ Defines how the validator should behave if an unknown or custom format is encoun
731740

732741
- `"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.
733742

743+
### ▪️ serDes (optional)
744+
745+
Default behaviour convert `Date` objects to `string` when a field, in OpenAPI configuration, has a `format` setting set to `date` or `date-time`.
746+
This Date conversion only occurs before sending the response.
747+
748+
You can use `serDes` option to add custom mecanism that :
749+
- `deserialize` string to custom object (Date...) on request
750+
- Deserialization is made after other schema validation (`pattern`...)
751+
- `serialize` object before sending the response
752+
- Serialization is made instead of other validation. No `pattern` or other rule is checked.
753+
754+
The goal of `serDes` option is to focus route functions on feature and without having to cast data on request or before sending response.
755+
756+
To both `deserialize` on request and `serialize` on response, both functions must be defined and are launched when schema `format` fields match.
757+
```javascript
758+
serDes: [{
759+
OpenApiValidator.serdes.dateTime, // used when 'format: date-time'
760+
OpenApiValidator.serdes.date, // used when 'format: date'
761+
{
762+
format: 'mongo-objectid',
763+
deserialize: (s) => new ObjectID(s),
764+
serialize: (o) => o.toString(),
765+
}
766+
}],
767+
```
768+
769+
If you ONLY want to `serialize` response data (and avoid to deserialize on request), the configuration must not define `deserialize` function.
770+
```javascript
771+
serDes: [{
772+
// No need to declare date and dateTime. Those types deserialization is already done by default.
773+
//OpenApiValidator.serdes.dateTime.serializer,
774+
//OpenApiValidator.serdes.date.serializer,
775+
{
776+
format: 'mongo-objectid',
777+
serialize: (o) => o.toString(),
778+
}
779+
}],
780+
```
781+
So, in conclusion, you can use `OpenApiValidator.serdes.dateTime` if you can to serialize and deserialize dateTime.
782+
You can also use `OpenApiValidator.serdes.dateTime.serializer` if you only want to serialize or `OpenApiValidator.serdes.dateTime.deserializer` if you only want to deserialize.
783+
784+
NOTE : If you add custom formats in serDes, they are automatically added as accepted custom formats in [unknownFormats](#unknownFormats-(optional)) option setting.
785+
You don't need to add them in unknownFormats.
786+
787+
You may want to use `serDes` option for MongoDB types (ObjectID, UUID...).
788+
Then you can use the package [mongo-serdes-js](https://github.com/pilerou/mongo-serdes-js). It is designed to be a good addition to this package.
789+
734790
### ▪️ operationHandlers (optional)
735791

736792
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).

src/framework/ajv/index.ts

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,23 @@ function createAjv(
3636
ajv.removeKeyword('const');
3737

3838
if (request) {
39+
if (options.serDesMap) {
40+
ajv.addKeyword('x-eov-serdes', {
41+
modifying: true,
42+
compile: (sch) => {
43+
if (sch) {
44+
return function validate(data, path, obj, propName) {
45+
if (typeof data === 'object') return true;
46+
if(!!sch.deserialize) {
47+
obj[propName] = sch.deserialize(data);
48+
}
49+
return true;
50+
};
51+
}
52+
return () => true;
53+
},
54+
});
55+
}
3956
ajv.removeKeyword('readOnly');
4057
ajv.addKeyword('readOnly', {
4158
modifying: true,
@@ -62,20 +79,23 @@ function createAjv(
6279
});
6380
} else {
6481
// response
65-
ajv.addKeyword('x-eov-serializer', {
66-
modifying: true,
67-
compile: (sch) => {
68-
if (sch) {
69-
const isDate = ['date', 'date-time'].includes(sch.format);
70-
return function validate(data, path, obj, propName) {
71-
if (typeof data === 'string' && isDate) return true
72-
obj[propName] = sch.serialize(data);
73-
return true;
74-
};
75-
}
76-
return () => true;
77-
},
78-
});
82+
if (options.serDesMap) {
83+
ajv.addKeyword('x-eov-serdes', {
84+
modifying: true,
85+
compile: (sch) => {
86+
if (sch) {
87+
return function validate(data, path, obj, propName) {
88+
if (typeof data === 'string') return true;
89+
if(!!sch.serialize) {
90+
obj[propName] = sch.serialize(data);
91+
}
92+
return true;
93+
};
94+
}
95+
return () => true;
96+
},
97+
});
98+
}
7999
ajv.removeKeyword('writeOnly');
80100
ajv.addKeyword('writeOnly', {
81101
modifying: true,

src/framework/base.serdes.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { SerDes, SerDesSingleton } from './types';
2+
3+
export const dateTime : SerDesSingleton = new SerDesSingleton({
4+
format : 'date-time',
5+
serialize: (d: Date) => {
6+
return d && d.toISOString();
7+
},
8+
deserialize: (s: string) => {
9+
return new Date(s);
10+
}
11+
});
12+
13+
export const date : SerDesSingleton = new SerDesSingleton({
14+
format : 'date',
15+
serialize: (d: Date) => {
16+
return d && d.toISOString().split('T')[0];
17+
},
18+
deserialize: (s: string) => {
19+
return new Date(s);
20+
}
21+
});
22+
23+
export const defaultSerDes : SerDes[] = [
24+
date.serializer,
25+
dateTime.serializer
26+
];

src/framework/types.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export interface MultipartOpts {
3838

3939
export interface Options extends ajv.Options {
4040
// Specific options
41-
schemaObjectMapper?: object;
41+
serDesMap?: SerDesMap;
4242
}
4343

4444
export interface RequestValidatorOptions extends Options, ValidateRequestOpts {}
@@ -70,9 +70,40 @@ export type Format = {
7070
validate: (v: any) => boolean;
7171
};
7272

73-
export type Serializer = {
73+
export type SerDes = {
7474
format: string;
75-
serialize: (o: unknown) => string;
75+
serialize?: (o: unknown) => string;
76+
deserialize?: (s: string) => unknown;
77+
};
78+
79+
export class SerDesSingleton implements SerDes {
80+
serializer: SerDes;
81+
deserializer: SerDes;
82+
format: string;
83+
serialize?: (o: unknown) => string;
84+
deserialize?: (s: string) => unknown;
85+
86+
constructor(param: {
87+
format: string;
88+
serialize: (o: unknown) => string;
89+
deserialize: (s: string) => unknown;
90+
}) {
91+
this.format = param.format;
92+
this.serialize = param.serialize;
93+
this.deserialize = param.deserialize;
94+
this.deserializer = {
95+
format : param.format,
96+
deserialize : param.deserialize
97+
}
98+
this.serializer = {
99+
format : param.format,
100+
serialize : param.serialize
101+
}
102+
}
103+
};
104+
105+
export type SerDesMap = {
106+
[format: string]: SerDes
76107
};
77108

78109
export interface OpenApiValidatorOpts {
@@ -85,6 +116,7 @@ export interface OpenApiValidatorOpts {
85116
securityHandlers?: SecurityHandlers;
86117
coerceTypes?: boolean | 'array';
87118
unknownFormats?: true | string[] | 'ignore';
119+
serDes?: SerDes[];
88120
formats?: Format[];
89121
fileUploader?: boolean | multer.Options;
90122
multerOpts?: multer.Options;

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const error = {
2929
Forbidden,
3030
};
3131

32+
export * as serdes from './framework/base.serdes';
33+
3234
function openapiValidator(options: OpenApiValidatorOpts) {
3335
const oav = new OpenApiValidator(options);
3436
exports.middleware._oav = oav;

src/middlewares/parsers/schema.preprocessor.ts

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import * as _get from 'lodash.get';
55
import { createRequestAjv } from '../../framework/ajv';
66
import {
77
OpenAPIV3,
8-
Serializer,
8+
SerDesMap,
9+
Options,
910
ValidateResponseOpts,
1011
} from '../../framework/types';
1112

@@ -48,20 +49,6 @@ class Root<T> extends Node<T, T> {
4849
}
4950
}
5051

51-
const dateTime: Serializer = {
52-
format: 'date-time',
53-
serialize: (d: Date) => {
54-
return d && d.toISOString();
55-
},
56-
};
57-
58-
const date: Serializer = {
59-
format: 'date',
60-
serialize: (d: Date) => {
61-
return d && d.toISOString().split('T')[0];
62-
},
63-
};
64-
6552
type SchemaObject = OpenAPIV3.SchemaObject;
6653
type ReferenceObject = OpenAPIV3.ReferenceObject;
6754
type Schema = ReferenceObject | SchemaObject;
@@ -87,14 +74,16 @@ export class SchemaPreprocessor {
8774
private ajv: Ajv;
8875
private apiDoc: OpenAPIV3.Document;
8976
private apiDocRes: OpenAPIV3.Document;
77+
private serDesMap: SerDesMap;
9078
private responseOpts: ValidateResponseOpts;
9179
constructor(
9280
apiDoc: OpenAPIV3.Document,
93-
ajvOptions: ajv.Options,
81+
ajvOptions: Options,
9482
validateResponsesOpts: ValidateResponseOpts,
9583
) {
9684
this.ajv = createRequestAjv(apiDoc, ajvOptions);
9785
this.apiDoc = apiDoc;
86+
this.serDesMap = ajvOptions.serDesMap;
9887
this.responseOpts = validateResponsesOpts;
9988
}
10089

@@ -356,19 +345,9 @@ export class SchemaPreprocessor {
356345
schema: SchemaObject,
357346
state: TraversalState,
358347
) {
359-
if (state.kind === 'res') {
360-
if (schema.type === 'string' && !!schema.format) {
361-
switch (schema.format) {
362-
case 'date-time':
363-
(<any>schema).type = ['object', 'string'];
364-
schema['x-eov-serializer'] = dateTime;
365-
break;
366-
case 'date':
367-
(<any>schema).type = ['object', 'string'];
368-
schema['x-eov-serializer'] = date;
369-
break;
370-
}
371-
}
348+
if (schema.type === 'string' && !!schema.format && this.serDesMap[schema.format]) {
349+
(<any>schema).type = ['object', 'string'];
350+
schema['x-eov-serdes'] = this.serDesMap[schema.format];
372351
}
373352
}
374353

src/openapi.validator.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import {
2020
} from './framework/types';
2121
import { defaultResolver } from './resolvers';
2222
import { OperationHandlerOptions } from './framework/types';
23+
import { defaultSerDes } from './framework/base.serdes';
2324
import { SchemaPreprocessor } from './middlewares/parsers/schema.preprocessor';
2425

26+
2527
export {
2628
OpenApiValidatorOpts,
2729
InternalServerError,
@@ -341,7 +343,27 @@ export class OpenApiValidator {
341343
}
342344

343345
private normalizeOptions(options: OpenApiValidatorOpts): void {
344-
// Modify the request
346+
if(!options.serDes) {
347+
options.serDes = defaultSerDes;
348+
}
349+
else {
350+
if(!Array.isArray(options.unknownFormats)) {
351+
options.unknownFormats = Array<string>();
352+
}
353+
options.serDes.forEach(currentSerDes => {
354+
if((options.unknownFormats as string[]).indexOf(currentSerDes.format) === -1) {
355+
(options.unknownFormats as string[]).push(currentSerDes.format)
356+
}
357+
});
358+
defaultSerDes.forEach(currentDefaultSerDes => {
359+
let defautSerDesOverride = options.serDes.find(currentOptionSerDes => {
360+
return currentDefaultSerDes.format === currentOptionSerDes.format;
361+
});
362+
if(!defautSerDesOverride) {
363+
options.serDes.push(currentDefaultSerDes);
364+
}
365+
});
366+
}
345367
}
346368

347369
private isOperationHandlerOptions(
@@ -393,7 +415,22 @@ class AjvOptions {
393415
}
394416

395417
private baseOptions(): Options {
396-
const { coerceTypes, unknownFormats, validateFormats } = this.options;
418+
const { coerceTypes, unknownFormats, validateFormats, serDes } = this.options;
419+
const serDesMap = {};
420+
for (const serDesObject of serDes) {
421+
if(!serDesMap[serDesObject.format]) {
422+
serDesMap[serDesObject.format] = serDesObject;
423+
}
424+
else {
425+
if (serDesObject.serialize) {
426+
serDesMap[serDesObject.format].serialize = serDesObject.serialize;
427+
}
428+
if (serDesObject.deserialize) {
429+
serDesMap[serDesObject.format].deserialize = serDesObject.deserialize;
430+
}
431+
}
432+
}
433+
397434
return {
398435
nullable: true,
399436
coerceTypes,
@@ -408,6 +445,7 @@ class AjvOptions {
408445
};
409446
return acc;
410447
}, {}),
448+
serDesMap : serDesMap,
411449
};
412450
}
413451
}

0 commit comments

Comments
 (0)