Skip to content

Commit

Permalink
optional file upload
Browse files Browse the repository at this point in the history
  • Loading branch information
Carmine DiMascio committed Dec 30, 2019
1 parent 17dadc2 commit 81243f8
Show file tree
Hide file tree
Showing 12 changed files with 334 additions and 43 deletions.
4 changes: 3 additions & 1 deletion src/framework/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as ajv from 'ajv';
import * as multer from 'multer';
import { Request, Response, NextFunction } from 'express';
export { OpenAPIFrameworkArgs };

Expand Down Expand Up @@ -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';
};
Expand Down
23 changes: 22 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -243,5 +260,9 @@ export class OpenApiValidator {
};
delete options.securityHandlers;
}
if (options.multerOpts) {
options.fileUploader = options.multerOpts;
delete options.multerOpts;
}
}
}
28 changes: 13 additions & 15 deletions src/middlewares/openapi.multipart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const multer = require('multer');

export function multipart(
OpenApiContext: OpenApiContext,
multerOpts: {} = {},
multerOpts: {},
): OpenApiRequestHandler {
const mult = multer(multerOpts);
return (req, res, next) => {
Expand All @@ -35,7 +35,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 = (<Express.Multer.File[]>req.files)
Expand All @@ -46,18 +45,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();
}
Expand All @@ -74,7 +70,9 @@ function isValidContentType(req: Request): boolean {
}

function isMultipart(req: OpenApiRequest): boolean {
return (<any>req?.openapi)?.schema?.requestBody?.content?.['multipart/form-data'];
return (<any>req?.openapi)?.schema?.requestBody?.content?.[
'multipart/form-data'
];
}

function error(req: OpenApiRequest, err: Error): ValidationError {
Expand Down
4 changes: 2 additions & 2 deletions src/middlewares/parsers/body.parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/middlewares/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 9 additions & 9 deletions test/common/app.common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion test/common/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')));
Expand Down
13 changes: 10 additions & 3 deletions test/common/myapp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions test/multipart.disabled.spec.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
(<any>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 fileUpload i.e. multer is disabled
it.skip('should return 200 for multipart/form-data with p1 and p2 fields present (with fileUpload 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');
}));
});
});
33 changes: 24 additions & 9 deletions test/multipart.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as express from 'express';
import * as path from 'path';
import { expect } from 'chai';
import * as request from 'supertest';
Expand All @@ -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(() => {
(<any>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)
Expand All @@ -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')
Expand All @@ -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/)
Expand All @@ -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/)
Expand Down
Loading

0 comments on commit 81243f8

Please sign in to comment.