Skip to content

Commit

Permalink
serDes setting allows to serialize response objects and `deserial…
Browse files Browse the repository at this point in the history
…ize` request parameters or fields. It could resolve : cdimascio#353 cdimascio#465 cdimascio#288 cdimascio#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
  • Loading branch information
pilerou committed Dec 30, 2020
1 parent e08f45a commit 005312e
Show file tree
Hide file tree
Showing 9 changed files with 491 additions and 58 deletions.
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
}
},
});
```

Expand Down Expand Up @@ -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).
Expand Down
48 changes: 34 additions & 14 deletions src/framework/ajv/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions src/framework/base.serdes.ts
Original file line number Diff line number Diff line change
@@ -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];
},
};
12 changes: 9 additions & 3 deletions src/framework/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 15 additions & 36 deletions src/middlewares/parsers/schema.preprocessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -48,20 +48,6 @@ class Root<T> extends Node<T, T> {
}
}

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;
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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':
(<any>schema).type = ['object', 'string'];
schema['x-eov-serializer'] = dateTime;
break;
case 'date':
(<any>schema).type = ['object', 'string'];
schema['x-eov-serializer'] = date;
break;
}
//if (state.kind === 'res') {
if (schema.type === 'string' && !!schema.format && this.serDesMap[schema.format]) {
(<any>schema).type = ['object', 'string'];
schema['x-eov-serdes'] = this.serDesMap[schema.format];
}
}
//}
}

private handleReadonly(
Expand Down
9 changes: 6 additions & 3 deletions src/openapi.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -408,6 +407,10 @@ class AjvOptions {
};
return acc;
}, {}),
serDesMap : serDes ? serDes.reduce((map, obj) => {
map[obj.format]=obj;
return map;
}, {}) : null,
};
}
}
Loading

0 comments on commit 005312e

Please sign in to comment.