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
1 change: 1 addition & 0 deletions jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"./timeout": "./src/middleware/timeout/index.ts",
"./timing": "./src/middleware/timing/timing.ts",
"./logger": "./src/middleware/logger/index.ts",
"./method-not-allowed": "./src/middleware/method-not-allowed/index.ts",
"./method-override": "./src/middleware/method-override/index.ts",
"./powered-by": "./src/middleware/powered-by/index.ts",
"./pretty-json": "./src/middleware/pretty-json/index.ts",
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,11 @@
"import": "./dist/middleware/logger/index.js",
"require": "./dist/cjs/middleware/logger/index.js"
},
"./method-not-allowed": {
"types": "./dist/types/middleware/method-not-allowed/index.d.ts",
"import": "./dist/middleware/method-not-allowed/index.js",
"require": "./dist/cjs/middleware/method-not-allowed/index.js"
},
"./method-override": {
"types": "./dist/types/middleware/method-override/index.d.ts",
"import": "./dist/middleware/method-override/index.js",
Expand Down Expand Up @@ -518,6 +523,9 @@
"logger": [
"./dist/types/middleware/logger"
],
"method-not-allowed": [
"./dist/types/middleware/method-not-allowed"
],
"method-override": [
"./dist/types/middleware/method-override"
],
Expand Down
158 changes: 158 additions & 0 deletions src/middleware/method-not-allowed/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { Hono } from '../../hono'
import { methodNotAllowed } from '.'

describe('Method Not Allowed Middleware', () => {
const app = new Hono()

app.use(methodNotAllowed({ app }))

app.get('/hello', (c) => c.text('Hello!'))
app.post('/hello', (c) => c.text('Posted!'))
app.get('/users/:id', (c) => c.text(`User ${c.req.param('id')}`))
app.put('/users/:id', (c) => c.text(`Updated ${c.req.param('id')}`))
app.delete('/users/:id', (c) => c.text(`Deleted ${c.req.param('id')}`))

it('Should return 200 for a valid GET request', async () => {
const res = await app.request('/hello')
expect(res.status).toBe(200)
expect(await res.text()).toBe('Hello!')
})

it('Should return 200 for a valid POST request', async () => {
const res = await app.request('/hello', { method: 'POST' })
expect(res.status).toBe(200)
expect(await res.text()).toBe('Posted!')
})

it('Should return 405 for an unsupported method on an existing path', async () => {
const res = await app.request('/hello', { method: 'PUT' })
expect(res.status).toBe(405)
expect(await res.text()).toBe('Method Not Allowed')
})

it('Should include Allow header listing permitted methods', async () => {
const res = await app.request('/hello', { method: 'DELETE' })
expect(res.status).toBe(405)
const allow = res.headers.get('Allow')
expect(allow).toBeTruthy()
expect(allow!.split(', ')).toEqual(expect.arrayContaining(['GET', 'POST']))
})

it('Should return 404 for a path that does not exist', async () => {
const res = await app.request('/nonexistent')
expect(res.status).toBe(404)
})

it('Should return 405 with correct Allow header for parameterized routes', async () => {
const res = await app.request('/users/123', { method: 'POST' })
expect(res.status).toBe(405)
const allow = res.headers.get('Allow')
expect(allow).toBeTruthy()
expect(allow!.split(', ')).toEqual(expect.arrayContaining(['GET', 'PUT', 'DELETE']))
})

it('Should return 200 for a valid parameterized route', async () => {
const res = await app.request('/users/456')
expect(res.status).toBe(200)
expect(await res.text()).toBe('User 456')
})

it('Should not include the request method in the Allow header', async () => {
const res = await app.request('/hello', { method: 'PUT' })
const allow = res.headers.get('Allow')
expect(allow).toBeTruthy()
expect(allow!.split(', ')).not.toContain('PUT')
})
})

describe('Method Not Allowed with other middleware', () => {
const app = new Hono()

app.use(methodNotAllowed({ app }))

// Add another middleware alongside methodNotAllowed
app.use(async (c, next) => {
await next()
c.res.headers.set('X-Custom', 'test')
})

app.get('/api/data', (c) => c.json({ data: 'ok' }))

it('Should return 405 even when other middleware is present', async () => {
const res = await app.request('/api/data', { method: 'DELETE' })
expect(res.status).toBe(405)
expect(await res.text()).toBe('Method Not Allowed')
})

it('Should not interfere with successful requests', async () => {
const res = await app.request('/api/data')
expect(res.status).toBe(200)
})
})

describe('Method Not Allowed with sub-applications', () => {
const sub = new Hono()
sub.get('/info', (c) => c.text('Info'))
sub.post('/info', (c) => c.text('Created'))

const app = new Hono()
app.use(methodNotAllowed({ app }))
app.route('/sub', sub)

it('Should return 405 for unsupported method on sub-app route', async () => {
const res = await app.request('/sub/info', { method: 'DELETE' })
expect(res.status).toBe(405)
const allow = res.headers.get('Allow')
expect(allow).toBeTruthy()
expect(allow!.split(', ')).toEqual(expect.arrayContaining(['GET', 'POST']))
})

it('Should return 200 for valid sub-app route', async () => {
const res = await app.request('/sub/info')
expect(res.status).toBe(200)
expect(await res.text()).toBe('Info')
})
})

describe('Method Not Allowed with HEAD requests', () => {
const app = new Hono()
app.use(methodNotAllowed({ app }))
app.get('/page', (c) => c.text('Page content'))

it('Should return 200 for HEAD on a GET route (Hono handles HEAD automatically)', async () => {
const res = await app.request('/page', { method: 'HEAD' })
// Hono automatically supports HEAD for GET routes
expect(res.status).toBe(200)
})
})

describe('Method Not Allowed with OPTIONS requests', () => {
const app = new Hono()
app.use(methodNotAllowed({ app }))
app.get('/resource', (c) => c.text('Resource'))
app.options('/resource', () => {
return new Response(null, { status: 204, headers: { Allow: 'GET, OPTIONS' } })
})

it('Should return 204 for OPTIONS when explicitly defined', async () => {
const res = await app.request('/resource', { method: 'OPTIONS' })
expect(res.status).toBe(204)
})

it('Should return 405 for unsupported method', async () => {
const res = await app.request('/resource', { method: 'PATCH' })
expect(res.status).toBe(405)
})
})

describe('Method Not Allowed with all methods on one path', () => {
const app = new Hono()
app.use(methodNotAllowed({ app }))
app.all('/catch-all', (c) => c.text('Caught'))

it('Should not return 405 when app.all is used (any method is valid)', async () => {
const res = await app.request('/catch-all', { method: 'PATCH' })
expect(res.status).toBe(200)
expect(await res.text()).toBe('Caught')
})
})
87 changes: 87 additions & 0 deletions src/middleware/method-not-allowed/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* @module
* Method Not Allowed Middleware for Hono.
*/

import type { Hono } from '../../hono'
import { HTTPException } from '../../http-exception'
import { METHOD_NAME_ALL } from '../../router'
import { TrieRouter } from '../../router/trie-router/router'
import type { Env, MiddlewareHandler } from '../../types'

type MethodNotAllowedOptions = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app: Hono<any, any, any>
}

