Skip to content

Commit

Permalink
feat: add request and response utils (#15)
Browse files Browse the repository at this point in the history
* feat: add request and response utils

* chore: use MIME.json

* chore: stringify json body

* chore: use dummy host for URL parser

* test: add test

* chore: types

* improve utils

* use use

* update tests

* move destr to devDependencies

* refactor: improve error and body utils

Co-authored-by: Pooya Parsa <pyapar@gmail.com>
  • Loading branch information
farnabaz and pi0 committed Dec 12, 2020
1 parent 12cd218 commit 648e9b9
Show file tree
Hide file tree
Showing 10 changed files with 595 additions and 500 deletions.
21 changes: 11 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,22 @@
"test": "yarn lint && jest"
},
"devDependencies": {
"@nuxt/ufo": "^0.1.3",
"@nuxtjs/eslint-config-typescript": "latest",
"@types/express": "^4.17.9",
"@types/express": "latest",
"@types/node": "latest",
"@types/supertest": "^2.0.10",
"codecov": "^3.8.1",
"connect": "^3.7.0",
"@types/supertest": "latest",
"connect": "latest",
"destr": "^1.0.1",
"eslint": "latest",
"express": "^4.17.1",
"get-port": "^5.1.1",
"jest": "^26.6.3",
"jiti": "^0.1.12",
"express": "latest",
"get-port": "latest",
"jest": "latest",
"jiti": "latest",
"siroc": "latest",
"standard-version": "latest",
"supertest": "^6.0.1",
"ts-jest": "^26.4.4",
"supertest": "latest",
"ts-jest": "latest",
"typescript": "latest"
}
}
8 changes: 5 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { IncomingMessage, ServerResponse } from 'http'
import { withoutTrailingSlash } from '@nuxt/ufo'
import type { Stack, InputLayer, Handle, PHandle, App, AppOptions, LazyHandle } from './types'
import { promisifyHandle } from './promisify'
import { lazyHandle } from './lazy'
import { send, createError, sendError, MIMES, stripTrailingSlash } from './utils'
import { createError, sendError } from './error'
import { send, MIMES } from './utils'

export function createApp (options: AppOptions = {}): App {
const stack: Stack = []
Expand Down Expand Up @@ -80,14 +82,14 @@ export function createHandle (stack: Stack): PHandle {
}
}
if (!res.writableEnded) {
throw createError(404, 'Not Found')
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
}
}

