test(middleware-multipart): added test cases for filesystem + error handling
MODIFY
packages/@integration/src/effects/static.effects.ts
packages/@integration/src/effects/static.effects.ts
@@ -2,24 +2,14 @@ import * as fs from 'fs';
import * as path from 'path';
import { r, combineRoutes, use } from '@marblejs/core';
import { requestValidator$, t } from '@marblejs/middleware-io';
-import { multipart$, StreamHandler } from '@marblejs/middleware-multipart';
+import { multipart$ } from '@marblejs/middleware-multipart';
+import { streamFileTo } from '@marblejs/middleware-multipart/dist/multipart.util';
import { readFile } from '@marblejs/core/dist/+internal';
-import { of } from 'rxjs';
import { map, mergeMap } from 'rxjs/operators';
const STATIC_PATH = path.resolve(__dirname, '../../../../assets');
const TMP_PATH = path.resolve(__dirname, '../../../../tmp');
-const streamFileTo = (basePath: string): StreamHandler => {
- if (!fs.existsSync(basePath)) { fs.mkdirSync(basePath); }
-
- return ({ file, fieldname }) => {
- const destination = path.join(basePath, path.basename(fieldname));
- file.pipe(fs.createWriteStream(destination));
- return of({ destination });
- };
-}
-
const getFileValidator$ = requestValidator$({
params: t.type({ dir: t.string })
});

MODIFY
packages/middleware-multipart/src/multipart.interface.ts
packages/middleware-multipart/src/multipart.interface.ts
@@ -28,9 +28,7 @@ interface StreamHandlerOutput {
}
export interface StreamHandler {
- (opts: FileIncomingData):
- | Promise<StreamHandlerOutput>
- | Observable<StreamHandlerOutput>;
+ (opts: FileIncomingData): Promise<StreamHandlerOutput> | Observable<StreamHandlerOutput>;
}
export interface ParserOpts {

MODIFY
packages/middleware-multipart/src/multipart.middleware.ts
packages/middleware-multipart/src/multipart.middleware.ts
-import { HttpRequest } from '@marblejs/core';
-import { of, Observable } from 'rxjs';
+import { HttpRequest, HttpError, HttpStatus } from '@marblejs/core';
+import { Observable, throwError } from 'rxjs';
import { mergeMap, map } from 'rxjs/operators';
import { parseMultipart } from './multipart.parser';
import { shouldParseMultipart } from './multipart.util';
import { WithFile, ParserOpts } from './multipart.interface';
+import { ContentType } from '@marblejs/core/dist/+internal/http';
export const multipart$ = <File extends string>(opts: ParserOpts = {}) => <T extends HttpRequest>(req$: Observable<T>) =>
req$.pipe(
mergeMap(req => shouldParseMultipart(req)
? parseMultipart(opts)(req)
- : of(req)),
+ : throwError(
+ new HttpError(`Content-Type must be of type ${ContentType.MULTIPART_FORM_DATA}`, HttpStatus.PRECONDITION_FAILED)
+ )),
map(req => req as T & WithFile<File>),
);

