Skip to content

Commit bea652e

Browse files
committed
fix(backend): redirect trailing-slash API requests instead of 404
Hono routes strictly, so `/api/vllm/recipes/` returned `404 Route not found` while `/api/vllm/recipes` worked. Any caller that appended a stray trailing slash (curl, a proxy, a copied URL) silently broke. - Register Hono's `trimTrailingSlash()` middleware app-wide in `hono-app.ts`. It only acts on a would-be 404 `GET`/`HEAD`, 301-redirecting to the no-slash path, so it never changes the outcome of an already-matched route, and it fixes every route, not just recipes. - Add `vllmRecipes.test.ts` route-shape tests: trailing-slash `GET`s now 301-redirect to the no-slash path, while `POST /resolve/` is left to strict routing and still 404s (the middleware only redirects GET/HEAD). Signed-off-by: Suraj Deshmukh <suraj.deshmukh@microsoft.com>
1 parent 433909f commit bea652e

2 files changed

Lines changed: 101 additions & 0 deletions

File tree

backend/src/hono-app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Hono } from 'hono';
22
import { cors } from 'hono/cors';
33
import { compress } from 'hono/compress';
4+
import { trimTrailingSlash } from 'hono/trailing-slash';
45
import { HTTPException } from 'hono/http-exception';
56

67
import { authService } from './services/auth';
@@ -106,6 +107,11 @@ const app = new Hono<AppEnv>();
106107

107108
// Global middleware
108109
app.use('*', compress());
110+
// Treat a trailing slash as equivalent to no slash: Hono routes strictly, so
111+
// "/api/vllm/recipes/" would otherwise 404 while "/api/vllm/recipes" works.
112+
// This only acts on a would-be 404 GET/HEAD, 301-redirecting to the no-slash
113+
// path, so it never changes the outcome of an already-matched route.
114+
app.use('*', trimTrailingSlash());
109115
app.use(
110116
'*',
111117
cors({
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, test, expect, afterEach, mock } from 'bun:test';
2+
import app from '../hono-app';
3+
4+
// Routing-shape tests for the vLLM recipes router. The point is to pin down
5+
// trailing-slash vs no-slash behavior. Hono routes strictly ("/x" != "/x/"),
6+
// so the app registers `trimTrailingSlash()` middleware globally: a GET/HEAD
7+
// whose trailing-slash form would 404 gets a 301 redirect to the no-slash path.
8+
// These tests guard that a stray trailing slash on a GET resolves (via 301)
9+
// instead of dead-ending at 404, while non-GET/HEAD methods (POST) are left to
10+
// strict routing and still 404.
11+
//
12+
// fetch is mocked so the no-slash (route-matched) cases don't make a real
13+
// network call to recipes.vllm.ai — we assert on ROUTING, not upstream content.
14+
const originalFetch = globalThis.fetch;
15+
16+
function mockRecipeFetch(payload: unknown) {
17+
// @ts-expect-error - mocking fetch for tests
18+
globalThis.fetch = mock(() =>
19+
Promise.resolve({
20+
ok: true,
21+
status: 200,
22+
statusText: 'OK',
23+
headers: { get: () => null },
24+
arrayBuffer: () => Promise.resolve(new TextEncoder().encode(JSON.stringify(payload)).buffer),
25+
json: () => Promise.resolve(payload),
26+
text: () => Promise.resolve(JSON.stringify(payload)),
27+
} as unknown as Response)
28+
);
29+
}
30+
31+
describe('vLLM Recipes route shapes (trailing slash vs none)', () => {
32+
afterEach(() => {
33+
globalThis.fetch = originalFetch;
34+
});
35+
36+
describe('GET list route /api/vllm/recipes', () => {
37+
test('no trailing slash → route matches (not 404)', async () => {
38+
mockRecipeFetch({ models: [] });
39+
const res = await app.request('/api/vllm/recipes');
40+
// Route is matched and handled; the exact 2xx body depends on the mock,
41+
// but it must NOT be the router's 404 "Route not found".
42+
expect(res.status).not.toBe(404);
43+
});
44+
45+
test('trailing slash → 301 redirect to the no-slash path', async () => {
46+
mockRecipeFetch({ models: [] });
47+
const res = await app.request('/api/vllm/recipes/');
48+
expect(res.status).toBe(301);
49+
expect(new URL(res.headers.get('location')!).pathname).toBe('/api/vllm/recipes');
50+
});
51+
});
52+
53+
describe('GET per-model route /api/vllm/recipes/:org/:model', () => {
54+
test('no trailing slash → route matches (not 404)', async () => {
55+
mockRecipeFetch({ recommended_command: { argv: ['vllm', 'serve', 'x'] } });
56+
const res = await app.request('/api/vllm/recipes/microsoft/Phi-4-mini-instruct');
57+
expect(res.status).not.toBe(404);
58+
});
59+
60+
test('trailing slash → 301 redirect to the no-slash path', async () => {
61+
mockRecipeFetch({ recommended_command: { argv: ['vllm', 'serve', 'x'] } });
62+
const res = await app.request('/api/vllm/recipes/microsoft/Phi-4-mini-instruct/');
63+
expect(res.status).toBe(301);
64+
expect(new URL(res.headers.get('location')!).pathname).toBe(
65+
'/api/vllm/recipes/microsoft/Phi-4-mini-instruct'
66+
);
67+
});
68+
69+
test('single path segment is not the two-segment model route → 404', async () => {
70+
const res = await app.request('/api/vllm/recipes/onlyoneseg');
71+
expect(res.status).toBe(404);
72+
});
73+
});
74+
75+
describe('POST resolve route /api/vllm/recipes/resolve', () => {
76+
test('no trailing slash → route matches (not 404)', async () => {
77+
mockRecipeFetch({ recommended_command: { argv: ['vllm', 'serve', 'a/b'] } });
78+
const res = await app.request('/api/vllm/recipes/resolve', {
79+
method: 'POST',
80+
headers: { 'Content-Type': 'application/json' },
81+
body: JSON.stringify({ modelId: 'a/b' }),
82+
});
83+
expect(res.status).not.toBe(404);
84+
});
85+
86+
test('trailing slash → 404 (trimTrailingSlash only redirects GET/HEAD, not POST)', async () => {
87+
const res = await app.request('/api/vllm/recipes/resolve/', {
88+
method: 'POST',
89+
headers: { 'Content-Type': 'application/json' },
90+
body: JSON.stringify({ modelId: 'a/b' }),
91+
});
92+
expect(res.status).toBe(404);
93+
});
94+
});
95+
});

0 commit comments

Comments
 (0)