style(middleware-multipart): separated file and field parsers
MODIFY
packages/middleware-multipart/src/index.ts
packages/middleware-multipart/src/index.ts
export * from './multipart.middleware';
export * from './multipart.interface';
-export { StreamHandler, ParserOpts } from './multipart.parser';

MODIFY
packages/middleware-multipart/src/multipart.interface.ts
packages/middleware-multipart/src/multipart.interface.ts
+import { Observable } from 'rxjs';
+
export interface File {
destination?: any;
buffer?: Buffer;
@@ -8,6 +10,34 @@ export interface File {
fieldname: string;
}
+export interface FileIncomingData {
+ file: NodeJS.ReadableStream;
+ filename: string;
+ fieldname: string;
+ encoding: string;
+ mimetype: string;
+};
+
export interface WithFile<T extends string = string> {
file: Record<T, File>;
}
+
+interface StreamHandlerOutput {
+ destination: any;
+ size?: number;
+}
+
+export interface StreamHandler {
+ (opts: FileIncomingData):
+ | Promise<StreamHandlerOutput>
+ | Observable<StreamHandlerOutput>;
+}
+
+export interface ParserOpts {
+ files?: string[];
+ stream?: StreamHandler;
+ maxFileSize?: number;
+ maxFileCount?: number;
+ maxFieldSize?: number;
+ maxFieldCount?: number;
+}

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 { mergeMap, map } from 'rxjs/operators';
-import { parseMultipart, ParserOpts } from './multipart.parser';
+import { parseMultipart } from './multipart.parser';
import { shouldParseMultipart } from './multipart.util';
-import { WithFile } from './multipart.interface';
+import { WithFile, ParserOpts } from './multipart.interface';
export const multipart$ = <File extends string>(opts: ParserOpts = {}) => <T extends HttpRequest>(req$: Observable<T>) =>
req$.pipe(

ADD
packages/middleware-multipart/src/multipart.parser.field.ts
+import { HttpRequest } from '@marblejs/core';
+import { Observable } from 'rxjs';
+import { takeUntil, tap } from 'rxjs/operators';
+
+type FieldEvent = [string, any, boolean, boolean, string, string];
+
+export const parseField = (req: HttpRequest) => (event$: Observable<FieldEvent>, finish$: Observable<any>) =>
+ event$.pipe(
+ takeUntil(finish$),
+ tap(([ fieldname, value ]) => {
+ req.body = {
+ ...req.body || {},
+ [fieldname]: value,
+ };
+ }),
+ );

ADD
packages/middleware-multipart/src/multipart.parser.file.ts
+import { HttpRequest, HttpError, HttpStatus } from '@marblejs/core';
+import { isNonNullable } from '@marblejs/core/dist/+internal/utils';
+import { fromReadableStream } from '@marblejs/core/dist/+internal/observable';
+import { fromEvent, Observable, of, throwError, merge, iif } from 'rxjs';
+import { mapTo, map, mergeMap, takeUntil, toArray, tap, mergeMapTo, take, ignoreElements } from 'rxjs/operators';
+import { Readable } from 'stream';
+import { FileIncomingData, ParserOpts } from './multipart.interface';
+import { setRequestData, isFieldnameParseable } from './multipart.util';
+
+type FileEvent = [string, NodeJS.ReadableStream, string, string, string];
+
+const fileSizeLimit = (maxBytes: number | undefined) => (data: FileIncomingData) =>
+ fromEvent(data.file, 'limit').pipe(
+ take(1),
+ mergeMapTo(throwError(
+ new HttpError(`Reached file size limit for "${data.fieldname}" [${maxBytes} bytes]`, HttpStatus.PRECONDITION_FAILED),
+ )),
+ ignoreElements(),
+ );
+
+export const parseFile = (req: HttpRequest) => (opts: ParserOpts) => (event$: Observable<FileEvent>, finish$: Observable<any>) =>
+ event$.pipe(
+ takeUntil(finish$),
+ map(([ fieldname, file, filename, encoding, mimetype ]) => ({ fieldname, file, filename, encoding, mimetype })),
+ mergeMap(data => iif(
+ () => !isFieldnameParseable(opts.files)(data.fieldname),
+ throwError(new HttpError(`File "${data.fieldname}" is not acceptable`, HttpStatus.PRECONDITION_FAILED)),
+ of(data),
+ )),
+ mergeMap(data => merge(fileSizeLimit(opts.maxFileSize)(data), of(data))),
+ mergeMap(data => isNonNullable(opts.stream)
+ ? of(data).pipe(
+ mergeMap(opts.stream),
+ tap(setRequestData(req)(data)),
+ mapTo(data),
+ )
+ : of(data).pipe(
+ map(data => data.file as Readable),
+ mergeMap(fromReadableStream),
+ toArray(),
+ map(chunks => ({ buffer: Buffer.concat(chunks) })),
+ map(({ buffer }) => ({ buffer, size: Buffer.byteLength(buffer) })),
+ tap(setRequestData(req)(data)),
+ mapTo(data),
+ ),
+ ),
+ );

MODIFY
packages/middleware-multipart/src/multipart.parser.ts
packages/middleware-multipart/src/multipart.parser.ts
-import { HttpRequest, HttpError, HttpStatus } from '@marblejs/core';
-import { isNonNullable } from '@marblejs/core/dist/+internal/utils';
-import { fromReadableStream } from '@marblejs/core/dist/+internal/observable';
-import { fromEvent, Observable, of, throwError, merge, iif } from 'rxjs';
-import { mapTo, map, mergeMap, takeUntil, toArray, tap, catchError, buffer, mergeMapTo, take, ignoreElements } from 'rxjs/operators';
import * as Busboy from 'busboy';
-import { Readable } from 'stream';
-
-type FileEvent = [string, NodeJS.ReadableStream, string, string, string];
-
-type FieldEvent = [string, any, boolean, boolean, string, string];
-
-type FileData = {
- file: NodeJS.ReadableStream;
- filename: string;
- fieldname: string;
- encoding: string;
- mimetype: string;
-};
-
-interface StreamHandlerOutput {
- destination: any;
- size?: number;
-}
-
-export interface StreamHandler {
- (opts: FileData):
- | Promise<StreamHandlerOutput>
- | Observable<StreamHandlerOutput>;
-}
-
-export interface ParserOpts {
- files?: string[];
- stream?: StreamHandler;
- maxFileSize?: number;
- maxFileCount?: number;
-}
-
-const fileSizeLimit = (data: FileData, maxBytes: number | undefined) =>
- fromEvent(data.file, 'limit').pipe(
- take(1),
- mergeMapTo(throwError(
- new HttpError(`Reached file size limit for "${data.fieldname}" [${maxBytes} bytes]`, HttpStatus.PRECONDITION_FAILED),
- )),
- ignoreElements(),
- );
-
-const parseFile = (req: HttpRequest) => (opts: ParserOpts) => (event$: Observable<FileEvent>, finish$: Observable<any>) =>
- event$.pipe(
- takeUntil(finish$),
- map(([ fieldname, file, filename, encoding, mimetype ]) => ({ fieldname, file, filename, encoding, mimetype })),
- mergeMap(data => iif(
- () => !!opts.files ? !opts.files.includes(data.fieldname) : false,
- throwError(new HttpError(`File "${data.fieldname}" is not acceptable`, HttpStatus.PRECONDITION_FAILED)),
- of(data),
- )),
- mergeMap(data => merge(fileSizeLimit(data, opts.maxFileSize), of(data))),
- mergeMap(data => isNonNullable(opts.stream)
- ? of(data).pipe(
- mergeMap(opts.stream),
- catchError(error => throwError(new HttpError(error.message, HttpStatus.INTERNAL_SERVER_ERROR))),
- tap(({ destination, size }) => {
- req.file = {
- ...req.file || {},
- [data.fieldname]: {
- size,
- destination,
- encoding: data.encoding,
- mimetype: data.mimetype,
- filename: data.filename,
- fieldname: data.fieldname,
- },
- };
- }),
- mapTo(data),
- )
- : of(data.file as Readable).pipe(
- mergeMap(fromReadableStream),
- toArray(),
- map(chunks => Buffer.concat(chunks)),
- catchError(error => throwError(new HttpError(error.message, HttpStatus.INTERNAL_SERVER_ERROR))),
- tap(buffer => {
- req.file = {
- ...req.file || {},
- [data.fieldname]: {
- size: Buffer.byteLength(buffer),
- buffer,
- encoding: data.encoding,
- mimetype: data.mimetype,
- filename: data.filename,
- fieldname: data.fieldname,
- },
- };
- }),
- mapTo(data),
- ),
- ),
- );
-
-const parseField = (req: HttpRequest) => (event$: Observable<FieldEvent>, finish$: Observable<any>) =>
- event$.pipe(
- takeUntil(finish$),
- tap(([ fieldname, value ]) => {
- req.body = {
- ...req.body || {},
- [fieldname]: value,
- };
- }),
- );
+import { HttpRequest, HttpError, HttpStatus } from '@marblejs/core';
+import { fromEvent, Observable, of, throwError, merge } from 'rxjs';
+import { mapTo, mergeMap, tap, buffer, mergeMapTo } from 'rxjs/operators';
+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> => {
const busboy = new Busboy({
@@ -114,6 +12,8 @@ export const parseMultipart = (opts: ParserOpts = {}) => (req: HttpRequest): Obs
limits: {
fileSize: opts.maxFileSize,
files: opts.maxFileCount,
+ fields: opts.maxFieldCount,
+ fieldSize: opts.maxFieldSize,
},
});
@@ -133,9 +33,20 @@ export const parseMultipart = (opts: ParserOpts = {}) => (req: HttpRequest): Obs
)),
);
+ const fieldsLimit$ = fromEvent(busboy, 'fieldsLimit').pipe(
+ mergeMap(() => throwError(
+ new HttpError(`Reached max fields count limit [${opts.maxFieldCount}]`, HttpStatus.PRECONDITION_FAILED),
+ )),
+ );
+
return of(req).pipe(
tap(() => req.pipe(busboy)),
- mergeMapTo(merge(parseFile$, parseField$, filesLimit$)),
+ mergeMapTo(merge(
+ parseFile$,
+ parseField$,
+ filesLimit$,
+ fieldsLimit$,
+ )),
buffer(fromEvent(busboy, 'finish')),
mapTo(req),
);