function normalizeLayer (layer: InputLayer) {
return {
route: stripTrailingSlash(layer.route).toLocaleLowerCase(),
route: withoutTrailingSlash(layer.route).toLocaleLowerCase(),
match: layer.match,
handle: layer.lazy
? lazyHandle(layer.handle as LazyHandle, layer.promisify)
Expand Down
42 changes: 42 additions & 0 deletions src/body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { IncomingMessage } from 'http'

import destr from 'destr'

type Encoding = false | 'ascii' | 'utf8' | 'utf-8' | 'utf16le' | 'ucs2' | 'ucs-2' | 'base64' | 'latin1' | 'binary' | 'hex'

export function useBody (req: IncomingMessage, encoding: Encoding = 'utf-8'): Encoding extends false ? Buffer : Promise<string> {
// @ts-ignore
if (req.rawBody) {
// @ts-ignore
return Promise.resolve(encoding ? req.rawBody.toString(encoding) : req.rawBody)
}

return new Promise<string>((resolve, reject) => {
const bodyData: any[] = []
req
.on('error', (err) => { reject(err) })
.on('data', (chunk) => { bodyData.push(chunk) })
.on('end', () => {
// @ts-ignore
req.rawBody = Buffer.concat(bodyData)
// @ts-ignore
resolve(encoding ? req.rawBody.toString(encoding) : req.rawBody)
})
})
}

export async function useBodyJSON<T> (req: IncomingMessage): Promise<T> {
// @ts-ignore
if (req.jsonBody) {
// @ts-ignore
return req.jsonBody
}

const body = await useBody(req)
const json = destr(body)

// @ts-ignore
req.jsonBody = json

return json
}
55 changes: 55 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { ServerResponse } from 'http'
import { MIMES } from './utils'

export class H2Error extends Error {
statusCode: number = 500
statusMessage: string = 'Internal Error'
data?: any
internal: boolean = false
}

export function createError (input: Partial<H2Error>): H2Error {
if (input instanceof H2Error) {
return input
}
const err = new H2Error()
if (input.statusCode) {
err.statusCode = input.statusCode
}
if (input.statusMessage) {
err.statusMessage = input.statusMessage
}
if (input.data) {
err.data = input.data
}
if (input.internal) {
err.internal = input.internal
}
return err
}

export function sendError (res: ServerResponse, error: Error | H2Error, debug: boolean) {
const h2Error = createError(error)

// @ts-ignore
if (h2Error.internal) {
console.error(h2Error) // eslint-disable-line no-console
}

res.statusCode = h2Error.statusCode
res.statusMessage = h2Error.statusMessage

const responseBody = {
statusCode: res.statusCode,
statusMessage: res.statusMessage,
stack: [] as string[],
data: h2Error.data
}

if (debug) {
responseBody.stack = (h2Error.stack || '').split('\n').map(l => l.trim())
}

res.setHeader('Content-Type', MIMES.json)
res.end(JSON.stringify(responseBody, null, 2))
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export * from './promisify'
export * from './types'
export * from './utils'
export * from './lazy'
export * from './body'
export * from './error'
40 changes: 8 additions & 32 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { ServerResponse } from 'http'
import type { ServerResponse, IncomingMessage } from 'http'

import { withoutTrailingSlash, getParams } from '@nuxt/ufo'
import { PHandle } from './types'

export const MIMES = {
Expand All @@ -19,45 +21,15 @@ export function defaultContentType (res: ServerResponse, type?: string) {
}
}

export function sendError (res: ServerResponse, error: Error | string, code?: number, debug: boolean = true) {
res.statusCode = code ||
(res.statusCode !== 200 && res.statusCode) ||
// @ts-ignore
error.statusCode || error.status ||
500

if (debug && res.statusCode !== 404) {
console.error(error) // eslint-disable-line no-console
}

// @ts-ignore
res.statusMessage = res.statusMessage || error.statusMessage || error.statusText || 'Internal Error'

res.end(`"${res.statusMessage} (${res.statusCode})"`)
}

export function createError (statusCode: number, statusMessage: string) {
const err = new Error(statusMessage)
// @ts-ignore
err.statusCode = statusCode
// @ts-ignore
err.statusMessage = statusMessage
return err
}

export function sendRedirect (res: ServerResponse, location: string, code = 302) {
res.statusCode = code
res.setHeader('Location', location)
defaultContentType(res, MIMES.html)
res.end(location)
}

export function stripTrailingSlash (str: string = '') {
return str.endsWith('/') ? str.slice(0, -1) : str
}

export function useBase (base: string, handle: PHandle): PHandle {
base = stripTrailingSlash(base)
base = withoutTrailingSlash(base)
if (!base) { return handle }
return function (req, res) {
(req as any).originalUrl = (req as any).originalUrl || req.url || '/'
Expand All @@ -68,3 +40,7 @@ export function useBase (base: string, handle: PHandle): PHandle {
return handle(req, res)
}
}

export function useQuery (req: IncomingMessage) {
return getParams(req.url || '')
}
50 changes: 50 additions & 0 deletions test/body.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import supertest, { SuperTest, Test } from 'supertest'
import { createApp, App, useBody, useBodyJSON } from '../src'

describe('', () => {
let app: App
let request: SuperTest<Test>

beforeEach(() => {
app = createApp({ debug: false })
request = supertest(app)
})

describe('useBody', () => {
it('can handle raw string', async () => {
app.use('/', async (request) => {
const body = await useBody(request)
expect(body).toEqual('{"bool":true,"name":"string","number":1}')
return '200'
})
const result = await request.post('/api/test').send(JSON.stringify({
bool: true,
name: 'string',
number: 1
}))

expect(result.text).toBe('200')
})
})

describe('useJSON', () => {
it('can parse json payload', async () => {
app.use('/', async (request) => {
const body = await useBodyJSON(request)
expect(body).toMatchObject({
bool: true,
name: 'string',
number: 1
})
return '200'
})
const result = await request.post('/api/test').send({
bool: true,
name: 'string',
number: 1
})

expect(result.text).toBe('200')
})
})
})
86 changes: 86 additions & 0 deletions test/error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import supertest, { SuperTest, Test } from 'supertest'
import { createApp, App, createError } from '../src'

; (global.console.error as any) = jest.fn()

describe('error', () => {
let app: App
let request: SuperTest<Test>

beforeEach(() => {
app = createApp({ debug: false })
request = supertest(app)
})

describe('sendError', () => {
it('logs errors', async () => {
app.use((_req) => {
throw createError({ statusMessage: 'Unprocessable', statusCode: 422 })
})
const result = await request.get('/')

expect(result.status).toBe(422)
})

it('returns errors', async () => {
app.use((_req) => {
throw createError({ statusMessage: 'Unprocessable', statusCode: 422 })
})
const result = await request.get('/')

expect(result.status).toBe(422)
})
})

describe('createError', () => {
it('can send internal error', async () => {
app.use('/', () => {
throw createError({
statusCode: 500,
statusMessage: 'Internal Error',
data: 'oops',
internal: true
})
})
const result = await request.get('/api/test')

expect(result.status).toBe(500)
// eslint-disable-next-line
expect(console.error).toBeCalled()

expect(JSON.parse(result.text)).toMatchObject({
statusCode: 500,
statusMessage: 'Internal Error'
})
})

it('can send runtime error', async () => {
jest.clearAllMocks()

app.use('/', () => {
throw createError({
statusCode: 400,
statusMessage: 'Bad Request',
data: {
message: 'Invalid Input'
}
})
})

const result = await request.get('/api/test')

expect(result.status).toBe(400)
expect(result.type).toMatch('application/json')
// eslint-disable-next-line
expect(console.error).not.toBeCalled()

expect(JSON.parse(result.text)).toMatchObject({
statusCode: 400,
statusMessage: 'Bad Request',
data: {
message: 'Invalid Input'
}
})
})
})
})
Loading

0 comments on commit 648e9b9

Please sign in to comment.