Skip to content

Commit 1f707c5

Browse files
authored
feat(middleware): add method-not-allowed middleware (#5132)
* feat(middleware): add method-not-allowed * test(middleware): cover method-not-allowed * chore(exports): expose method-not-allowed Resolve #4633
1 parent 2df0b47 commit 1f707c5

4 files changed

Lines changed: 442 additions & 0 deletions

File tree

jsr.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"./timeout": "./src/middleware/timeout/index.ts",
4646
"./timing": "./src/middleware/timing/timing.ts",
4747
"./logger": "./src/middleware/logger/index.ts",
48+
"./method-not-allowed": "./src/middleware/method-not-allowed/index.ts",
4849
"./method-override": "./src/middleware/method-override/index.ts",
4950
"./powered-by": "./src/middleware/powered-by/index.ts",
5051
"./pretty-json": "./src/middleware/pretty-json/index.ts",

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,11 @@
231231
"import": "./dist/middleware/logger/index.js",
232232
"require": "./dist/cjs/middleware/logger/index.js"
233233
},
234+
"./method-not-allowed": {
235+
"types": "./dist/types/middleware/method-not-allowed/index.d.ts",
236+
"import": "./dist/middleware/method-not-allowed/index.js",
237+
"require": "./dist/cjs/middleware/method-not-allowed/index.js"
238+
},
234239
"./method-override": {
235240
"types": "./dist/types/middleware/method-override/index.d.ts",
236241
"import": "./dist/middleware/method-override/index.js",
@@ -528,6 +533,9 @@
528533
"logger": [
529534
"./dist/types/middleware/logger"
530535
],
536+
"method-not-allowed": [
537+
"./dist/types/middleware/method-not-allowed"
538+
],
531539
"method-override": [
532540
"./dist/types/middleware/method-override"
533541
],
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import { Hono } from '../../hono'
2+
import { methodNotAllowed } from '.'
3+
4+
describe('Method Not Allowed Middleware', () => {
5+
it('infers the environment from the app', () => {
6+
const app = new Hono<{ Variables: { requestId: string } }>()
7+
const middleware = methodNotAllowed({
8+
app,
9+
onMethodNotAllowed: (c) => c.text(c.var.requestId, 405),
10+
})
11+
12+
expect(middleware).toBeTypeOf('function')
13+
})
14+
15+
it('returns 405 with every allowed method for an existing path', async () => {
16+
const app = new Hono()
17+
app.use(methodNotAllowed({ app }))
18+
app.get('/resource', (c) => c.text('GET'))
19+
app.post('/resource', (c) => c.text('POST'))
20+
app.delete('/resource', (c) => c.text('DELETE'))
21+
22+
const res = await app.request('/resource', { method: 'PUT' })
23+
24+
expect(res.status).toBe(405)
25+
expect(res.headers.get('Allow')).toBe('GET, HEAD, POST, DELETE')
26+
expect(await res.text()).toBe('Method Not Allowed')
27+
28+
const resUsingCachedRouter = await app.request('/resource', { method: 'OPTIONS' })
29+
expect(resUsingCachedRouter.status).toBe(405)
30+
expect(resUsingCachedRouter.headers.get('Allow')).toBe('GET, HEAD, POST, DELETE')
31+
})
32+
33+
it('does not affect an allowed method', async () => {
34+
const app = new Hono()
35+
app.use(methodNotAllowed({ app }))
36+
app.get('/resource', (c) => c.text('GET'))
37+
38+
const res = await app.request('/resource')
39+
40+
expect(res.status).toBe(200)
41+
expect(res.headers.has('Allow')).toBe(false)
42+
expect(await res.text()).toBe('GET')
43+
})
44+
45+
it('returns 404 when the path does not exist', async () => {
46+
const app = new Hono()
47+
app.use(methodNotAllowed({ app }))
48+
app.get('/resource', (c) => c.text('GET'))
49+
50+
const res = await app.request('/missing', { method: 'POST' })
51+
52+
expect(res.status).toBe(404)
53+
expect(res.headers.has('Allow')).toBe(false)
54+
})
55+
56+
it('matches parameterized and overlapping routes without duplicate methods', async () => {
57+
const app = new Hono()
58+
app.use(methodNotAllowed({ app }))
59+
app.get('/users/:id', (_c, next) => next())
60+
app.get('/users/me', (c) => c.text('me'))
61+
app.patch('/users/:id', (c) => c.text(c.req.param('id')))
62+
63+
const res = await app.request('/users/me', { method: 'POST' })
64+
65+
expect(res.status).toBe(405)
66+
expect(res.headers.get('Allow')).toBe('GET, HEAD, PATCH')
67+
})
68+
69+
it('preserves an intentional 404 from a handler registered for the request method', async () => {
70+
const app = new Hono()
71+
app.use(methodNotAllowed({ app }))
72+
app.get('/users/:id', (c) => c.notFound())
73+
app.post('/users/:id', (c) => c.text('Created'))
74+
75+
const res = await app.request('/users/123')
76+
77+
expect(res.status).toBe(404)
78+
expect(res.headers.has('Allow')).toBe(false)
79+
})
80+
81+
it('preserves an intentional 404 for HEAD when a GET route is registered', async () => {
82+
const app = new Hono()
83+
app.use(methodNotAllowed({ app }))
84+
app.get('/users/:id', (c) => c.notFound())
85+
app.post('/users/:id', (c) => c.text('Created'))
86+
87+
const res = await app.request('/users/123', { method: 'HEAD' })
88+
89+
expect(res.status).toBe(404)
90+
expect(res.headers.has('Allow')).toBe(false)
91+
})
92+
93+
it('preserves a 404 returned by the error handler', async () => {
94+
const app = new Hono()
95+
app.onError((_error, c) => c.text('Handled error', 404))
96+
app.use(methodNotAllowed({ app }))
97+
app.use('/resource', () => {
98+
throw new Error('boom')
99+
})
100+
app.get('/resource', (c) => c.text('GET'))
101+
102+
const res = await app.request('/resource', { method: 'POST' })
103+
104+
expect(res.status).toBe(404)
105+
expect(res.headers.has('Allow')).toBe(false)
106+
expect(await res.text()).toBe('Handled error')
107+
})
108+
109+
it('returns 405 for HEAD when the path does not support GET', async () => {
110+
const app = new Hono()
111+
app.use(methodNotAllowed({ app }))
112+
app.post('/resource', (c) => c.text('POST'))
113+
114+
const res = await app.request('/resource', { method: 'HEAD' })
115+
116+
expect(res.status).toBe(405)
117+
expect(res.headers.get('Allow')).toBe('POST')
118+
expect(await res.text()).toBe('')
119+
})
120+
121+
it('does not advertise explicit HEAD routes that Hono cannot dispatch', async () => {
122+
const app = new Hono()
123+
app.use(methodNotAllowed({ app }))
124+
app.on('HEAD', '/resource', (c) => c.text('HEAD'))
125+
126+
const headRes = await app.request('/resource', { method: 'HEAD' })
127+
expect(headRes.status).toBe(404)
128+
expect(headRes.headers.has('Allow')).toBe(false)
129+
130+
const putRes = await app.request('/resource', { method: 'PUT' })
131+
expect(putRes.status).toBe(404)
132+
expect(putRes.headers.has('Allow')).toBe(false)
133+
})
134+
135+
it('supports custom methods', async () => {
136+
const app = new Hono()
137+
app.use(methodNotAllowed({ app }))
138+
app.on('PURGE', '/cache', (c) => c.text('Purged'))
139+
140+
const res = await app.request('/cache', { method: 'POST' })
141+
142+
expect(res.status).toBe(405)
143+
expect(res.headers.get('Allow')).toBe('PURGE')
144+
})
145+
146+
it('compares request methods case-sensitively', async () => {
147+
const app = new Hono()
148+
app.use(methodNotAllowed({ app }))
149+
app.patch('/item', (c) => c.text('PATCH'))
150+
151+
const request = new Request('http://localhost/item', { method: 'PATCH' })
152+
Object.defineProperty(request, 'method', { value: 'patch' })
153+
const res = await app.request(request)
154+
155+
expect(res.status).toBe(405)
156+
expect(res.headers.get('Allow')).toBe('PATCH')
157+
})
158+
159+
it('works with routes mounted using app.route()', async () => {
160+
const api = new Hono()
161+
api.get('/resource', (c) => c.text('GET'))
162+
api.post('/resource', (c) => c.text('POST'))
163+
164+
const app = new Hono()
165+
app.use(methodNotAllowed({ app }))
166+
app.route('/api', api)
167+
168+
const res = await app.request('/api/resource', { method: 'DELETE' })
169+
170+
expect(res.status).toBe(405)
171+
expect(res.headers.get('Allow')).toBe('GET, HEAD, POST')
172+
})
173+
174+
it('works when installed in an app mounted using app.route()', async () => {
175+
const api = new Hono().basePath('/v1')
176+
api.use(methodNotAllowed({ app: api }))
177+
api.get('/resource', (c) => c.text('GET'))
178+
api.post('/resource', (c) => c.text('POST'))
179+
180+
const app = new Hono()
181+
app.route('/api', api)
182+
183+
const res = await app.request('/api/v1/resource', { method: 'DELETE' })
184+
185+
expect(res.status).toBe(405)
186+
expect(res.headers.get('Allow')).toBe('GET, HEAD, POST')
187+
})
188+
189+
it('ignores ALL routes when collecting allowed methods', async () => {
190+
const app = new Hono()
191+
app.use(methodNotAllowed({ app }))
192+
app.all('/resource', (_c, next) => next())
193+
194+
const res = await app.request('/resource', { method: 'POST' })
195+
196+
expect(res.status).toBe(404)
197+
expect(res.headers.has('Allow')).toBe(false)
198+
})
199+
200+
it('does not invoke the error handler for a 405 response', async () => {
201+
const app = new Hono()
202+
const onError = vi.fn(() => new Response('error', { status: 500 }))
203+
app.onError(onError)
204+
app.use(methodNotAllowed({ app }))
205+
app.get('/resource', (c) => c.text('GET'))
206+
207+
const res = await app.request('/resource', { method: 'POST' })
208+
209+
expect(res.status).toBe(405)
210+
expect(onError).not.toHaveBeenCalled()
211+
})
212+
213+
it('preserves downstream headers and lets outer middleware observe the final status', async () => {
214+
const app = new Hono()
215+
let observedStatus: number | undefined
216+
217+
app.use(async (c, next) => {
218+
await next()
219+
observedStatus = c.res.status
220+
})
221+
app.use(methodNotAllowed({ app }))
222+
app.use(async (c, next) => {
223+
await next()
224+
c.header('X-Downstream', 'true')
225+
})
226+
app.get('/resource', (c) => c.text('GET'))
227+
228+
const res = await app.request('/resource', { method: 'POST' })
229+
230+
expect(res.status).toBe(405)
231+
expect(res.headers.get('X-Downstream')).toBe('true')
232+
expect(observedStatus).toBe(405)
233+
})
234+
235+
it('replaces stale representation headers from the not-found response', async () => {
236+
const app = new Hono()
237+
app.use(methodNotAllowed({ app }))
238+
app.get('/resource', (c) => c.text('GET'))
239+
app.post('/resource', (c) => c.text('POST'))
240+
app.notFound((c) =>
241+
c.text('Missing', 404, {
242+
Allow: 'BOGUS',
243+
'Content-Length': '7',
244+
})
245+
)
246+
247+
const res = await app.request('/resource', { method: 'PUT' })
248+
249+
expect(res.status).toBe(405)
250+
expect(res.headers.get('Allow')).toBe('GET, HEAD, POST')
251+
expect(res.headers.has('Content-Length')).toBe(false)
252+
expect(await res.text()).toBe('Method Not Allowed')
253+
})
254+
255+
it('supports a custom method-not-allowed response', async () => {
256+
const app = new Hono()
257+
app.use(
258+
methodNotAllowed({
259+
app,
260+
onMethodNotAllowed: (c, allowedMethods) =>
261+
c.json({ error: 'Method Not Allowed', allowedMethods }, 405, {
262+
Allow: allowedMethods.join(', '),
263+
}),
264+
})
265+
)
266+
app.get('/resource', (c) => c.text('GET'))
267+
app.post('/resource', (c) => c.text('POST'))
268+
269+
const res = await app.request('/resource', { method: 'PUT' })
270+
271+
expect(res.status).toBe(405)
272+
expect(res.headers.get('Allow')).toBe('GET, HEAD, POST')
273+
expect(res.headers.get('Content-Type')).toMatch(/^application\/json/)
274+
expect(await res.json()).toEqual({
275+
error: 'Method Not Allowed',
276+
allowedMethods: ['GET', 'HEAD', 'POST'],
277+
})
278+
})
279+
280+
it('leaves a non-404 custom not-found response unchanged', async () => {
281+
const app = new Hono()
282+
app.use(methodNotAllowed({ app }))
283+
app.get('/resource', (c) => c.text('GET'))
284+
app.notFound((c) => c.text('Missing', 410))
285+
286+
const res = await app.request('/resource', { method: 'POST' })
287+
288+
expect(res.status).toBe(410)
289+
expect(res.headers.has('Allow')).toBe(false)
290+
expect(await res.text()).toBe('Missing')
291+
})
292+
})

0 commit comments

Comments
 (0)