Skip to content

fix: Deserialize custom types with inline schemas #823

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions src/middlewares/parsers/schema.preprocessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface TraversalState {

interface TopLevelPathNodes {
requestBodies: Root<SchemaObject>[];
requestParameters: Root<SchemaObject>[];
responses: Root<SchemaObject>[];
}
interface TopLevelSchemaNodes extends TopLevelPathNodes {
Expand All @@ -43,14 +44,31 @@ class Node<T, P> {
}
type SchemaObjectNode = Node<SchemaObject, SchemaObject>;

function isParameterObject(node: ParameterObject | ReferenceObject): node is ParameterObject {
return !((node as ReferenceObject).$ref);
}
function isReferenceObject(node: ArraySchemaObject | NonArraySchemaObject | ReferenceObject): node is ReferenceObject {
return !!((node as ReferenceObject).$ref);
}
function isArraySchemaObject(node: ArraySchemaObject | NonArraySchemaObject | ReferenceObject): node is ArraySchemaObject {
return !!((node as ArraySchemaObject).items);
}
function isNonArraySchemaObject(node: ArraySchemaObject | NonArraySchemaObject | ReferenceObject): node is NonArraySchemaObject {
return !isArraySchemaObject(node) && !isReferenceObject(node);
}

class Root<T> extends Node<T, T> {
constructor(schema: T, path: string[]) {
super(null, schema, path);
}
}

type SchemaObject = OpenAPIV3.SchemaObject;
type ArraySchemaObject = OpenAPIV3.ArraySchemaObject;
type NonArraySchemaObject = OpenAPIV3.NonArraySchemaObject;
type OperationObject = OpenAPIV3.OperationObject;
type ParameterObject = OpenAPIV3.ParameterObject;
type ReferenceObject = OpenAPIV3.ReferenceObject;
type SchemaObject = OpenAPIV3.SchemaObject;
type Schema = ReferenceObject | SchemaObject;

if (!Array.prototype['flatMap']) {
Expand Down Expand Up @@ -99,6 +117,7 @@ export class SchemaPreprocessor {
schemas: componentSchemas,
requestBodies: r.requestBodies,
responses: r.responses,
requestParameters: r.requestParameters,
};

// Traverse the schemas
Expand Down Expand Up @@ -127,6 +146,7 @@ export class SchemaPreprocessor {

private gatherSchemaNodesFromPaths(): TopLevelPathNodes {
const requestBodySchemas = [];
const requestParameterSchemas = [];
const responseSchemas = [];

for (const [p, pi] of Object.entries(this.apiDoc.paths)) {
Expand All @@ -140,14 +160,18 @@ export class SchemaPreprocessor {
const node = new Root<OpenAPIV3.OperationObject>(operation, path);
const requestBodies = this.extractRequestBodySchemaNodes(node);
const responseBodies = this.extractResponseSchemaNodes(node);
const requestParameters = this.extractRequestParameterSchemaNodes(node);

requestBodySchemas.push(...requestBodies);
responseSchemas.push(...responseBodies);
requestParameterSchemas.push(...requestParameters);
}
}
}

return {
requestBodies: requestBodySchemas,
requestParameters: requestParameterSchemas,
responses: responseSchemas,
};
}
Expand Down Expand Up @@ -227,6 +251,10 @@ export class SchemaPreprocessor {
for (const node of nodes.responses) {
recurse(null, node, initOpts());
}

for (const node of nodes.requestParameters) {
recurse(null, node, initOpts());
}
}

private schemaVisitor(
Expand Down Expand Up @@ -502,6 +530,28 @@ export class SchemaPreprocessor {
return schemas;
}

private extractRequestParameterSchemaNodes(
operationNode: Root<OperationObject>,
): Root<SchemaObject>[] {

return (operationNode.schema.parameters ?? []).flatMap((node) => {
const parameterObject = isParameterObject(node) ? node : undefined;
if (!parameterObject?.schema) return [];

const schema = isNonArraySchemaObject(parameterObject.schema) ?
parameterObject.schema :
undefined;
if (!schema) return [];

return new Root(schema, [
...operationNode.path,
'parameters',
parameterObject.name,
parameterObject.in
]);
});
}

private resolveSchema<T>(schema): T {
if (!schema) return null;
const ref = schema?.['$ref'];
Expand Down Expand Up @@ -538,7 +588,7 @@ export class SchemaPreprocessor {
) =>
// if name or ref exists and are equal
(opParam['name'] && opParam['name'] === pathParam['name']) ||
(opParam['$ref'] && opParam['$ref'] === pathParam['$ref']);
(opParam['$ref'] && opParam['$ref'] === pathParam['$ref']);

// Add Path level query param to list ONLY if there is not already an operation-level query param by the same name.
for (const param of parameters) {
Expand Down
47 changes: 44 additions & 3 deletions test/resources/serdes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ paths:
required: true
schema:
$ref: "#/components/schemas/ObjectId"
- name: date-time-from-inline
in: query
required: false
schema:
type: string
format: date-time
- name: date-time-from-schema
in: query
required: false
schema:
$ref: "#/components/schemas/DateTime"
- name: baddateresponse
in: query
schema:
Expand All @@ -29,21 +40,51 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/User"
allOf:
- $ref: "#/components/schemas/User"
- type: object
properties:
summary:
type: object
additionalProperties:
type: object
properties:
value:
type: string
typeof:
type: string
/users:
post:
requestBody:
content :
application/json:
schema:
$ref: '#/components/schemas/User'
allOf:
- $ref: '#/components/schemas/User'
- type: object
properties:
creationDateTimeInline:
type: string
format: date-time
responses:
200:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/User"
allOf:
- $ref: "#/components/schemas/User"
- type: object
properties:
summary:
type: object
additionalProperties:
type: object
properties:
value:
type: string
typeof:
type: string
components:
schemas:
ObjectId:
Expand Down
34 changes: 32 additions & 2 deletions test/serdes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ class BadDate extends Date {
}
}

function toSummary(title, value) {
return {
[title]: {
value: value?.toISOString?.() || value?.toString(),
typeof: typeof value
}
}
}

describe('serdes', () => {
let app = null;

Expand Down Expand Up @@ -63,6 +72,10 @@ describe('serdes', () => {
creationDateTime: date,
creationDate: date,
shortOrLong: 'a',
summary: {
...toSummary('req.query.date-time-from-inline', req.query['date-time-from-inline']),
...toSummary('req.query.date-time-from-schema', req.query['date-time-from-schema']),
},
});
});
app.post([`${app.basePath}/users`], (req, res) => {
Expand All @@ -75,7 +88,13 @@ describe('serdes', () => {
if (typeof req.body.creationDateTime !== 'object' || !(req.body.creationDateTime instanceof Date)) {
throw new Error("Should be deserialized to Date object");
}
res.json(req.body);
if (typeof req.body.creationDateTimeInline !== 'object' || !(req.body.creationDateTimeInline instanceof Date)) {
throw new Error("Should be deserialized to Date object");
}
res.json({
...req.body,
summary: Object.entries(req.body).reduce((acc, [k, v]) => Object.assign(acc, toSummary(k, v)), {})
});
});
app.use((err, req, res, next) => {
res.status(err.status ?? 500).json({
Expand Down Expand Up @@ -103,12 +122,16 @@ describe('serdes', () => {

it('should control GOOD id format and get a response in expected format', async () =>
request(app)
.get(`${app.basePath}/users/5fdefd13a6640bb5fb5fa925`)
.get(`${app.basePath}/users/5fdefd13a6640bb5fb5fa925?date-time-from-inline=2019-11-20T01%3A11%3A54.930Z&date-time-from-schema=2020-11-20T01%3A11%3A54.930Z`)
.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");
expect(r.body.summary['req.query.date-time-from-schema'].value).to.equal("2020-11-20T01:11:54.930Z");
expect(r.body.summary['req.query.date-time-from-schema'].typeof).to.equal("object");
expect(r.body.summary['req.query.date-time-from-inline'].value).to.equal("2019-11-20T01:11:54.930Z");
expect(r.body.summary['req.query.date-time-from-inline'].typeof).to.equal("object");
}));

it('should POST also works with deserialize on request then serialize en response', async () =>
Expand All @@ -117,6 +140,7 @@ describe('serdes', () => {
.send({
id: '5fdefd13a6640bb5fb5fa925',
creationDateTime: '2020-12-20T07:28:19.213Z',
creationDateTimeInline: '2019-11-21T07:24:19.213Z',
creationDate: '2020-12-20',
shortOrLong: 'ab',
})
Expand All @@ -126,6 +150,12 @@ describe('serdes', () => {
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");
expect(r.body.summary['creationDate'].value).to.equal('2020-12-20T00:00:00.000Z');
expect(r.body.summary['creationDate'].typeof).to.equal('object');
expect(r.body.summary['creationDateTime'].value).to.equal('2020-12-20T07:28:19.213Z');
expect(r.body.summary['creationDateTime'].typeof).to.equal('object');
expect(r.body.summary['creationDateTimeInline'].value).to.equal('2019-11-21T07:24:19.213Z');
expect(r.body.summary['creationDateTimeInline'].typeof).to.equal('object');
}));

it('should POST throw error on invalid schema ObjectId', async () =>
Expand Down