Skip to content

Commit

Permalink
feat: support multipart/form-data content type
Browse files Browse the repository at this point in the history
  • Loading branch information
yannick-bonnefond authored and ybonnefond committed Mar 8, 2023
1 parent c60f641 commit cea8793
Show file tree
Hide file tree
Showing 7 changed files with 714 additions and 510 deletions.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@
"@types/hapi__accept": "^5.0.0",
"@types/jest": "^29.4.0",
"@types/lodash": "^4.14.191",
"@types/multiparty": "^0.0.33",
"@types/node": "^18.14.6",
"axios": "^1.3.4",
"form-data": "^4.0.0",
"husky": "^8.0.3",
"jest": "^29.5.0",
"jest-diff": "^29.5.0",
Expand All @@ -78,7 +81,8 @@
"body-parser": "^1.20.2",
"chalk": "^4.1.0",
"content-type": "^1.0.5",
"lodash": "^4.17.15"
"lodash": "^4.17.15",
"multiparty": "^4.2.3"
},
"commitlint": {
"extends": [
Expand Down
1 change: 1 addition & 0 deletions src/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ function buildMiddlewares(router: Router) {
middlewares.urlParser({ host: router.getHost(), port: router.getPort() }),
middlewares.bodyJson(),
middlewares.bodyUrlEncoded(),
middlewares.bodyMultipartFormData(),
middlewares.bodyRaw(),
middlewares.bodyEmpty(),
];
Expand Down
55 changes: 55 additions & 0 deletions src/middlewares/bodyMultipartFormData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Form } from 'multiparty';
import { JsonObject, NextFunction, Request, Response } from '../@types';
import ReadableStream = NodeJS.ReadableStream;
/**
* @internal
*/
export function bodyMultipartFormData() {
return (req: Request, _res: Response, next: NextFunction) => {
if (!/^multipart\/form-data/.test(req.headers['content-type'] || '')) {
return next();
}

(req as Request & { _body: boolean })._body = true;

const form = new Form({
autoFields: false,
autoFiles: false,
});

req.body = {} as JsonObject;

form.on('error', err => {
next(err);
});

// Parts are emitted when parsing the form
form.on('part', async part => {
const content = await streamToString(part);

(req.body as JsonObject)[part.name] =
part.filename === undefined
? content
: {
content,
filename: part.filename,
};
});

// Close emitted after form parsed
form.on('close', () => {
next();
});

form.parse(req);
};
}

function streamToString(stream: ReadableStream): Promise<string> {
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
stream.on('data', (chunk: ArrayBuffer) => chunks.push(Buffer.from(chunk)));
stream.on('error', (err: Error) => reject(err));
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
}
2 changes: 2 additions & 0 deletions src/middlewares/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { bodyJson } from './bodyJson';
import { bodyRaw } from './bodyRaw';
import { bodyUrlEncoded } from './bodyUrlEncoded';
import { urlParser } from './urlParser';
import { bodyMultipartFormData } from './bodyMultipartFormData';
/**
* @internal
*/
Expand All @@ -12,4 +13,5 @@ export const middlewares = {
bodyUrlEncoded,
bodyRaw,
urlParser,
bodyMultipartFormData,
};
4 changes: 3 additions & 1 deletion test/helpers/httpClient.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import FormData from 'form-data';
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import { Stubborn } from '../../src';

Expand All @@ -6,7 +7,7 @@ export interface HttpClientRequest {
headers?: Record<string, string>;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
responseType?: 'json' | 'text' | 'arraybuffer';
data?: string | Record<string, unknown>;
data?: string | Record<string, unknown> | FormData;
query?: Record<string, string> | URLSearchParams;
}

Expand All @@ -33,6 +34,7 @@ export class HttpClient {
data: req.data || '',
responseType: req.responseType || 'json',
params: req.query,
maxRedirects: 0,
headers: {
accept: 'application/json',
'content-type': null,
Expand Down
66 changes: 66 additions & 0 deletions test/specs/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Readable } from 'stream';
import FormData from 'form-data';
import {
EVENTS,
JsonValue,
Expand Down Expand Up @@ -646,6 +648,70 @@ describe('index', () => {
).toReplyWith({ status: STATUS_CODES.SUCCESS });
});
});

describe('multipart/form-data', () => {
it('should return SUCCESS with a form field', async () => {
const form = new FormData();
form.append('field1', 'value1');

sb.post('/', { field1: 'value1' }).setHeaders({
...form.getHeaders(),
});

expect(
await httpClient.request({
method: 'POST',
data: form,
headers: { ...form.getHeaders() },
}),
).toReplyWith({ status: STATUS_CODES.SUCCESS });
});

it('should return SUCCESS with a form field with stream', async () => {
const form = new FormData();
form.append('stream1', Readable.from(Buffer.from('Hello')));

sb.post('/', { stream1: 'Hello' })
.setHeaders({
...form.getHeaders(),
'transfer-encoding': 'chunked',
})
.logDiffOn501();

expect(
await httpClient.request({
method: 'POST',
data: form,
headers: { ...form.getHeaders() },
}),
).toReplyWith({ status: STATUS_CODES.SUCCESS });
});

it('should return SUCCESS with a form field with streamed file', async () => {
const form = new FormData();
form.append('file', Readable.from(Buffer.from('Hello')), {
filename: 'file.txt',
});

sb.post('/', {
file: {
filename: 'file.txt',
content: 'Hello',
},
}).setHeaders({
...form.getHeaders(),
'transfer-encoding': 'chunked',
});

expect(
await httpClient.request({
method: 'POST',
data: form,
headers: { ...form.getHeaders() },
}),
).toReplyWith({ status: STATUS_CODES.SUCCESS });
});
});
});
});

Expand Down
Loading

0 comments on commit cea8793

Please sign in to comment.