diff --git a/.gitignore b/.gitignore index 646bb75b..725d048f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ dist secrets.zip jest junk -/a_reference \ No newline at end of file +/a_reference +/sample2 \ No newline at end of file diff --git a/src/middlewares/openapi.request.validator.ts b/src/middlewares/openapi.request.validator.ts index 47c5b43c..33fe3c03 100644 --- a/src/middlewares/openapi.request.validator.ts +++ b/src/middlewares/openapi.request.validator.ts @@ -86,7 +86,7 @@ export class RequestValidator { contentType: ContentType, ): RequestHandler { const apiDoc = this.apiDoc; - const schemaParser = new ParametersSchemaParser(apiDoc); + const schemaParser = new ParametersSchemaParser(this.ajv, apiDoc); const bodySchemaParser = new BodySchemaParser(this.ajv, apiDoc); const parameters = schemaParser.parse(path, reqSchema.parameters); const securityQueryParam = Security.queryParam(apiDoc, reqSchema); @@ -112,7 +112,7 @@ export class RequestValidator { req.params = openapi.pathParams ?? req.params; } - const mutator = new RequestParameterMutator(apiDoc, path, properties); + const mutator = new RequestParameterMutator(this.ajv, apiDoc, path, properties); mutator.modifyRequest(req); diff --git a/src/middlewares/parsers/req.parameter.mutator.ts b/src/middlewares/parsers/req.parameter.mutator.ts index 6a5112fd..96f8502b 100644 --- a/src/middlewares/parsers/req.parameter.mutator.ts +++ b/src/middlewares/parsers/req.parameter.mutator.ts @@ -1,4 +1,6 @@ import { Request } from 'express'; +import { Ajv } from 'ajv'; +import * as ajv from 'ajv'; import { OpenAPIV3, OpenApiRequest, @@ -40,13 +42,16 @@ type Parameter = ReferenceObject | ParameterObject; export class RequestParameterMutator { private _apiDocs: OpenAPIV3.Document; private path: string; + private ajv: Ajv; private parsedSchema: ValidationSchema; constructor( + ajv: Ajv, apiDocs: OpenAPIV3.Document, path: string, parsedSchema: ValidationSchema, ) { + this.ajv = ajv; this._apiDocs = apiDocs; this.path = path; this.parsedSchema = parsedSchema; @@ -63,9 +68,10 @@ export class RequestParameterMutator { url.parse(req.originalUrl).query, ); - parameters.forEach(p => { + parameters.forEach((p) => { const parameter = dereferenceParameter(this._apiDocs, p); - const { name, schema } = normalizeParameter(parameter); + const { name, schema } = normalizeParameter(this.ajv, parameter); + const { type } = schema; const { style, explode } = parameter; const i = req.originalUrl.indexOf('?'); @@ -78,11 +84,13 @@ export class RequestParameterMutator { if (parameter.content) { this.handleContent(req, name, parameter); } else if (parameter.in === 'query' && this.isObjectOrXOf(schema)) { - this.parseJsonAndMutateRequest(req, parameter.in, name); if (style === 'form' && explode) { + this.parseJsonAndMutateRequest(req, parameter.in, name); this.handleFormExplode(req, name, schema, parameter); } else if (style === 'deepObject') { this.handleDeepObject(req, queryString, name); + } else { + this.parseJsonAndMutateRequest(req, parameter.in, name); } } else if (type === 'array' && !explode) { const delimiter = ARRAY_DELIMITER[parameter.style]; @@ -97,7 +105,10 @@ export class RequestParameterMutator { } private handleDeepObject(req: Request, qs: string, name: string): void { - // nothing to do + if (!req.query?.[name]) { + req.query[name] = {}; + } + this.parseJsonAndMutateRequest(req, 'query', name); // TODO handle url encoded? } @@ -219,11 +230,11 @@ export class RequestParameterMutator { // for easy validation, keep the schema but update whereabouts of its sub components const field = REQUEST_FIELDS[$in]; if (req[field]) { - // check if there is at least one of the nested properties before create the parent - const atLeastOne = properties.some(p => req[field].hasOwnProperty(p)); + // check if there is at least one of the nested properties before creating the root property + const atLeastOne = properties.some((p) => req[field].hasOwnProperty(p)); if (atLeastOne) { req[field][name] = {}; - properties.forEach(property => { + properties.forEach((property) => { if (req[field][property]) { const schema = this.parsedSchema[field]; const type = schema.properties[name].properties?.[property]?.type; @@ -254,8 +265,9 @@ export class RequestParameterMutator { } private isObjectOrXOf(schema: Schema): boolean { - const schemaHasObject = schema => { + const schemaHasObject = (schema) => { if (!schema) return false; + if (schema.$ref) return true; const { type, allOf, oneOf, anyOf } = schema; return ( type === 'object' || diff --git a/src/middlewares/parsers/schema.parse.ts b/src/middlewares/parsers/schema.parse.ts index 60819038..d9e50ac5 100644 --- a/src/middlewares/parsers/schema.parse.ts +++ b/src/middlewares/parsers/schema.parse.ts @@ -1,6 +1,7 @@ import { OpenAPIV3, ParametersSchema } from '../../framework/types'; import { validationError } from '../util'; import { dereferenceParameter, normalizeParameter } from './util'; +import { Ajv } from 'ajv'; const PARAM_TYPE = { query: 'query', @@ -16,9 +17,11 @@ type Parameter = OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject; * whose value must later be parsed as a JSON object, JSON Exploded Object, JSON Array, or JSON Exploded Array */ export class ParametersSchemaParser { + private _ajv: Ajv; private _apiDocs: OpenAPIV3.Document; - constructor(apiDocs: OpenAPIV3.Document) { + constructor(ajv: Ajv, apiDocs: OpenAPIV3.Document) { + this._ajv = ajv; this._apiDocs = apiDocs; } @@ -31,13 +34,13 @@ export class ParametersSchemaParser { public parse(path: string, parameters: Parameter[] = []): ParametersSchema { const schemas = { query: {}, headers: {}, params: {}, cookies: {} }; - parameters.forEach(p => { + parameters.forEach((p) => { const parameter = dereferenceParameter(this._apiDocs, p); this.validateParameterType(path, parameter); const reqField = PARAM_TYPE[parameter.in]; - const { name, schema } = normalizeParameter(parameter); + const { name, schema } = normalizeParameter(this._ajv, parameter); if (!schemas[reqField].properties) { schemas[reqField] = { diff --git a/src/middlewares/parsers/util.ts b/src/middlewares/parsers/util.ts index 40299396..2e5411d5 100644 --- a/src/middlewares/parsers/util.ts +++ b/src/middlewares/parsers/util.ts @@ -1,4 +1,7 @@ +import { Ajv } from 'ajv'; import { OpenAPIV3 } from '../../framework/types'; +import ajv = require('ajv'); +import { OpenAPIFramework } from '../../framework'; export function dereferenceParameter( apiDocs: OpenAPIV3.Document, @@ -16,23 +19,42 @@ export function dereferenceParameter( } export function normalizeParameter( + ajv: Ajv, parameter: OpenAPIV3.ParameterObject, ): { name: string; - schema: OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject; + schema: OpenAPIV3.SchemaObject; } { - // TODO this should recurse or use ajv.getSchema - if implemented as such, may want to cache the result - // as it is called by query.paraer and req.parameter mutator - let schema = parameter.schema; + let schema; + if (is$Ref(parameter)) { + schema = dereferenceSchema(ajv, parameter['$ref']); + } else if (parameter?.schema?.['$ref']) { + schema = dereferenceSchema(ajv, parameter.schema['$ref']); + } else { + schema = parameter.schema + } if (!schema) { const contentType = Object.keys(parameter.content)[0]; schema = parameter.content?.[contentType]?.schema; } + if (!schema) { + schema = parameter; + } + const name = parameter.in === 'header' ? parameter.name.toLowerCase() : parameter.name; return { name, schema }; } +export function dereferenceSchema(ajv: Ajv, ref: string) { + // TODO cache schemas - so that we don't recurse every time + const derefSchema = ajv.getSchema(ref); + if (derefSchema?.['$ref']) { + return dereferenceSchema(ajv, ''); + } + return derefSchema.schema; +} + function is$Ref( parameter: OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject, ): boolean { diff --git a/test/common/app.ts b/test/common/app.ts index 42ec6e69..38ab54b0 100644 --- a/test/common/app.ts +++ b/test/common/app.ts @@ -11,7 +11,7 @@ import { OpenApiValidatorOpts } from '../../src/framework/types'; export async function createApp( opts?: OpenApiValidatorOpts, port = 3000, - customRoutes = app => {}, + customRoutes = (app) => {}, useRoutes = true, apiRouter = undefined, ) { diff --git a/test/resources/serialized.objects.defaults.yaml b/test/resources/serialized.objects.defaults.yaml new file mode 100644 index 00000000..c9b7ba01 --- /dev/null +++ b/test/resources/serialized.objects.defaults.yaml @@ -0,0 +1,58 @@ +components: + schemas: + PageSort: + allOf: + - $ref: "#/components/schemas/Paging" + - $ref: "#/components/schemas/Sorting" + Paging: + properties: + page: + default: 1 + minimum: 1 + type: integer + perPage: + default: 25 + type: integer + type: object + Sorting: + properties: + field: + default: id + enum: + - id + - name + type: string + order: + default: ASC + enum: + - ASC + - DESC + type: string + type: object +info: + description: API + title: API + version: 1.0.0 +openapi: 3.0.0 +servers: + - url: /v1/ +paths: + /deep_object: + get: + operationId: getDeepObject + parameters: + - explode: true + in: query + name: pagesort + schema: + $ref: "#/components/schemas/PageSort" + style: deepObject + responses: + "200": + description: description + content: + application/json: + schema: + items: + type: number + type: array diff --git a/test/serialized.objects.defaults.spec.ts b/test/serialized.objects.defaults.spec.ts new file mode 100644 index 00000000..a36c2780 --- /dev/null +++ b/test/serialized.objects.defaults.spec.ts @@ -0,0 +1,51 @@ +import * as path from 'path'; +import * as express from 'express'; +import * as request from 'supertest'; +import * as packageJson from '../package.json'; +import { expect } from 'chai'; +import { createApp } from './common/app'; + +describe(packageJson.name, () => { + let app = null; + + before(async () => { + // Set up the express app + const apiSpec = path.join( + 'test', + 'resources', + 'serialized.objects.defaults.yaml', + ); + app = await createApp({ apiSpec }, 3005, (app) => + app.use( + `${app.basePath}`, + express.Router().get(`/deep_object`, (req, res) => res.json(req.query)), + ), + ); + }); + + after(() => { + app.server.close(); + }); + + it('should use defaults when empty', async () => + request(app) + .get(`${app.basePath}/deep_object`) + .expect(200) + .then((r) => { + console.log(r.body); + expect(r.body).to.deep.equals({ + pagesort: { page: 1, perPage: 25, field: 'id', order: 'ASC' }, + }); + })); + + it('should use defaults for values not provided', async () => + request(app) + .get(`${app.basePath}/deep_object?pagesort[field]=name`) + .expect(200) + .then((r) => { + console.log(r.body); + expect(r.body).to.deep.equals({ + pagesort: { page: 1, perPage: 25, field: 'name', order: 'ASC' }, + }); + })); +});