MODIFY
packages/middleware-multipart/src/multipart.util.ts
packages/middleware-multipart/src/multipart.util.ts
import { HttpRequest } from '@marblejs/core';
import { getContentType } from '@marblejs/core/dist/+internal/http';
+import { FileIncomingData } from './multipart.interface';
-const isProperMethod = (req: HttpRequest): boolean =>
+type ComputedFileData = Partial<{ buffer: Buffer; destination: any; size: number }>;
+
+export const isProperMethod = (req: HttpRequest): boolean =>
['POST', 'PUT'].includes(req.method);
-const isMultipart = (req: HttpRequest): boolean =>
+export const isMultipart = (req: HttpRequest): boolean =>
getContentType(req.headers).includes('multipart/');
+export const isFieldnameParseable = (files: string[] | undefined) => (fieldname: string) =>
+ !!files ? files.includes(fieldname) : true;
+
export const shouldParseMultipart = (req: HttpRequest) =>
isProperMethod(req) && isMultipart(req);
+
+export const setRequestData =
+ (req: HttpRequest) =>
+ (incomingFile: FileIncomingData) =>
+ ({ buffer, destination, size }: ComputedFileData) => {
+ req.file = {
+ ...req.file || {},
+ [incomingFile.fieldname]: {
+ size,
+ buffer,
+ destination,
+ encoding: incomingFile.encoding,
+ mimetype: incomingFile.mimetype,
+ filename: incomingFile.filename,
+ fieldname: incomingFile.fieldname,
+ },
+ };
+}

style(middleware-multipart): separated file and field parsers