Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: body parser helper #1389

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions deno_dist/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
export * from './helper/cookie/index.ts'
export * from './helper/html/index.ts'
export * from './helper/adapter/index.ts'
export * from './helper/body-parser/index.ts'
53 changes: 53 additions & 0 deletions deno_dist/helper/body-parser/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { merge } from './parser/helper.ts'
import { parseKeys } from './parser/keys.ts'
import { parseValue } from './parser/value.ts'

type bodyParserOptions = {
depth: number
parseArrays: boolean
arrayLimit: number
allowPrototypes: boolean
splitByComma: boolean
parameterLimit: number
}

export class BodyParser {
private static _options: bodyParserOptions = {
depth: 5,
parseArrays: true,
arrayLimit: 20,
allowPrototypes: false,
splitByComma: true,
parameterLimit: 1000,
}

public static setOptions(options: Partial<bodyParserOptions>) {
this._options = {
...this._options,
...options,
}
}

public static get options() {
return this._options
}

public static parse<T>(value: unknown): T {
if (value === '' || value === null || value === undefined) return {} as T

const temp = typeof value === 'string' ? parseValue(value) : value

let obj: Record<string, unknown> = {}

const keys = Object.keys(temp)

for (let i = 0; i < keys.length; ++i) {
const key = keys[i]
const newObj = parseKeys(key, temp[key as keyof typeof temp], typeof value === 'string')

obj = merge(obj, newObj as never) as typeof obj
}

return obj as T
}
}
7 changes: 7 additions & 0 deletions deno_dist/helper/body-parser/parser/constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const ISO_SENTINEL = 'utf8=%26%2310003%3B'
export const CHARSET_SENTINEL = 'utf8=%E2%9C%93'

export const CHARSET = {
UTF8: 'utf-8',
ISO: 'iso-8859-1',
}
106 changes: 106 additions & 0 deletions deno_dist/helper/body-parser/parser/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { BodyParser } from '../index.ts'
import { CHARSET } from './constant.ts'

export const hasProperty = Object.prototype.hasOwnProperty

export const decoder = (str: string, charset: string) => {
const strWithoutPlus = str.replace(/\+/g, ' ')

if (charset === CHARSET.ISO) {
return strWithoutPlus.replace(/%[0-9a-f]{2}/gi, unescape)
}

// utf-8
try {
return decodeURIComponent(strWithoutPlus)
} catch {
return strWithoutPlus
}
}

export const mapAndDecode = (value: string | string[], charset: string) => {
if (Array.isArray(value)) {
const mapped: string[] = []

for (let i = 0; i < value.length; ++i) {
mapped.push(decoder(value[i], charset))
}

return mapped
}

return decoder(value, charset)
}

const arrayToObject = (source: unknown[]) => {
const obj: Record<string, unknown> = {}

for (let i = 0; i < source.length; ++i) {
if (typeof source[i] !== 'undefined') {
obj[i] = source[i]
}
}

return obj
}

export const merge = <T, U>(target: T, source: U) => {
if (!source) {
return target
}

if (typeof source !== 'object') {
if (Array.isArray(target)) {
target.push(source)
} else if (target && typeof target === 'object') {
if (
BodyParser.options.allowPrototypes ||
!hasProperty.call(Object.prototype, source as string)
) {
target[source as keyof typeof target] = true as never
}
} else {
return [target, source]
}

return target
}

if (!target || typeof target !== 'object') {
return [target].concat(source as never)
}

let mergeTarget = target
if (Array.isArray(target) && !Array.isArray(source)) {
mergeTarget = arrayToObject(target) as typeof mergeTarget
}

if (Array.isArray(target) && Array.isArray(source)) {
source.forEach((item, i) => {
if (hasProperty.call(target, i)) {
const targetItem = target[i]
if (targetItem && typeof targetItem === 'object' && item && typeof item === 'object') {
target[i] = merge(targetItem, item)
} else {
target.push(item)
}
} else {
target[i] = item
}
})
return target
}

return Object.keys(source).reduce((acc, key) => {
const value = source[key as never]
const curr = acc[key as never]

if (hasProperty.call(acc, key)) {
acc[key as never] = merge(curr, value as never) as never
} else {
acc[key as never] = value as never
}

return acc
}, mergeTarget)
}
54 changes: 54 additions & 0 deletions deno_dist/helper/body-parser/parser/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { BodyParser } from '../index.ts'
import { hasProperty } from './helper.ts'
import { parseObject } from './object.ts'