/**
* Method Not Allowed Middleware for Hono.
*
* @see {@link https://hono.dev/docs/middleware/builtin/method-not-allowed}
*
* @param {MethodNotAllowedOptions} options - The options for the method not allowed middleware.
* @param {Hono} options.app - The instance of Hono used in your application.
* @returns {MiddlewareHandler} The middleware handler function.
*
* @example
* ```ts
* const app = new Hono()
*
* app.use(methodNotAllowed({ app }))
*
* app.get('/hello', (c) => c.text('Hello!'))
* app.post('/hello', (c) => c.text('Posted!'))
*
* // GET /hello => 200 "Hello!"
* // POST /hello => 200 "Posted!"
* // PUT /hello => 405 Method Not Allowed (Allow: GET, POST)
* // GET /foo => 404 Not Found
* ```
*/
export const methodNotAllowed = <E extends Env = Env>(
options: MethodNotAllowedOptions
): MiddlewareHandler<E> => {
let methodNotAllowedRouter: TrieRouter<string[]> | undefined

return async function methodNotAllowed(c, next) {
await next()

if (c.res.status === 404) {
if (!methodNotAllowedRouter) {
const paths = new Map<string, string[]>()
for (const route of options.app.routes) {
if (route.method === METHOD_NAME_ALL) {
continue
}
const methods = paths.get(route.path) || []
methods.push(route.method.toUpperCase())
paths.set(route.path, methods)
}
methodNotAllowedRouter = new TrieRouter()
for (const [path, methods] of paths) {
methodNotAllowedRouter.add(METHOD_NAME_ALL, path, methods)
}
}

const allMethods = methodNotAllowedRouter
.match(METHOD_NAME_ALL, c.req.path)[0]
.reduce<string[]>((acc, [route]) => {
return acc.concat(route)
}, [])

const methods = [...new Set(allMethods)].filter(
(method) => method !== c.req.method.toUpperCase()
)

if (methods.length > 0) {
const res = new Response('Method Not Allowed', {
status: 405,
headers: {
Allow: methods.join(', '),
},
})
throw new HTTPException(405, { res })
}
}
}
}
Loading