MODIFY
packages/middleware-multipart/src/multipart.parser.ts
packages/middleware-multipart/src/multipart.parser.ts
@@ -6,7 +6,7 @@ import { ParserOpts } from './multipart.interface';
import { parseField } from './multipart.parser.field';
import { parseFile } from './multipart.parser.file';
-export const parseMultipart = (opts: ParserOpts = {}) => (req: HttpRequest): Observable<HttpRequest> => {
+export const parseMultipart = (opts: ParserOpts) => (req: HttpRequest): Observable<HttpRequest> => {
const busboy = new Busboy({
headers: req.headers,
limits: {
@@ -40,7 +40,7 @@ export const parseMultipart = (opts: ParserOpts = {}) => (req: HttpRequest): Obs
);
return of(req).pipe(
- tap(() => req.pipe(busboy)),
+ tap(req => req.pipe(busboy)),
mergeMapTo(merge(
parseFile$,
parseField$,

MODIFY
packages/middleware-multipart/src/multipart.util.ts
packages/middleware-multipart/src/multipart.util.ts
+import * as fs from 'fs';
+import * as path from 'path';
+import { of } from 'rxjs';
import { HttpRequest } from '@marblejs/core';
import { getContentType } from '@marblejs/core/dist/+internal/http';
-import { FileIncomingData } from './multipart.interface';
+import { FileIncomingData, StreamHandler } from './multipart.interface';
type ComputedFileData = Partial<{ buffer: Buffer; destination: any; size: number }>;
@@ -33,3 +36,13 @@ export const setRequestData =
},
};
}
+
+export const streamFileTo = (basePath: string): StreamHandler => {
+ if (!fs.existsSync(basePath)) { fs.mkdirSync(basePath); }
+
+ return ({ file, fieldname }) => {
+ const destination = path.join(basePath, path.basename(fieldname));
+ file.pipe(fs.createWriteStream(destination));
+ return of({ destination });
+ };
+}

MODIFY
packages/middleware-multipart/src/specs/multipart.middleware.spec.ts
packages/middleware-multipart/src/specs/multipart.middleware.spec.ts
+import * as fs from 'fs';
+import * as path from 'path';
import * as request from 'supertest';
+import { map } from 'rxjs/operators';
import { r, use, httpListener, createContext } from '@marblejs/core';
import { multipart$ } from '../multipart.middleware';
-import { map } from 'rxjs/operators';
+import { streamFileTo } from '../multipart.util';
-const effect$ = r.pipe(
- r.matchPath('/'),
+const TMP_PATH = path.resolve(__dirname, '../../../../tmp');
+
+const memoryMultipart$ = r.pipe(
+ r.matchPath('/memory'),
r.matchType('POST'),
r.useEffect(req$ => req$.pipe(
use(multipart$()),
@@ -14,14 +19,48 @@ const effect$ = r.pipe(
}}))
)));
-const app = httpListener({ effects: [effect$] }).run(createContext());
+const memoryWithOptionsMultipart$ = r.pipe(
+ r.matchPath('/memory-with-options'),
+ r.matchType('POST'),
+ r.useEffect(req$ => req$.pipe(
+ use(multipart$({
+ maxFileCount: 1,
+ maxFieldCount: 2,
+ maxFileSize: 20,
+ })),
+ map(req => ({ body: {
+ file: req.file,
+ fields: req.body,
+ }}))
+ )));
+
+const filesystemMultipart$ = r.pipe(
+ r.matchPath('/filesystem'),
+ r.matchType('POST'),
+ r.useEffect(req$ => req$.pipe(
+ use(multipart$({
+ stream: streamFileTo(TMP_PATH),
+ })),
+ map(req => ({ body: {
+ file: req.file,
+ fields: req.body,
+ }}))
+ )));
+
+const app = httpListener({
+ effects: [memoryMultipart$, memoryWithOptionsMultipart$, filesystemMultipart$],
+}).run(createContext());
+
+afterEach(() => {
+ if (fs.existsSync(TMP_PATH)) { fs.rmdirSync(TMP_PATH); }
+});
describe('multipart$', () => {
- test('parses multipart/form-data request and stores it in-memory', async () => {
+ test('parses multipart/form-data request with additional fields and stores it in-memory', async () => {
const data = Buffer.from('test_buffer_1');
return request(app)
- .post('/')
+ .post('/memory')
.field('field_1', 'test_1')
.field('field_2', 'test_2')
.attach('data_field', data)
@@ -41,4 +80,94 @@ describe('multipart$', () => {
expect(body.fields.field_2).toEqual('test_2');
});
});
+
+ test('parses multipart/form-data request with additional fields and stores it filesystem', async () => {
+ const uploadFilePath = path.resolve(__dirname, '../../README.md');
+ const savedFilePath = path.resolve(TMP_PATH, 'data_field');
+ const file = fs.readFileSync(uploadFilePath);
+
+ return request(app)
+ .post('/filesystem')
+ .field('field_1', 'test_1')
+ .field('field_2', 'test_2')
+ .attach('data_field', file, { filename: 'README.md' })
+ .expect(200)
+ .then(({ body }) => {
+ // data_field
+ expect(body.file.data_field.size).toBeUndefined();
+ expect(body.file.data_field.destination).toEqual(savedFilePath);
+ expect(body.file.data_field.filename).toEqual('README.md');
+ expect(body.file.data_field.encoding).toEqual('7bit');
+ expect(body.file.data_field.mimetype).toEqual('text/markdown');
+ expect(body.file.data_field.fieldname).toEqual('data_field');
+ expect(body.file.data_field.buffer).toBeUndefined();
+ expect(fs.readFileSync(savedFilePath)).toBeDefined();
+
+ // field_1, field_2
+ expect(body.fields.field_1).toEqual('test_1');
+ expect(body.fields.field_2).toEqual('test_2');
+
+ fs.unlinkSync(savedFilePath);
+ });
+ });
+
+ test('throws an error if incoming request is not multipart/form-data', async () =>
+ request(app)
+ .post('/memory')
+ .send({ test: 'test' })
+ .expect(412)
+ .expect({
+ error: {
+ status: 412,
+ message: 'Content-Type must be of type multipart/form-data',
+ }
+ }),
+ );
+
+ test('throws an error if max files count limit is reached', async () => {
+ const data1 = Buffer.from('test_buffer_1');
+ const data2 = Buffer.from('test_buffer_2');
+
+ return request(app)
+ .post('/memory-with-options')
+ .attach('data_field_1', data1)
+ .attach('data_field_2', data2)
+ .expect(412)
+ .expect({
+ error: {
+ status: 412,
+ message: 'Reached max files count limit [1]',
+ }
+ });
+ });
+
+ test('throws an error if max fields count limit is reached', async () => {
+ return request(app)
+ .post('/memory-with-options')
+ .field('field_1', 'test_1')
+ .field('field_2', 'test_2')
+ .field('field_3', 'test_3')
+ .expect(412)
+ .expect({
+ error: {
+ status: 412,
+ message: 'Reached max fields count limit [2]',
+ }
+ });
+ });
+
+ test('throws an error if max file size limit is reached', async () => {
+ const data = Buffer.from(Array(100).fill(0));
+
+ return request(app)
+ .post('/memory-with-options')
+ .attach('data_field', data)
+ .expect(412)
+ .expect({
+ error: {
+ status: 412,
+ message: 'Reached file size limit for "data_field" [20 bytes]',
+ }
+ });
+ });
});

MODIFY
packages/middleware-multipart/src/specs/multipart.util.spec.ts
packages/middleware-multipart/src/specs/multipart.util.spec.ts
+import * as fs from 'fs';
+import * as path from 'path';
import { createHttpRequest } from '@marblejs/core/dist/+internal/testing';
-import { shouldParseFieldname, shouldParseMultipart, setRequestData } from '../multipart.util';
+import { shouldParseFieldname, shouldParseMultipart, setRequestData, streamFileTo } from '../multipart.util';
import { FileIncomingData } from '../multipart.interface';
+import { from } from 'rxjs';
test('#shouldParseFieldname checks if given fieldname can be parsed by middleware', () => {
expect(shouldParseFieldname(undefined)('test')).toEqual(true);
@@ -66,3 +69,66 @@ test('#setRequestData sets file related request data', () => {
},
})
});
+
+describe('#streamFileTo', async () => {
+ const TMP_PATH = path.resolve(__dirname, '../../../../tmp');
+ const uploadFilePath = path.resolve(__dirname, '../../README.md');
+ const savedFilePath = path.resolve(TMP_PATH, 'data_field');
+ const file = fs.createReadStream(uploadFilePath) as NodeJS.ReadableStream;
+
+ afterEach(() => fs.rmdirSync(TMP_PATH));
+
+ test('streams file to given path ', async () => {
+ if (fs.existsSync(TMP_PATH)) { fs.rmdirSync(TMP_PATH); }
+
+ const response = await from(streamFileTo(TMP_PATH)({
+ file,
+ fieldname: 'data_field',
+ filename: 'README.md',
+ mimetype: 'text/markdown',
+ encoding: '7bit',
+ })).toPromise();
+
+ expect(response.destination).toEqual(savedFilePath);
+ expect(fs.readFileSync(savedFilePath)).toBeDefined();
+
+ fs.unlinkSync(savedFilePath);
+ });
+
+ test('streams file to given path ', async () => {
+ const response = await from(streamFileTo(TMP_PATH)({
+ file,
+ fieldname: 'data_field',
+ filename: 'README.md',
+ mimetype: 'text/markdown',
+ encoding: '7bit',
+ })).toPromise();
+
+ expect(response.destination).toEqual(savedFilePath);
+ expect(fs.readFileSync(savedFilePath)).toBeDefined();
+
+ fs.unlinkSync(savedFilePath);
+ });
+
+ test('creates folder if it doesn not exist ', async () => {
+ // given
+ if (fs.existsSync(TMP_PATH)) { fs.rmdirSync(TMP_PATH); }
+
+ // when
+ streamFileTo(TMP_PATH);
+
+ // then
+ expect(fs.existsSync(TMP_PATH)).toEqual(true);
+ });
+
+ test('does not create folder if exists ', async () => {
+ // given
+ if (!fs.existsSync(TMP_PATH)) { fs.mkdirSync(TMP_PATH); }
+
+ // when
+ streamFileTo(TMP_PATH);
+
+ // then
+ expect(fs.existsSync(TMP_PATH)).toEqual(true);
+ });
+});

test(middleware-multipart): added test cases for filesystem + error handling