Skip to content

Commit 7143c4a

Browse files
committed
feat: add 'methodNotAllowed' handler for '405 Method Not Allowed' responses
1 parent 28452f0 commit 7143c4a

File tree

4 files changed

+270
-0
lines changed

4 files changed

+270
-0
lines changed

src/hono-base.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
HandlerInterface,
1818
MergePath,
1919
MergeSchemaPath,
20+
MethodNotAllowedHandler,
2021
MiddlewareHandler,
2122
MiddlewareHandlerInterface,
2223
Next,
@@ -179,11 +180,14 @@ class Hono<
179180
})
180181
clone.errorHandler = this.errorHandler
181182
clone.#notFoundHandler = this.#notFoundHandler
183+
clone.#methodNotAllowedHandler = this.#methodNotAllowedHandler
182184
clone.routes = this.routes
183185
return clone
184186
}
185187

186188
#notFoundHandler: NotFoundHandler = notFoundHandler
189+
#methodNotAllowedHandler: MethodNotAllowedHandler | null = null
190+
187191
// Cannot use `#` because it requires visibility at JavaScript runtime.
188192
private errorHandler: ErrorHandler = errorHandler
189193

@@ -293,6 +297,28 @@ class Hono<
293297
return this
294298
}
295299

300+
/**
301+
* `.methodNotAllowed()` allows you to customize a Method Not Allowed Response.
302+
*
303+
* @see {@link https://hono.dev/docs/api/hono#method-not-allowed}
304+
*
305+
* @param {MethodNotAllowedHandler} handler - request handler for method-not-allowed
306+
* @returns {Hono} changed Hono instance
307+
*
308+
* @example
309+
* ```ts
310+
* app.methodNotAllowed((c, allowedMethods) => {
311+
* return c.text(`Method not allowed. Allowed: ${allowedMethods.join(', ')}`, 405, {
312+
* Allow: allowedMethods.join(', ').toUpperCase()
313+
* })
314+
* })
315+
* ```
316+
*/
317+
methodNotAllowed = (handler: MethodNotAllowedHandler<E>): Hono<E, S, BasePath, CurrentPath> => {
318+
this.#methodNotAllowedHandler = handler
319+
return this
320+
}
321+
296322
/**
297323
* `.mount()` allows you to mount applications built with other frameworks into your Hono application.
298324
*
@@ -390,6 +416,24 @@ class Hono<
390416
this.routes.push(r)
391417
}
392418

419+
#findAllowedMethods(path: string): string[] {
420+
const allowedMethods = new Set<string>()
421+
const allMethods = METHODS.map((m) => m.toUpperCase())
422+
423+
for (const method of allMethods) {
424+
const matchResult = this.router.match(method, path)
425+
// Check if there are any method-specific routes (excluding METHOD_NAME_ALL middleware)
426+
const hasMethodSpecificRoute = matchResult[0].some(
427+
(match: [[H, RouterRoute], unknown]) => match[0][1].method !== METHOD_NAME_ALL
428+
)
429+
if (hasMethodSpecificRoute) {
430+
allowedMethods.add(method)
431+
}
432+
}
433+
434+
return Array.from(allowedMethods)
435+
}
436+
393437
#handleError(err: unknown, c: Context<E>): Response | Promise<Response> {
394438
if (err instanceof Error) {
395439
return this.errorHandler(err, c)
@@ -420,6 +464,19 @@ class Hono<
420464
notFoundHandler: this.#notFoundHandler,
421465
})
422466

467+
// Check if there are any method-specific routes (excluding METHOD_NAME_ALL middleware)
468+
const hasMethodSpecificRoute = matchResult[0].some(
469+
(match: [[H, RouterRoute], unknown]) => match[0][1].method !== METHOD_NAME_ALL
470+
)
471+
472+
// Check for method not allowed if no method-specific match found and handler is set
473+
if (!hasMethodSpecificRoute && this.#methodNotAllowedHandler) {
474+
const allowedMethods = this.#findAllowedMethods(path)
475+
if (allowedMethods.length > 0) {
476+
return this.#methodNotAllowedHandler(c, allowedMethods)
477+
}
478+
}
479+
423480
// Do not `compose` if it has only one handler
424481
if (matchResult[0].length === 1) {
425482
let res: ReturnType<H>

src/hono.test.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1340,6 +1340,200 @@ describe('Not Found', () => {
13401340
})
13411341
})
13421342

1343+
describe('Method Not Allowed', () => {
1344+
it('Should return 404 by default when method is not allowed', async () => {
1345+
const app = new Hono()
1346+
1347+
app.get('/hello', (c) => {
1348+
return c.text('hello')
1349+
})
1350+
1351+
const res = await app.request('http://localhost/hello', { method: 'POST' })
1352+
expect(res.status).toBe(404)
1353+
expect(await res.text()).toBe('404 Not Found')
1354+
})
1355+
1356+
it('Should return 405 when methodNotAllowed handler is set', async () => {
1357+
const app = new Hono()
1358+
1359+
app.get('/hello', (c) => {
1360+
return c.text('hello')
1361+
})
1362+
1363+
app.methodNotAllowed((c, allowedMethods) => {
1364+
return c.text('405 Method Not Allowed', 405, {
1365+
Allow: allowedMethods.join(', ').toUpperCase(),
1366+
})
1367+
})
1368+
1369+
const res = await app.request('http://localhost/hello', { method: 'POST' })
1370+
expect(res.status).toBe(405)
1371+
expect(res.headers.get('Allow')).toBe('GET')
1372+
expect(await res.text()).toBe('405 Method Not Allowed')
1373+
})
1374+
1375+
it('Should return 404 when path does not exist even with methodNotAllowed handler', async () => {
1376+
const app = new Hono()
1377+
1378+
app.get('/hello', (c) => {
1379+
return c.text('hello')
1380+
})
1381+
1382+
app.methodNotAllowed((c, allowedMethods) => {
1383+
return c.text('405 Method Not Allowed', 405, {
1384+
Allow: allowedMethods.join(', ').toUpperCase(),
1385+
})
1386+
})
1387+
1388+
const res = await app.request('http://localhost/nonexistent', { method: 'POST' })
1389+
expect(res.status).toBe(404)
1390+
expect(await res.text()).toBe('404 Not Found')
1391+
})
1392+
1393+
it('Should include all allowed methods in Allow header', async () => {
1394+
const app = new Hono()
1395+
1396+
app.get('/hello', (c) => c.text('GET'))
1397+
app.post('/hello', (c) => c.text('POST'))
1398+
app.put('/hello', (c) => c.text('PUT'))
1399+
1400+
app.methodNotAllowed((c, allowedMethods) => {
1401+
return c.text('405 Method Not Allowed', 405, {
1402+
Allow: allowedMethods.join(', ').toUpperCase(),
1403+
})
1404+
})
1405+
1406+
const res = await app.request('http://localhost/hello', { method: 'DELETE' })
1407+
expect(res.status).toBe(405)
1408+
const allowHeader = res.headers.get('Allow')
1409+
expect(allowHeader).toContain('GET')
1410+
expect(allowHeader).toContain('POST')
1411+
expect(allowHeader).toContain('PUT')
1412+
expect(allowHeader?.split(', ').length).toBe(3)
1413+
})
1414+
1415+
it('Should work with custom methodNotAllowed handler', async () => {
1416+
const app = new Hono()
1417+
1418+
app.get('/hello', (c) => c.text('hello'))
1419+
1420+
app.methodNotAllowed((c, allowedMethods) => {
1421+
return c.json({ error: 'Method not allowed', allowed: allowedMethods }, 405, {
1422+
Allow: allowedMethods.join(', ').toUpperCase(),
1423+
})
1424+
})
1425+
1426+
const res = await app.request('http://localhost/hello', { method: 'POST' })
1427+
expect(res.status).toBe(405)
1428+
expect(res.headers.get('Allow')).toBe('GET')
1429+
const json = await res.json()
1430+
expect(json.error).toBe('Method not allowed')
1431+
expect(json.allowed).toEqual(['GET'])
1432+
})
1433+
1434+
it('Should work with routes with parameters', async () => {
1435+
const app = new Hono()
1436+
1437+
app.get('/user/:id', (c) => {
1438+
return c.text(`User ${c.req.param('id')}`)
1439+
})
1440+
1441+
app.methodNotAllowed((c, allowedMethods) => {
1442+
return c.text('405 Method Not Allowed', 405, {
1443+
Allow: allowedMethods.join(', ').toUpperCase(),
1444+
})
1445+
})
1446+
1447+
const res = await app.request('http://localhost/user/123', { method: 'POST' })
1448+
expect(res.status).toBe(405)
1449+
expect(res.headers.get('Allow')).toBe('GET')
1450+
})
1451+
1452+
it('Should work with HEAD method (which maps to GET)', async () => {
1453+
const app = new Hono()
1454+
1455+
app.get('/hello', (c) => c.text('hello'))
1456+
1457+
app.methodNotAllowed((c, allowedMethods) => {
1458+
return c.text('405 Method Not Allowed', 405, {
1459+
Allow: allowedMethods.join(', ').toUpperCase(),
1460+
})
1461+
})
1462+
1463+
// HEAD should work (maps to GET)
1464+
const headRes = await app.request('http://localhost/hello', { method: 'HEAD' })
1465+
expect(headRes.status).toBe(200)
1466+
1467+
// But POST should return 405
1468+
const postRes = await app.request('http://localhost/hello', { method: 'POST' })
1469+
expect(postRes.status).toBe(405)
1470+
expect(postRes.headers.get('Allow')).toBe('GET')
1471+
})
1472+
1473+
it('Should work with app.route()', async () => {
1474+
const app = new Hono()
1475+
const subApp = new Hono()
1476+
1477+
subApp.get('/hello', (c) => c.text('hello'))
1478+
app.route('/api', subApp)
1479+
1480+
app.methodNotAllowed((c, allowedMethods) => {
1481+
return c.text('405 Method Not Allowed', 405, {
1482+
Allow: allowedMethods.join(', ').toUpperCase(),
1483+
})
1484+
})
1485+
1486+
const res = await app.request('http://localhost/api/hello', { method: 'POST' })
1487+
expect(res.status).toBe(405)
1488+
expect(res.headers.get('Allow')).toBe('GET')
1489+
})
1490+
1491+
it('Should work with multiple methods on same path', async () => {
1492+
const app = new Hono()
1493+
1494+
app.get('/resource', (c) => c.text('GET'))
1495+
app.post('/resource', (c) => c.text('POST'))
1496+
app.delete('/resource', (c) => c.text('DELETE'))
1497+
1498+
app.methodNotAllowed((c, allowedMethods) => {
1499+
return c.text('405 Method Not Allowed', 405, {
1500+
Allow: allowedMethods.join(', ').toUpperCase(),
1501+
})
1502+
})
1503+
1504+
const res = await app.request('http://localhost/resource', { method: 'PUT' })
1505+
expect(res.status).toBe(405)
1506+
const allowHeader = res.headers.get('Allow')
1507+
expect(allowHeader).toContain('GET')
1508+
expect(allowHeader).toContain('POST')
1509+
expect(allowHeader).toContain('DELETE')
1510+
})
1511+
1512+
it('Should work with app.use() middleware', async () => {
1513+
const app = new Hono()
1514+
1515+
app.methodNotAllowed((c, allowedMethods) => {
1516+
return c.text(`Method not allowed. Allowed: ${allowedMethods.join(', ')}`, 405, {
1517+
Allow: allowedMethods.join(', ').toUpperCase(),
1518+
})
1519+
})
1520+
1521+
// Adding middleware should not prevent methodNotAllowed from being called
1522+
app.use(async (c, next) => {
1523+
await next()
1524+
})
1525+
1526+
app.get('/', (c) => {
1527+
return new Response('Hello, World!')
1528+
})
1529+
1530+
const res = await app.request('http://localhost/', { method: 'POST' })
1531+
expect(res.status).toBe(405)
1532+
expect(res.headers.get('Allow')).toBe('GET')
1533+
expect(await res.text()).toBe('Method not allowed. Allowed: GET')
1534+
})
1535+
})
1536+
13431537
describe('Redirect', () => {
13441538
const app = new Hono()
13451539
app.get('/redirect', (c) => {

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export type {
2424
ErrorHandler,
2525
Handler,
2626
MiddlewareHandler,
27+
MethodNotAllowedHandler,
28+
MethodNotAllowedResponse,
2729
Next,
2830
NotFoundResponse,
2931
NotFoundHandler,

src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,23 @@ export type NotFoundHandler<E extends Env = any> = (
111111
? NotFoundResponse | Promise<NotFoundResponse>
112112
: Response | Promise<Response>
113113

114+
/**
115+
* You can extend this interface to define a custom `c.methodNotAllowed()` Response type.
116+
*
117+
* @example
118+
* declare module 'hono' {
119+
* interface MethodNotAllowedResponse extends Response, TypedResponse<string, 405, 'text'> {}
120+
* }
121+
*/
122+
export interface MethodNotAllowedResponse {}
123+
124+
export type MethodNotAllowedHandler<E extends Env = any> = (
125+
c: Context<E>,
126+
allowedMethods: string[]
127+
) => MethodNotAllowedResponse extends Response
128+
? MethodNotAllowedResponse | Promise<MethodNotAllowedResponse>
129+
: Response | Promise<Response>
130+
114131
export interface HTTPResponseError extends Error {
115132
getResponse: () => Response
116133
}

0 commit comments

Comments
 (0)