Skip to content

Commit

Permalink
fix: Deserialize custom types with inline schemas (#823)
Browse files Browse the repository at this point in the history
  • Loading branch information
duncanbeevers authored Apr 30, 2023
1 parent f5bbce9 commit d53621d
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 7 deletions.
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 @@ -230,6 +254,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 @@ -505,6 +533,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 @@ -541,7 +591,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

0 comments on commit d53621d

Please sign in to comment.