Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/hono-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
HandlerInterface,
MergePath,
MergeSchemaPath,
MethodNotAllowedHandler,
MiddlewareHandler,
MiddlewareHandlerInterface,
Next,
Expand Down Expand Up @@ -179,11 +180,14 @@ class Hono<
})
clone.errorHandler = this.errorHandler
clone.#notFoundHandler = this.#notFoundHandler
clone.#methodNotAllowedHandler = this.#methodNotAllowedHandler
clone.routes = this.routes
return clone
}

#notFoundHandler: NotFoundHandler = notFoundHandler
#methodNotAllowedHandler: MethodNotAllowedHandler | null = null

// Cannot use `#` because it requires visibility at JavaScript runtime.
private errorHandler: ErrorHandler = errorHandler

Expand Down Expand Up @@ -293,6 +297,28 @@ class Hono<
return this
}

/**
* `.methodNotAllowed()` allows you to customize a Method Not Allowed Response.
*
* @see {@link https://hono.dev/docs/api/hono#method-not-allowed}
*
* @param {MethodNotAllowedHandler} handler - request handler for method-not-allowed
* @returns {Hono} changed Hono instance
*
* @example
* ```ts
* app.methodNotAllowed((c, allowedMethods) => {
* return c.text(`Method not allowed. Allowed: ${allowedMethods.join(', ')}`, 405, {
* Allow: allowedMethods.join(', ').toUpperCase()
* })
* })
* ```
*/
methodNotAllowed = (handler: MethodNotAllowedHandler<E>): Hono<E, S, BasePath, CurrentPath> => {
this.#methodNotAllowedHandler = handler
return this
}

