diff --git a/README.md b/README.md index 7a423ab4..bf65c692 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,7 @@ new OpenApiValidator(options).install({ }, ignorePaths: /.*\/pets$/, unknownFormats: ['phone-number', 'uuid'], - multerOpts: { ... }, + fileUploader: { ... } | true | false, $refParser: { mode: 'bundle' } @@ -454,10 +454,22 @@ 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. -### ▪️ multerOpts (optional) +### ▪️ fileUploader (optional) Specifies the options to passthrough to multer. express-openapi-validator uses multer to handle file uploads. see [multer opts](https://github.com/expressjs/multer) +- `true` (**default**) - enables multer and provides simple file(s) upload capabilities +- `false` - disables file upload capability. Upload capabilities may be provided by the user +- `{...}` - multer options to be passed-through to multer. see [multer opts](https://github.com/expressjs/multer) for possible options + + e.g. + + ```javascript + fileUploader: { + dest: 'uploads/'; + } + ``` + ### ▪️ coerceTypes (optional) Determines whether the validator should coerce value types to match the type defined in the OpenAPI spec. @@ -773,6 +785,10 @@ module.exports = app; **A:** In v3, `securityHandlers` have been replaced by `validateSecurity.handlers`. To use v3 security handlers, move your existing security handlers to the new property. No other change is required. Note that the v2 `securityHandlers` property is supported in v3, but deprecated +Q: What happened to the `multerOpts` property? + +A: In v3, `multerOpts` have been replaced by `fileUploader`. In order to use the v3 `fileUploader`, move your multer options to `fileUploader` No other change is required. Note that the v2 `multerOpts` property is supported in v3, but deprecated + **Q:** Can I use a top level await? **A:** Top-level await is currently a stage 3 proposal, however it can be used today with [babel](https://babeljs.io/docs/en/babel-plugin-syntax-top-level-await) diff --git a/example/README.md b/example/README.md index 566889a1..de491602 100644 --- a/example/README.md +++ b/example/README.md @@ -153,7 +153,10 @@ new OpenApiValidator({ .then(app => { // 5. Define routes using Express app.get('/v1/pets', function(req, res, next) { - res.json([{ id: 1, name: 'max' }, { id: 2, name: 'mini' }]); + res.json([ + { id: 1, name: 'max' }, + { id: 2, name: 'mini' }, + ]); }); app.post('/v1/pets', function(req, res, next) { @@ -364,7 +367,7 @@ new OpenApiValidator(options).install({ validateResponses: true, ignorePaths: /.*\/pets$/ unknownFormats: ['phone-number', 'uuid'], - multerOpts: { ... }, + fileUploader: { ... }, securityHandlers: { ApiKeyAuth: (req, scopes, schema) => { throw { status: 401, message: 'sorry' } @@ -461,10 +464,22 @@ 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. -### ▪️ multerOpts (optional) +### ▪️ fileUploader (optional) Specifies the options to passthrough to multer. express-openapi-validator uses multer to handle file uploads. see [multer opts](https://github.com/expressjs/multer) +- `true` (**default**) - enables multer and provides simple file(s) upload capabilities +- `false` - disables file upload capability. Upload capabilities may be provided by the user +- `{...}` - multer options to be passed-through to multer. see [multer opts](https://github.com/expressjs/multer) for possible options + + e.g. + + ```javascript + fileUploader: { + dest: 'uploads/'; + } + ``` + ### ▪️ coerceTypes (optional) Determines whether the validator should coerce value types to match the type defined in the OpenAPI spec. diff --git a/package-lock.json b/package-lock.json index 923a1bca..4854dc4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "express-openapi-validator", - "version": "3.3.1", + "version": "3.4.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 9c23293f..2d26dc2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-openapi-validator", - "version": "3.3.1", + "version": "3.4.1", "description": "Automatically validate API requests and responses with OpenAPI 3 and Express.", "main": "dist/index.js", "scripts": { diff --git a/src/framework/types.ts b/src/framework/types.ts index db8fd0ca..4194e3d0 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -1,4 +1,5 @@ import * as ajv from 'ajv'; +import * as multer from 'multer'; import { Request, Response, NextFunction } from 'express'; export { OpenAPIFrameworkArgs }; @@ -55,7 +56,8 @@ export interface OpenApiValidatorOpts { securityHandlers?: SecurityHandlers; coerceTypes?: boolean | 'array'; unknownFormats?: true | string[] | 'ignore'; - multerOpts?: {}; + fileUploader?: boolean | multer.Options; + multerOpts?: multer.Options; $refParser?: { mode: 'bundle' | 'dereference'; }; diff --git a/src/index.ts b/src/index.ts index d13d20b6..f46c5dc8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export class OpenApiValidator { if (options.validateRequests == null) options.validateRequests = true; if (options.validateResponses == null) options.validateResponses = false; if (options.validateSecurity == null) options.validateSecurity = true; + if (options.fileUploader == null) options.fileUploader = {}; if (options.$refParser == null) options.$refParser = { mode: 'bundle' }; if (options.validateResponses === true) { @@ -83,7 +84,9 @@ export class OpenApiValidator { this.installPathParams(app, context); this.installMetadataMiddleware(app, context); - this.installMultipartMiddleware(app, context); + if (this.options.fileUploader) { + this.installMultipartMiddleware(app, context); + } const components = context.apiDoc.components; if (this.options.validateSecurity && components?.securitySchemes) { @@ -218,6 +221,20 @@ export class OpenApiValidator { ); } + const multerOpts = options.multerOpts; + if (securityHandlers != null) { + if (typeof multerOpts !== 'object' || Array.isArray(securityHandlers)) { + throw ono('multerOpts must be an object or undefined'); + } + deprecationWarning('multerOpts is deprecated. Use fileUploader instead.'); + } + + if (options.multerOpts && options.fileUploader) { + throw ono( + 'multerOpts and fileUploader may not be used together. Use fileUploader to specify upload options.', + ); + } + const unknownFormats = options.unknownFormats; if (typeof unknownFormats === 'boolean') { if (!unknownFormats) { @@ -243,5 +260,9 @@ export class OpenApiValidator { }; delete options.securityHandlers; } + if (options.multerOpts) { + options.fileUploader = options.multerOpts; + delete options.multerOpts; + } } } diff --git a/src/middlewares/openapi.multipart.ts b/src/middlewares/openapi.multipart.ts index d71a1ac7..fc0ff6ac 100644 --- a/src/middlewares/openapi.multipart.ts +++ b/src/middlewares/openapi.multipart.ts @@ -12,10 +12,12 @@ const multer = require('multer'); export function multipart( OpenApiContext: OpenApiContext, - multerOpts: {} = {}, + multerOpts: {}, ): OpenApiRequestHandler { const mult = multer(multerOpts); return (req, res, next) => { + // TODO check that format: binary (for upload) else do not use multer.any() + // use multer.none() if no binary parameters exist if (isMultipart(req) && isValidContentType(req)) { mult.any()(req, res, err => { if (err) { @@ -35,7 +37,6 @@ export function multipart( // case we must follow the $ref to check the type. if (req.files) { - // to handle single and multiple file upload at the same time, let us this initialize this count variable // for example { "files": 5 } const count_by_fieldname = (req.files) @@ -46,18 +47,15 @@ export function multipart( }, {}); // add file(s) to body - Object - .entries(count_by_fieldname) - .forEach( - ([fieldname, count]: [string, number]) => { - // TODO maybe also check in the api doc if it is a single upload or multiple - const is_multiple = count > 1; - req.body[fieldname] = (is_multiple) - ? new Array(count).fill('') - : ''; - }, - ); - + Object.entries(count_by_fieldname).forEach( + ([fieldname, count]: [string, number]) => { + // TODO maybe also check in the api doc if it is a single upload or multiple + const is_multiple = count > 1; + req.body[fieldname] = is_multiple + ? new Array(count).fill('') + : ''; + }, + ); } next(); } @@ -74,7 +72,9 @@ function isValidContentType(req: Request): boolean { } function isMultipart(req: OpenApiRequest): boolean { - return (req?.openapi)?.schema?.requestBody?.content?.['multipart/form-data']; + return (req?.openapi)?.schema?.requestBody?.content?.[ + 'multipart/form-data' + ]; } function error(req: OpenApiRequest, err: Error): ValidationError { diff --git a/src/middlewares/parsers/body.parse.ts b/src/middlewares/parsers/body.parse.ts index 15aa3616..44a221c5 100644 --- a/src/middlewares/parsers/body.parse.ts +++ b/src/middlewares/parsers/body.parse.ts @@ -62,8 +62,8 @@ export class BodySchemaParser { requestBody: OpenAPIV3.RequestBodyObject, ): BodySchema { const bodyContentSchema = - requestBody.content[contentType.contentType] && - requestBody.content[contentType.contentType].schema; + requestBody.content[contentType.withoutBoundary] && + requestBody.content[contentType.withoutBoundary].schema; let bodyContentRefSchema = null; if (bodyContentSchema && '$ref' in bodyContentSchema) { diff --git a/src/middlewares/util.ts b/src/middlewares/util.ts index fd6f1444..14b40236 100644 --- a/src/middlewares/util.ts +++ b/src/middlewares/util.ts @@ -7,7 +7,7 @@ export class ContentType { public contentType: string = null; public mediaType: string = null; public charSet: string = null; - private withoutBoundary: string = null; + public withoutBoundary: string = null; private constructor(contentType: string | null) { this.contentType = contentType; if (contentType) { diff --git a/test/common/app.common.ts b/test/common/app.common.ts index e1d68ebd..74d88930 100644 --- a/test/common/app.common.ts +++ b/test/common/app.common.ts @@ -98,15 +98,15 @@ export function routes(app) { }); }); - app.post('/v1/pets/:id/photos', function(req: Request, res: Response): void { - // req.file is the `avatar` file - // req.body will hold the text fields, if there were any - const files = req.files; - res.status(200).json({ - files, - metadata: req.body.metadata, - }); - }); + // app.post('/v1/pets/:id/photos', function(req: Request, res: Response): void { + // // req.file is the `avatar` file + // // req.body will hold the text fields, if there were any + // const files = req.files; + // res.status(200).json({ + // files, + // metadata: req.body.metadata, + // }); + // }); app.post('/v1/pets_charset', function(req: Request, res: Response): void { // req.file is the `avatar` file // req.body will hold the text fields, if there were any diff --git a/test/common/app.ts b/test/common/app.ts index cb1e84ee..a9656251 100644 --- a/test/common/app.ts +++ b/test/common/app.ts @@ -21,7 +21,6 @@ export async function createApp( app.use(bodyParser.json({ type: 'application/hal+json' })); app.use(bodyParser.text()); app.use(logger('dev')); - app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); diff --git a/test/common/myapp.ts b/test/common/myapp.ts index a15ecd87..5cc45fc8 100644 --- a/test/common/myapp.ts +++ b/test/common/myapp.ts @@ -9,12 +9,12 @@ import { OpenApiValidator } from '../../src'; const app = express(); -app.use(bodyParser.urlencoded()); +app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.text()); app.use(bodyParser.json()); app.use(logger('dev')); -app.use(express.json()); -app.use(express.urlencoded({ extended: false })); +// app.use(express.json()); +// app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); const spec = path.join(__dirname, 'openapi.yaml'); @@ -44,6 +44,13 @@ app.get('/v1/pets/:id', function(req: Request, res: Response): void { res.json({ id: req.params.id, name: 'sparky' }); }); +app.get('/v1/pets/:id/form_urlencoded', function( + req: Request, + res: Response, +): void { + res.json(req.body); +}); + // 2a. Add a route upload file(s) app.post('/v1/pets/:id/photos', function(req: Request, res: Response): void { // DO something with the file diff --git a/test/multipart.disabled.spec.ts b/test/multipart.disabled.spec.ts new file mode 100644 index 00000000..80fe7158 --- /dev/null +++ b/test/multipart.disabled.spec.ts @@ -0,0 +1,112 @@ +import * as express from 'express'; +import * as path from 'path'; +import { expect } from 'chai'; +import * as request from 'supertest'; +import { createApp } from './common/app'; +import * as packageJson from '../package.json'; + +describe(packageJson.name, () => { + let app = null; + before(async () => { + const apiSpec = path.join('test', 'resources', 'multipart.yaml'); + app = await createApp({ apiSpec, fileUploader: false }, 3003, app => + app.use( + `${app.basePath}`, + express + .Router() + .post(`/sample_2`, (req, res) => res.json(req.body)) + .post(`/sample_1`, (req, res) => res.json(req.body)) + .get('/range', (req, res) => res.json(req.body)), + ), + ); + }); + after(() => { + (app).server.close(); + }); + describe(`multipart disabled`, () => { + it('should throw 400 when required multipart file field', async () => + request(app) + .post(`${app.basePath}/sample_2`) + .set('Content-Type', 'multipart/form-data') + .set('Accept', 'application/json') + .expect(400) + .then(e => { + expect(e.body) + .has.property('errors') + .with.length(2); + expect(e.body.errors[0]) + .has.property('message') + .equal("should have required property 'file'"); + expect(e.body.errors[1]) + .has.property('message') + .equal("should have required property 'metadata'"); + })); + + it('should throw 400 when required form field is missing during multipart upload', async () => + request(app) + .post(`${app.basePath}/sample_2`) + .set('Content-Type', 'multipart/form-data') + .set('Accept', 'application/json') + .attach('file', 'package.json') + .expect(400)); + + it('should validate x-www-form-urlencoded form_pa and and form_p2', async () => + request(app) + .post(`${app.basePath}/sample_2`) + .set('Content-Type', 'application/x-www-form-urlencoded') + .set('Accept', 'application/json') + .send('form_p1=stuff&form_p2=morestuff') + .expect(200)); + + // TODO make this work when fileUploader i.e. multer is disabled + it.skip('should return 200 for multipart/form-data with p1 and p2 fields present (with fileUploader false)', async () => + request(app) + .post(`${app.basePath}/sample_1`) + .set('Content-Type', 'multipart/form-data') + .field('p1', 'some data') + .field('p2', 'some data 2') + .expect(200)); + + it('should throw 405 get method not allowed', async () => + request(app) + .get(`${app.basePath}/sample_2`) + .set('Content-Type', 'multipart/form-data') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .attach('file', 'package.json') + .field('metadata', 'some-metadata') + .expect(405)); + + it('should throw 415 unsupported media type', async () => + request(app) + .post(`${app.basePath}/sample_2`) + .send({ test: 'test' }) + .set('Content-Type', 'application/json') + .expect('Content-Type', /json/) + .expect(415) + .then(r => { + expect(r.body) + .has.property('errors') + .with.length(1); + expect(r.body.errors[0]) + .has.property('message') + .equal('unsupported media type application/json'); + })); + + it('should return 400 when improper range specified', async () => + request(app) + .get(`${app.basePath}/range`) + .query({ + number: 2, + }) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(400) + .then(r => { + const e = r.body.errors; + expect(e).to.have.length(1); + expect(e[0].path).to.contain('number'); + expect(e[0].message).to.equal('should be >= 5'); + })); + }); +}); diff --git a/test/multipart.spec.ts b/test/multipart.spec.ts index b024c172..65d58358 100644 --- a/test/multipart.spec.ts +++ b/test/multipart.spec.ts @@ -1,3 +1,4 @@ +import * as express from 'express'; import * as path from 'path'; import { expect } from 'chai'; import * as request from 'supertest'; @@ -7,16 +8,30 @@ import * as packageJson from '../package.json'; describe(packageJson.name, () => { let app = null; before(async () => { - const apiSpec = path.join('test', 'resources', 'openapi.yaml'); - app = await createApp({ apiSpec }, 3003); + const apiSpec = path.join('test', 'resources', 'multipart.yaml'); + app = await createApp({ apiSpec }, 3003, app => + app.use( + `${app.basePath}`, + express + .Router() + .post(`/sample_2`, (req, res) => { + const files = req.files; + res.status(200).json({ + files, + metadata: req.body.metadata, + }); + }) + .post(`/sample_1`, (req, res) => res.json(req.body)), + ), + ); }); after(() => { (app).server.close(); }); - describe(`GET .../pets/:id/photos`, () => { + describe(`multipart`, () => { it('should throw 400 when required multipart file field', async () => request(app) - .post(`${app.basePath}/pets/10/photos`) + .post(`${app.basePath}/sample_2`) .set('Content-Type', 'multipart/form-data') .set('Accept', 'application/json') .expect(400) @@ -31,15 +46,15 @@ describe(packageJson.name, () => { it('should throw 400 when required form field is missing during multipart upload', async () => request(app) - .post(`${app.basePath}/pets/10/photos`) - .set('Content-Type', 'multipart/form-data') + .post(`${app.basePath}/sample_2`) + .set('Content-Type', 'multipart/sample_2') .set('Accept', 'application/json') .attach('file', 'package.json') .expect(400)); it('should validate multipart file and metadata', async () => request(app) - .post(`${app.basePath}/pets/10/photos`) + .post(`${app.basePath}/sample_2`) .set('Content-Type', 'multipart/form-data') .set('Accept', 'application/json') .attach('file', 'package.json') @@ -58,7 +73,7 @@ describe(packageJson.name, () => { it('should throw 405 get method not allowed', async () => request(app) - .get(`${app.basePath}/pets/10/photos`) + .get(`${app.basePath}/sample_2`) .set('Content-Type', 'multipart/form-data') .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -68,7 +83,7 @@ describe(packageJson.name, () => { it('should throw 415 unsupported media type', async () => request(app) - .post(`${app.basePath}/pets/10/photos`) + .post(`${app.basePath}/sample_2`) .send({ test: 'test' }) .set('Content-Type', 'application/json') .expect('Content-Type', /json/) diff --git a/test/resources/multipart.yaml b/test/resources/multipart.yaml new file mode 100644 index 00000000..c1a9770e --- /dev/null +++ b/test/resources/multipart.yaml @@ -0,0 +1,111 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification + termsOfService: http://swagger.io/terms/ + contact: + name: Swagger API Team + email: apiteam@swagger.io + url: http://swagger.io + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +servers: + - url: /v1/ +paths: + /sample_1: + post: + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + required: + - form_p1 + properties: + form_p1: + type: string + form_p2: + type: string + multipart/form-data: + schema: + type: object + required: + - p1 + - p2 + properties: + p1: + type: string + p2: + type: string + responses: + "200": + description: form data + /sample_2: + post: + description: upload a photo of the pet + operationId: formData + requestBody: + content: + multipart/form-data: + schema: + type: object + required: + - file + - metadata + properties: + file: + description: The photo + type: string + format: binary + metadata: + type: string + application/x-www-form-urlencoded: + schema: + type: object + required: + - form_p1 + properties: + form_p1: + type: string + form_p2: + type: string + responses: + "200": + description: form data + /range: + get: + parameters: + - name: tags + in: query + description: tags to filter by + required: false + style: form + schema: + type: array + items: + type: string + - name: number + in: query + required: true + schema: + type: integer + format: int32 + minimum: 5 + maximum: 10 + responses: + "200": + description: form data + +components: + schemas: + NewPhoto: + type: object + required: + - file + properties: + file: + description: The photo + type: string + format: binary diff --git a/test/resources/openapi.yaml b/test/resources/openapi.yaml index 0d61bbeb..43e0c256 100644 --- a/test/resources/openapi.yaml +++ b/test/resources/openapi.yaml @@ -192,6 +192,23 @@ paths: schema: $ref: '#/components/schemas/Error' + # /pets/{id}/form_urlencoded: + # post: + # requestBody: + # content: + # application/x-www-form-urlencoded: + # schema: + # type: object + # required: + # - form_p1 + # properties: + # form_p1: + # type: string + # form_p2: + # type: string + # responses: + # '200': + # description: photo uploaded /pets/{id}/photos: post: description: upload a photo of the pet @@ -219,7 +236,16 @@ paths: format: binary metadata: type: string - # $ref: '#/components/schemas/NewPhoto' + application/x-www-form-urlencoded: + schema: + type: object + required: + - form_p1 + properties: + form_p1: + type: string + form_p2: + type: string responses: '200': description: photo uploaded