forked from tinyhttp/milliparsec
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: basic
multipart/form-data
parser
- Loading branch information
1 parent
d88d842
commit 115d085
Showing
2 changed files
with
230 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
import { ClientError } from '@otterhttp/errors' | ||
|
||
import type { ParsedHeaders } from '@/parsers/http-headers' | ||
import { type ParsedMultipartDataPart, parseMultipart } from '@/parsers/multipart' | ||
|
||
export type ParsedFormFieldValue = { | ||
fieldName: string | ||
type: 'field-value' | ||
value: { | ||
headers: ParsedHeaders | ||
content: Buffer | ||
} | ||
} | ||
|
||
export type ParsedFormFieldFile = { | ||
filename: string | ||
headers: ParsedHeaders | ||
content: Buffer | ||
} | ||
|
||
export type ParsedFormFieldFileList = { | ||
fieldName: string | ||
type: 'field-file-list' | ||
files: ParsedFormFieldFile[] | ||
} | ||
|
||
export type ParsedMultipartFormData = Record<string, ParsedFormFieldValue | ParsedFormFieldFileList> | ||
|
||
export function isParsedFormFieldValue( | ||
value: ParsedFormFieldValue | ParsedFormFieldFileList, | ||
): value is ParsedFormFieldValue { | ||
return value.type === 'field-value' | ||
} | ||
|
||
export function isParsedFormFieldFileList( | ||
value: ParsedFormFieldValue | ParsedFormFieldFileList, | ||
): value is ParsedFormFieldFileList { | ||
return value.type === 'field-file-list' | ||
} | ||
|
||
function getFilesFromMixed(mixedContent: Buffer, boundary: string): ParsedFormFieldFile[] { | ||
const parts = parseMultipart(mixedContent, boundary) | ||
const files: ParsedFormFieldFile[] = [] | ||
|
||
for (const part of parts) { | ||
const contentDisposition = part.headers['content-disposition'] | ||
if (contentDisposition != null && Object.hasOwn(contentDisposition.parameters, 'filename')) { | ||
files.push({ | ||
filename: contentDisposition.parameters.filename, | ||
headers: part.headers, | ||
content: part.content, | ||
}) | ||
continue | ||
} | ||
|
||
const contentType = part.headers['content-type'] | ||
if (contentType != null && Object.hasOwn(contentType.parameters, 'name')) { | ||
files.push({ | ||
filename: contentType.parameters.name, | ||
headers: part.headers, | ||
content: part.content, | ||
}) | ||
continue | ||
} | ||
|
||
// ignore any parts with no clear filename specified | ||
void undefined | ||
} | ||
|
||
return files | ||
} | ||
|
||
function addFormData(part: ParsedMultipartDataPart, dest: ParsedMultipartFormData): void { | ||
function fail(): never { | ||
throw new ClientError('Invalid multipart form-data', { | ||
code: 'ERR_INVALID_FORM_DATA', | ||
}) | ||
} | ||
|
||
const contentDisposition = part.headers['content-disposition'] | ||
if (contentDisposition == null) fail() | ||
if (contentDisposition.type !== 'form-data') fail() | ||
if (!Object.hasOwn(contentDisposition.parameters, 'name')) fail() | ||
const name = contentDisposition.parameters.name | ||
|
||
if (Object.hasOwn(contentDisposition.parameters, 'filename')) { | ||
// get file from part | ||
const filename = contentDisposition.parameters.filename | ||
const file: ParsedFormFieldFile = { | ||
filename: filename, | ||
headers: part.headers, | ||
content: part.content, | ||
} | ||
|
||
if (Object.hasOwn(dest, name) && dest[name] != null) { | ||
const existingEntry = dest[name] | ||
// don't overwrite pre-existing values | ||
if (!isParsedFormFieldFileList(existingEntry)) return | ||
existingEntry.files.push(file) | ||
return | ||
} | ||
|
||
dest[name] = { | ||
fieldName: name, | ||
type: 'field-file-list', | ||
files: [file], | ||
} satisfies ParsedFormFieldFileList | ||
|
||
return | ||
} | ||
|
||
const contentType = part.headers['content-type'] | ||
if (contentType != null && contentType.mediaType === 'multipart/mixed') { | ||
// get files from multipart/mixed | ||
if (!Object.hasOwn(contentType.parameters, 'boundary')) fail() | ||
const files = getFilesFromMixed(part.content, contentType.parameters.boundary) | ||
|
||
if (Object.hasOwn(dest, name) && dest[name] != null) { | ||
const existingEntry = dest[name] | ||
// don't overwrite pre-existing values | ||
if (!isParsedFormFieldFileList(existingEntry)) return | ||
existingEntry.files.push(...files) | ||
return | ||
} | ||
|
||
dest[name] = { | ||
fieldName: name, | ||
type: 'field-file-list', | ||
files: files, | ||
} satisfies ParsedFormFieldFileList | ||
|
||
return | ||
} | ||
|
||
// otherwise, consider the part to be a value specifier | ||
// don't overwrite pre-existing values | ||
if (Object.hasOwn(dest, name) && dest[name] != null) return | ||
|
||
dest[name] = { | ||
fieldName: name, | ||
type: 'field-value', | ||
value: { | ||
headers: part.headers, | ||
content: part.content, | ||
}, | ||
} satisfies ParsedFormFieldValue | ||
} | ||
|
||
/** | ||
* @see [RFC 7578](https://datatracker.ietf.org/doc/html/rfc7578) | ||
*/ | ||
export function parseMultipartFormData(body: Buffer, boundary: string): ParsedMultipartFormData { | ||
const parts = parseMultipart(body, boundary) | ||
const parsedFormData: ParsedMultipartFormData = {} | ||
for (const part of parts) { | ||
addFormData(part, parsedFormData) | ||
} | ||
return parsedFormData | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import { expect, it } from 'vitest' | ||
|
||
import {ParsedFormFieldFileList, parseMultipartFormData} from "@/parsers/multipart-form-data"; | ||
|
||
it('should parse valid multipart form data with a single part', () => { | ||
const multipart = [ | ||
'preamble', | ||
'--boundary', | ||
'content-disposition: form-data; name=foo', | ||
'content-type: application/json', | ||
'', | ||
'{ "foo": "bar" }', | ||
'--boundary', | ||
'', | ||
'epilogue', | ||
].join('\r\n') | ||
|
||
expect(parseMultipartFormData(Buffer.from(multipart), 'boundary')).toMatchObject({ | ||
foo: { | ||
fieldName: "foo", | ||
type: "field-value", | ||
value: { | ||
headers: {}, | ||
content: Buffer.from('{ "foo": "bar" }') | ||
} | ||
} | ||
}) | ||
}) | ||
|
||
it('should parse valid multipart form data with multiple parts', () => { | ||
const multipart = [ | ||
'preamble', | ||
'--boundary', | ||
'content-disposition: form-data; name=foo', | ||
'x-content-type: application/json', | ||
'', | ||
'{ "foo": "bar" }', | ||
'--boundary', | ||
'content-length: 6', | ||
'content-disposition: form-data; name=bar; filename=baz', | ||
'', | ||
'foo bar baz', | ||
'', | ||
'--boundary', | ||
'', | ||
'epilogue', | ||
].join('\r\n') | ||
|
||
const result = parseMultipartFormData(Buffer.from(multipart), 'boundary') | ||
expect(result).toMatchObject({ | ||
foo: { | ||
fieldName: "foo", | ||
type: "field-value", | ||
value: { | ||
headers: {}, | ||
content: Buffer.from('{ "foo": "bar" }') | ||
} | ||
}, | ||
bar: { | ||
fieldName: "bar", | ||
type: "field-file-list", | ||
files: [ | ||
{ | ||
filename: "baz", | ||
content: Buffer.from("foo bar baz\r\n"), | ||
headers: {} | ||
} | ||
] | ||
} | ||
}) | ||
}) |