export const parseKeys = <T extends Record<string, unknown | unknown[]>>(
passedKey: string,
value: T,
valueParsed: boolean
) => {
if (!passedKey) return

const key = passedKey.replace(/\.([^.[]+)/g, '[$1]')
const brackets = /(\[[^[\]]*])/
const child = /(\[[^[\]]*])/g

const objectDepth = BodyParser.options.depth

let segment = objectDepth > 0 && brackets.exec(key)
const parent = segment ? key.slice(0, segment.index) : key
const keys: string[] = []

if (parent) {
if (hasProperty.call(Object.prototype, parent) && !BodyParser.options.allowPrototypes) {
return
}

keys.push(parent)
}

let i = 0
while (
objectDepth > 0 &&
// eslint-disable-next-line no-cond-assign
(segment = child.exec(key)) !== null &&
i < objectDepth
) {
i += 1

if (
hasProperty.call(Object.prototype, segment[1].slice(1, -1)) &&
!BodyParser.options.allowPrototypes
) {
return
}

keys.push(segment[1])
}

if (segment) {
keys.push(`[${key.slice(segment.index)}]`)
}

return parseObject(keys, value, valueParsed)
}
46 changes: 46 additions & 0 deletions deno_dist/helper/body-parser/parser/object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { BodyParser } from '../index.ts'
import { parseValues } from './value.ts'

export const parseObject = <T extends Record<string, unknown | string[]>>(
chain: string[],
value: T,
valueParsed: boolean
) => {
const { options } = BodyParser
let leaf = valueParsed ? value : parseValues(value as never)

for (let i = chain.length - 1; i >= 0; --i) {
let obj: string[] | Record<string, unknown | string[]>
const root = chain[i]

if (root === '[]') {
obj = [].concat(leaf as never)
} else {
obj = {}

const isEncapsulated = root.charAt(0) === '[' && root.at(-1) === ']'
const cleanRoot = isEncapsulated ? root.slice(1, -1) : root
const index = +cleanRoot

if (!options.parseArrays && cleanRoot === '') {
obj = { 0: leaf }
} else if (
!Number.isNaN(index) &&
root !== cleanRoot &&
String(index) === cleanRoot &&
index >= 0 &&
options.parseArrays &&
index <= options.arrayLimit
) {
obj = []
obj[index] = leaf as never
} else if (cleanRoot !== '__proto__') {
obj[cleanRoot] = leaf
}
}

leaf = obj as typeof leaf
}

return leaf
}
66 changes: 66 additions & 0 deletions deno_dist/helper/body-parser/parser/value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { BodyParser } from '../index.ts'
import { CHARSET, CHARSET_SENTINEL, ISO_SENTINEL } from './constant.ts'
import { decoder, hasProperty, mapAndDecode } from './helper.ts'

export const parseValues = <T>(val: T) => {
if (val && typeof val === 'string' && BodyParser.options.splitByComma && val.indexOf(',') > -1) {
return val.split(',') as never[]
}

return val
}

export const parseValue = (value: string) => {
const obj: Record<string, unknown | unknown[]> = {
__proto__: null,
}
const chunks = value.split('&', BodyParser.options.parameterLimit)

// Track where the `utf8=` was found
const skipIndex: number = chunks.findIndex((val) => val.indexOf('utf8=') !== -1)
let charset: string

// eslint-disable-next-line default-case
switch (chunks[skipIndex]) {
case ISO_SENTINEL:
charset = CHARSET.ISO
break

case CHARSET_SENTINEL:
default:
charset = CHARSET.UTF8
break
}

for (let i = 0; i < chunks.length; ++i) {
// eslint-disable-next-line no-continue
if (i === skipIndex) continue

const part = chunks[i]
const bracketEqualsPos = part.indexOf(']=')
const pos = bracketEqualsPos === -1 ? part.indexOf('=') : bracketEqualsPos + 1

let key: string
let val: unknown | unknown[]

if (pos === -1) {
key = decoder(part, charset)
val = ''
} else {
key = decoder(part.slice(0, pos), charset)
val = mapAndDecode(parseValues(part.slice(pos + 1)), charset)
}

if (part.indexOf('[]=') > -1) {
val = Array.isArray(val) ? [val] : val
}

if (hasProperty.call(obj, key)) {
obj[key] = ([] as unknown[]).concat(obj[key], val)
} else {
obj[key] = val
}
}

return obj
}
1 change: 1 addition & 0 deletions src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
export * from './helper/cookie'
export * from './helper/html'
export * from './helper/adapter'
export * from './helper/body-parser'
46 changes: 46 additions & 0 deletions src/helper/body-parser/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { BodyParser } from '.'

describe('Body Parser', () => {
it('Parse JSON body', async () => {
const req = new Request('http://localhost/json', {
method: 'POST',
body: JSON.stringify({ 'message[0]': 'Hello Hono', 'message[1]': 'Hello 🔥' }),
headers: new Headers({ 'Content-Type': 'application/json' }),
})

const result = BodyParser.parse(await req.json())
expect(result).toMatchObject({
message: ['Hello Hono', 'Hello 🔥'],
})
})

it('Parse URL encoded body', async () => {
const req = new Request('http://localhost/form', {
method: 'POST',
body: new URLSearchParams({
'message[0]': 'An Array',
'message[1]': 'Message 🔥',
}),
headers: new Headers({ 'Content-Type': 'application/x-www-form-urlencoded' }),
})

const result = BodyParser.parse(await req.text())

expect(result).toMatchObject({
message: ['An Array', 'Message 🔥'],
})
})

it('Parse Text body to JSON', async () => {
const req = new Request('http://localhost/form', {
method: 'POST',
body: 'An Array',
headers: new Headers({ 'Content-Type': 'text/plain' }),
})

const result = BodyParser.parse(await req.text())
expect(result).toMatchObject({
'An Array': '',
})
})
})