/**
* `.mount()` allows you to mount applications built with other frameworks into your Hono application.
*
Expand Down Expand Up @@ -390,6 +416,24 @@ class Hono<
this.routes.push(r)
}

#findAllowedMethods(path: string): string[] {
const allowedMethods = new Set<string>()
const allMethods = METHODS.map((m) => m.toUpperCase())

for (const method of allMethods) {
const matchResult = this.router.match(method, path)
// Check if there are any method-specific routes (excluding METHOD_NAME_ALL middleware)
const hasMethodSpecificRoute = matchResult[0].some(
(match: [[H, RouterRoute], unknown]) => match[0][1].method !== METHOD_NAME_ALL
)
if (hasMethodSpecificRoute) {
allowedMethods.add(method)
}
}

return Array.from(allowedMethods)
}

#handleError(err: unknown, c: Context<E>): Response | Promise<Response> {
if (err instanceof Error) {
return this.errorHandler(err, c)
Expand Down Expand Up @@ -420,6 +464,19 @@ class Hono<
notFoundHandler: this.#notFoundHandler,
})

// Check if there are any method-specific routes (excluding METHOD_NAME_ALL middleware)
const hasMethodSpecificRoute = matchResult[0].some(
(match: [[H, RouterRoute], unknown]) => match[0][1].method !== METHOD_NAME_ALL
)

// Check for method not allowed if no method-specific match found and handler is set
if (!hasMethodSpecificRoute && this.#methodNotAllowedHandler) {
const allowedMethods = this.#findAllowedMethods(path)
if (allowedMethods.length > 0) {
return this.#methodNotAllowedHandler(c, allowedMethods)
}
}

// Do not `compose` if it has only one handler
if (matchResult[0].length === 1) {
let res: ReturnType<H>
Expand Down
194 changes: 194 additions & 0 deletions src/hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1340,6 +1340,200 @@ describe('Not Found', () => {
})
})

describe('Method Not Allowed', () => {
it('Should return 404 by default when method is not allowed', async () => {
const app = new Hono()

app.get('/hello', (c) => {
return c.text('hello')
})

const res = await app.request('http://localhost/hello', { method: 'POST' })
expect(res.status).toBe(404)
expect(await res.text()).toBe('404 Not Found')
})

it('Should return 405 when methodNotAllowed handler is set', async () => {
const app = new Hono()

app.get('/hello', (c) => {
return c.text('hello')
})

app.methodNotAllowed((c, allowedMethods) => {
return c.text('405 Method Not Allowed', 405, {
Allow: allowedMethods.join(', ').toUpperCase(),
})
})

const res = await app.request('http://localhost/hello', { method: 'POST' })
expect(res.status).toBe(405)
expect(res.headers.get('Allow')).toBe('GET')
expect(await res.text()).toBe('405 Method Not Allowed')
})

it('Should return 404 when path does not exist even with methodNotAllowed handler', async () => {
const app = new Hono()

app.get('/hello', (c) => {
return c.text('hello')
})

app.methodNotAllowed((c, allowedMethods) => {
return c.text('405 Method Not Allowed', 405, {
Allow: allowedMethods.join(', ').toUpperCase(),
})
})

const res = await app.request('http://localhost/nonexistent', { method: 'POST' })
expect(res.status).toBe(404)
expect(await res.text()).toBe('404 Not Found')
})

it('Should include all allowed methods in Allow header', async () => {
const app = new Hono()

app.get('/hello', (c) => c.text('GET'))
app.post('/hello', (c) => c.text('POST'))
app.put('/hello', (c) => c.text('PUT'))

app.methodNotAllowed((c, allowedMethods) => {
return c.text('405 Method Not Allowed', 405, {
Allow: allowedMethods.join(', ').toUpperCase(),
})
})

const res = await app.request('http://localhost/hello', { method: 'DELETE' })
expect(res.status).toBe(405)
const allowHeader = res.headers.get('Allow')
expect(allowHeader).toContain('GET')
expect(allowHeader).toContain('POST')
expect(allowHeader).toContain('PUT')
expect(allowHeader?.split(', ').length).toBe(3)
})

it('Should work with custom methodNotAllowed handler', async () => {
const app = new Hono()

app.get('/hello', (c) => c.text('hello'))

app.methodNotAllowed((c, allowedMethods) => {
return c.json({ error: 'Method not allowed', allowed: allowedMethods }, 405, {
Allow: allowedMethods.join(', ').toUpperCase(),
})
})

const res = await app.request('http://localhost/hello', { method: 'POST' })
expect(res.status).toBe(405)
expect(res.headers.get('Allow')).toBe('GET')
const json = await res.json()
expect(json.error).toBe('Method not allowed')
expect(json.allowed).toEqual(['GET'])
})

it('Should work with routes with parameters', async () => {
const app = new Hono()

app.get('/user/:id', (c) => {
return c.text(`User ${c.req.param('id')}`)
})

app.methodNotAllowed((c, allowedMethods) => {
return c.text('405 Method Not Allowed', 405, {
Allow: allowedMethods.join(', ').toUpperCase(),
})
})

const res = await app.request('http://localhost/user/123', { method: 'POST' })
expect(res.status).toBe(405)
expect(res.headers.get('Allow')).toBe('GET')
})

it('Should work with HEAD method (which maps to GET)', async () => {
const app = new Hono()

app.get('/hello', (c) => c.text('hello'))

app.methodNotAllowed((c, allowedMethods) => {
return c.text('405 Method Not Allowed', 405, {
Allow: allowedMethods.join(', ').toUpperCase(),
})
})

// HEAD should work (maps to GET)
const headRes = await app.request('http://localhost/hello', { method: 'HEAD' })
expect(headRes.status).toBe(200)

// But POST should return 405
const postRes = await app.request('http://localhost/hello', { method: 'POST' })
expect(postRes.status).toBe(405)
expect(postRes.headers.get('Allow')).toBe('GET')
})

it('Should work with app.route()', async () => {
const app = new Hono()
const subApp = new Hono()

subApp.get('/hello', (c) => c.text('hello'))
app.route('/api', subApp)

app.methodNotAllowed((c, allowedMethods) => {
return c.text('405 Method Not Allowed', 405, {
Allow: allowedMethods.join(', ').toUpperCase(),
})
})

const res = await app.request('http://localhost/api/hello', { method: 'POST' })
expect(res.status).toBe(405)
expect(res.headers.get('Allow')).toBe('GET')
})

it('Should work with multiple methods on same path', async () => {
const app = new Hono()

app.get('/resource', (c) => c.text('GET'))
app.post('/resource', (c) => c.text('POST'))
app.delete('/resource', (c) => c.text('DELETE'))

app.methodNotAllowed((c, allowedMethods) => {
return c.text('405 Method Not Allowed', 405, {
Allow: allowedMethods.join(', ').toUpperCase(),
})
})

const res = await app.request('http://localhost/resource', { method: 'PUT' })
expect(res.status).toBe(405)
const allowHeader = res.headers.get('Allow')
expect(allowHeader).toContain('GET')
expect(allowHeader).toContain('POST')
expect(allowHeader).toContain('DELETE')
})

it('Should work with app.use() middleware', async () => {
const app = new Hono()

app.methodNotAllowed((c, allowedMethods) => {
return c.text(`Method not allowed. Allowed: ${allowedMethods.join(', ')}`, 405, {
Allow: allowedMethods.join(', ').toUpperCase(),
})
})

// Adding middleware should not prevent methodNotAllowed from being called
app.use(async (c, next) => {
await next()
})

app.get('/', (c) => {
return new Response('Hello, World!')
})

const res = await app.request('http://localhost/', { method: 'POST' })
expect(res.status).toBe(405)
expect(res.headers.get('Allow')).toBe('GET')
expect(await res.text()).toBe('Method not allowed. Allowed: GET')
})
})

describe('Redirect', () => {
const app = new Hono()
app.get('/redirect', (c) => {
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type {
ErrorHandler,
Handler,
MiddlewareHandler,
MethodNotAllowedHandler,
MethodNotAllowedResponse,
Next,
NotFoundResponse,
NotFoundHandler,
Expand Down
17 changes: 17 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,23 @@ export type NotFoundHandler<E extends Env = any> = (
? NotFoundResponse | Promise<NotFoundResponse>
: Response | Promise<Response>

/**
* You can extend this interface to define a custom `c.methodNotAllowed()` Response type.
*
* @example
* declare module 'hono' {
* interface MethodNotAllowedResponse extends Response, TypedResponse<string, 405, 'text'> {}
* }
*/
export interface MethodNotAllowedResponse {}

export type MethodNotAllowedHandler<E extends Env = any> = (
c: Context<E>,
allowedMethods: string[]
) => MethodNotAllowedResponse extends Response
? MethodNotAllowedResponse | Promise<MethodNotAllowedResponse>
: Response | Promise<Response>

export interface HTTPResponseError extends Error {
getResponse: () => Response
}
Expand Down