Skip to content

Commit

Permalink
feat: add multipart body-parsers
Browse files Browse the repository at this point in the history
  • Loading branch information
Lordfirespeed committed Aug 20, 2024
1 parent 40ad14c commit 48c09be
Show file tree
Hide file tree
Showing 3 changed files with 134 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/content-types/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from './custom'
export * from './json'
export * from "./multipart-form-data"
export * from './raw'
export * from "./raw-multipart"
export * from './text'
export * from './urlencoded'
66 changes: 66 additions & 0 deletions src/content-types/multipart-form-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ContentType } from '@otterhttp/content-type'
import { ClientError, HttpError } from '@otterhttp/errors'

import { type RawReadOptions, type ReadOptions, getRawRead } from '@/get-read'
import { type ParsedMultipartFormData, parseMultipartFormData } from '@/parsers/multipart-form-data'
import type { HasBody, MaybeParsed, NextFunction, Request, Response } from '@/types'
import { alreadyParsed } from '@/utils/already-parsed-symbol'
import { hasNoBody } from '@/utils/has-no-body'
import { typeChecker } from '@/utils/type-checker'

export type MultipartFormDataBodyParsingOptions<
Req extends Request & HasBody<ParsedMultipartFormData> = Request & HasBody<ParsedMultipartFormData>,
Res extends Response<Req> = Response<Req>,
> = RawReadOptions & {
/**
* Matcher used to determine which requests the middleware should body-parse.
*
* The default matcher will match requests with Content-Type `multipart/form-data`, `multipart/foobar+form-data`, etc.
*
* @default typeChecker("multipart/*+form-data")
*/
matcher?: (req: Req, res: Res) => boolean
}

export function multipartFormData<
Req extends Request & HasBody<ParsedMultipartFormData> = Request & HasBody<ParsedMultipartFormData>,
Res extends Response<Req> = Response<Req>,
>(options?: MultipartFormDataBodyParsingOptions<Req, Res>) {
const optionsCopy: ReadOptions = Object.assign({}, options)
optionsCopy.limit ??= '10mb'
optionsCopy.inflate ??= true

const matcher = options?.matcher ?? typeChecker(ContentType.parse('multipart/*+form-data'))

function failBoundaryParameter(): never {
throw new ClientError("multipart body cannot be parsed without 'boundary' parameter", {
statusCode: 400,
code: 'ERR_MULTIPART_BOUNDARY_REQUIRED',
})
}

const rawRead = getRawRead(options)
return async (req: Req & MaybeParsed, res: Res, next: NextFunction) => {
if (req[alreadyParsed] === true) return next()
if (hasNoBody(req.method)) return next()
if (!matcher(req, res)) return next()

if (req.headers['content-type'] == null) failBoundaryParameter()
const contentType = ContentType.parse(req.headers['content-type'])
if (!Object.hasOwn(contentType.parameters, 'boundary')) failBoundaryParameter()
const boundary = contentType.parameters.boundary

const rawBody = await rawRead(req, res)

try {
req.body = parseMultipartFormData(rawBody, boundary)
} catch (err) {
if (err instanceof HttpError) throw err
throw new ClientError('Multipart body parsing failed', {
statusCode: 400,
code: 'ERR_MULTIPART_PARSE_FAILED',
cause: err instanceof Error ? err : undefined,
})
}
}
}
66 changes: 66 additions & 0 deletions src/content-types/raw-multipart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ContentType } from '@otterhttp/content-type'
import { ClientError, HttpError } from '@otterhttp/errors'

import { type RawReadOptions, type ReadOptions, getRawRead } from '@/get-read'
import { type ParsedMultipartData, parseMultipart } from '@/parsers/multipart'
import type { HasBody, MaybeParsed, NextFunction, Request, Response } from '@/types'
import { alreadyParsed } from '@/utils/already-parsed-symbol'
import { hasNoBody } from '@/utils/has-no-body'
import { typeChecker } from '@/utils/type-checker'

export type RawMultipartBodyParsingOptions<
Req extends Request & HasBody<ParsedMultipartData> = Request & HasBody<ParsedMultipartData>,
Res extends Response<Req> = Response<Req>,
> = RawReadOptions & {
/**
* Matcher used to determine which requests the middleware should body-parse.
*
* The default matcher will match requests with Content-Type `multipart/mixed`, `multipart/foobar+form-data`, etc.
*
* @default typeChecker("multipart/*")
*/
matcher?: (req: Req, res: Res) => boolean
}

export function rawMultipart<
Req extends Request & HasBody<ParsedMultipartData> = Request & HasBody<ParsedMultipartData>,
Res extends Response<Req> = Response<Req>,
>(options?: RawMultipartBodyParsingOptions<Req, Res>) {
const optionsCopy: ReadOptions = Object.assign({}, options)
optionsCopy.limit ??= '10mb'
optionsCopy.inflate ??= true

const matcher = options?.matcher ?? typeChecker(ContentType.parse('multipart/*'))

function failBoundaryParameter(): never {
throw new ClientError("multipart body cannot be parsed without 'boundary' parameter", {
statusCode: 400,
code: 'ERR_MULTIPART_BOUNDARY_REQUIRED',
})
}

const rawRead = getRawRead(options)
return async (req: Req & MaybeParsed, res: Res, next: NextFunction) => {
if (req[alreadyParsed] === true) return next()
if (hasNoBody(req.method)) return next()
if (!matcher(req, res)) return next()

if (req.headers['content-type'] == null) failBoundaryParameter()
const contentType = ContentType.parse(req.headers['content-type'])
if (!Object.hasOwn(contentType.parameters, 'boundary')) failBoundaryParameter()
const boundary = contentType.parameters.boundary

const rawBody = await rawRead(req, res)

try {
req.body = parseMultipart(rawBody, boundary)
} catch (err) {
if (err instanceof HttpError) throw err
throw new ClientError('Multipart body parsing failed', {
statusCode: 400,
code: 'ERR_MULTIPART_PARSE_FAILED',
cause: err instanceof Error ? err : undefined,
})
}
}
}

0 comments on commit 48c09be

Please sign in to comment.