Skip to content

Commit

Permalink
feat(middleware): introduce Request ID middleware (#3082)
Browse files Browse the repository at this point in the history
* feat(middleware): introduce Request ID middleware

* fix not to accept empty string in header

* rename requestID to requestId

* pass the context to the generator option

* add typesVersions

* fix typo

Co-Authored-By: Taku Amano <taku@taaas.jp>

* change to generate id if validation fails

Co-Authored-By: Taku Amano <taku@taaas.jp>

* fix limit length test

---------

Co-authored-by: Taku Amano <taku@taaas.jp>
  • Loading branch information
ryuapp and usualoma committed Jul 13, 2024
1 parent c2698fa commit e6d253d
Show file tree
Hide file tree
Showing 5 changed files with 231 additions and 0 deletions.
1 change: 1 addition & 0 deletions jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"./method-override": "./src/middleware/method-override/index.ts",
"./powered-by": "./src/middleware/powered-by/index.ts",
"./pretty-json": "./src/middleware/pretty-json/index.ts",
"./request-id": "./src/middleware/request-id/request-id.ts",
"./secure-headers": "./src/middleware/secure-headers/secure-headers.ts",
"./ssg": "./src/helper/ssg/index.ts",
"./streaming": "./src/helper/streaming/index.ts",
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@
"import": "./dist/middleware/pretty-json/index.js",
"require": "./dist/cjs/middleware/pretty-json/index.js"
},
"./request-id": {
"types": "./dist/types/middleware/request-id/index.d.ts",
"import": "./dist/middleware/request-id/index.js",
"require": "./dist/cjs/middleware/request-id/index.js"
},
"./secure-headers": {
"types": "./dist/types/middleware/secure-headers/index.d.ts",
"import": "./dist/middleware/secure-headers/index.js",
Expand Down Expand Up @@ -476,6 +481,9 @@
"pretty-json": [
"./dist/types/middleware/pretty-json"
],
"request-id": [
"./dist/types/middleware/request-id"
],
"streaming": [
"./dist/types/helper/streaming"
],
Expand Down
155 changes: 155 additions & 0 deletions src/middleware/request-id/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import type { Context } from '../../context'
import { Hono } from '../../hono'
import { requestId } from '.'

const regexUUIDv4 = /([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})/

describe('Request ID Middleware', () => {
const app = new Hono()
app.use('*', requestId())
app.get('/requestId', (c) => c.text(c.get('requestId') ?? 'No Request ID'))

it('Should return random request id', async () => {
const res = await app.request('http://localhost/requestId')
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(res.headers.get('X-Request-Id')).toMatch(regexUUIDv4)
expect(await res.text()).match(regexUUIDv4)
})

it('Should return custom request id', async () => {
const res = await app.request('http://localhost/requestId', {
headers: {
'X-Request-Id': 'hono-is-cool',
},
})
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(res.headers.get('X-Request-Id')).toBe('hono-is-cool')
expect(await res.text()).toBe('hono-is-cool')
})

it('Should return random request id without using request header', async () => {
const res = await app.request('http://localhost/requestId', {
headers: {
'X-Request-Id': 'Hello!12345-@*^',
},
})
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(res.headers.get('X-Request-Id')).toMatch(regexUUIDv4)
expect(await res.text()).toMatch(regexUUIDv4)
})
})

describe('Request ID Middleware with custom generator', () => {
function generateWord() {
return 'HonoIsWebFramework'
}
function generateDoubleRequestId(c: Context) {
const honoId = c.req.header('Hono-Request-Id')
const ohnoId = c.req.header('Ohno-Request-Id')
if (honoId && ohnoId) {
return honoId + ohnoId
}
return crypto.randomUUID()
}
const app = new Hono()
app.use('/word', requestId({ generator: generateWord }))
app.use('/doubleRequestId', requestId({ generator: generateDoubleRequestId }))
app.get('/word', (c) => c.text(c.get('requestId') ?? 'No Request ID'))
app.get('/doubleRequestId', (c) => c.text(c.get('requestId') ?? 'No Request ID'))
it('Should return custom request id', async () => {
const res = await app.request('http://localhost/word')
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(res.headers.get('X-Request-Id')).toBe('HonoIsWebFramework')
expect(await res.text()).toBe('HonoIsWebFramework')
})

it('Should return complex request id', async () => {
const res = await app.request('http://localhost/doubleRequestId', {
headers: {
'Hono-Request-Id': 'Hello',
'Ohno-Request-Id': 'World',
},
})
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(res.headers.get('X-Request-Id')).toBe('HelloWorld')
expect(await res.text()).toBe('HelloWorld')
})
})

describe('Request ID Middleware with limit length', () => {
const charactersOf255 = 'h'.repeat(255)
const charactersOf256 = 'h'.repeat(256)

const app = new Hono()
app.use('/requestId', requestId())
app.use('/limit256', requestId({ limitLength: 256 }))
app.get('/requestId', (c) => c.text(c.get('requestId') ?? 'No Request ID'))
app.get('/limit256', (c) => c.text(c.get('requestId') ?? 'No Request ID'))

it('Should return custom request id', async () => {
const res = await app.request('http://localhost/requestId', {
headers: {
'X-Request-Id': charactersOf255,
},
})
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(res.headers.get('X-Request-Id')).toBe(charactersOf255)
expect(await res.text()).toBe(charactersOf255)
})
it('Should return random request id without using request header', async () => {
const res = await app.request('http://localhost/requestId', {
headers: {
'X-Request-Id': charactersOf256,
},
})
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(res.headers.get('X-Request-Id')).toMatch(regexUUIDv4)
expect(await res.text()).toMatch(regexUUIDv4)
})
it('Should return custom request id with 256 characters', async () => {
const res = await app.request('http://localhost/limit256', {
headers: {
'X-Request-Id': charactersOf256,
},
})
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(res.headers.get('X-Request-Id')).toBe(charactersOf256)
expect(await res.text()).toBe(charactersOf256)
})
})

describe('Request ID Middleware with custom header', () => {
const app = new Hono()
app.use('/requestId', requestId({ headerName: 'Hono-Request-Id' }))
app.get('/emptyId', requestId({ headerName: '' }))
app.get('/requestId', (c) => c.text(c.get('requestId') ?? 'No Request ID'))
app.get('/emptyId', (c) => c.text(c.get('requestId') ?? 'No Request ID'))

it('Should return custom request id', async () => {
const res = await app.request('http://localhost/requestId', {
headers: {
'Hono-Request-Id': 'hono-is-cool',
},
})
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(res.headers.get('Hono-Request-Id')).toBe('hono-is-cool')
expect(await res.text()).toBe('hono-is-cool')
})

it('Should not return request id', async () => {
const res = await app.request('http://localhost/emptyId')
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(res.headers.get('X-Request-Id')).toBeNull()
expect(await res.text()).toMatch(regexUUIDv4)
})
})
8 changes: 8 additions & 0 deletions src/middleware/request-id/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { RequestIdVariables } from './request-id'
export type { RequestIdVariables }
export { requestId } from './request-id'
import type {} from '../..'

declare module '../..' {
interface ContextVariableMap extends RequestIdVariables {}
}
59 changes: 59 additions & 0 deletions src/middleware/request-id/request-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* @module
* Request ID Middleware for Hono.
*/

import type { Context } from '../../context'
import type { MiddlewareHandler } from '../../types'

export type RequestIdVariables = {
requestId: string
}

export type RequestIdOptions = {
limitLength?: number
headerName?: string
generator?: (c: Context) => string
}

/**
* Request ID Middleware for Hono.
*
* @param {object} options - Options for Request ID middleware.
* @param {number} [options.limitLength=255] - The maximum length of request id.
* @param {string} [options.headerName=X-Request-Id] - The header name used in request id.
* @param {generator} [options.generator=() => crypto.randomUUID()] - The request id generation function.
*
* @returns {MiddlewareHandler} The middleware handler function.
*
* @example
* ```ts
* type Variables = RequestIdVariables
* const app = new Hono<{Variables: Variables}>()
*
* app.use(requestId())
* app.get('/', (c) => {
* console.log(c.get('requestId')) // Debug
* return c.text('Hello World!')
* })
* ```
*/
export const requestId = ({
limitLength = 255,
headerName = 'X-Request-Id',
generator = () => crypto.randomUUID(),
}: RequestIdOptions = {}): MiddlewareHandler => {
return async function requestId(c, next) {
// If `headerName` is empty string, req.header will return the object
let reqId = headerName ? c.req.header(headerName) : undefined
if (!reqId || reqId.length > limitLength || /[^\w\-]/.test(reqId)) {
reqId = generator(c)
}

c.set('requestId', reqId)
if (headerName) {
c.header(headerName, reqId)
}
await next()
}
}

0 comments on commit e6d253d

Please sign in to comment.