Skip to content

Commit a313e10

Browse files
Copilotardatan
andauthored
[WIP] Implement router inheritance and merging for nested routers (#3654)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ardatan <20847995+ardatan@users.noreply.github.com>
1 parent 4fa4ef4 commit a313e10

File tree

6 files changed

+413
-0
lines changed

6 files changed

+413
-0
lines changed

.changeset/router-composition.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'fets': minor
3+
---
4+
5+
Add `use()` method for router composition and merging
6+
7+
Routers can now be composed together using the `.use()` method, allowing you to split routes
8+
across multiple files and merge them into a parent router.
9+
10+
```ts
11+
// users-router.ts
12+
const usersRouter = createRouter()
13+
.route({ path: '/users', method: 'GET', handler: () => Response.json({ users: [] }) })
14+
15+
// app.ts
16+
const app = createRouter()
17+
.use(usersRouter) // merge at existing paths
18+
.use('/api', anotherRouter) // merge under a prefix
19+
```
20+
21+
The `.use()` method supports:
22+
- Merging a sub-router at its existing paths
23+
- Merging with a path prefix
24+
- Sub-routers with their own `base` option
25+
- Transitive composition (a merged router can itself be merged)
26+
27+
Internal routes (e.g. OpenAPI schema endpoint, Swagger UI) from sub-routers are not propagated
28+
to the parent router.

packages/fets/src/createRouter.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ export function createRouterBase(
8585
>
8686
>
8787
>();
88+
const __routes: RouteWithSchemasOpts<
89+
any,
90+
RouterComponentsBase,
91+
RouteSchemas,
92+
HTTPMethod,
93+
string,
94+
TypedRequest,
95+
TypedResponse
96+
>[] = [];
8897

8998
function handleUnhandledRoute(requestPath: string) {
9099
if (landingPage) {
@@ -226,6 +235,9 @@ export function createRouterBase(
226235
TypedResponse
227236
>,
228237
) {
238+
if (!route.internal) {
239+
__routes.push(route);
240+
}
229241
for (const onRouteHook of onRouteHooks) {
230242
onRouteHook({
231243
basePath,
@@ -238,8 +250,30 @@ export function createRouterBase(
238250
}
239251
return this as any;
240252
},
253+
use(
254+
prefixOrSubRouter: string | Router<any, any, any>,
255+
subRouter?: Router<any, any, any>,
256+
) {
257+
let prefix = '';
258+
let actualSubRouter: Router<any, any, any>;
259+
if (typeof prefixOrSubRouter === 'string') {
260+
prefix = prefixOrSubRouter === '/' ? '' : prefixOrSubRouter;
261+
actualSubRouter = subRouter!;
262+
} else {
263+
actualSubRouter = prefixOrSubRouter;
264+
}
265+
const subBase = actualSubRouter.__base === '/' ? '' : actualSubRouter.__base;
266+
for (const subRoute of actualSubRouter.__routes) {
267+
const subPath = subRoute.path === '/' ? '' : subRoute.path;
268+
const newPath = `${prefix}${subBase}${subPath}` || '/';
269+
this.route({ ...subRoute, path: newPath });
270+
}
271+
return this as any;
272+
},
241273
__client: {},
242274
__onRouterInitHooks,
275+
__routes,
276+
__base: basePath,
243277
};
244278
}
245279

packages/fets/src/types.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,42 @@ export interface RouterBaseObject<
285285
TComponents,
286286
TRouterSDK & RouterSDK<TPath, TTypedRequest, TTypedResponse>
287287
>;
288+
use<
289+
TSubServerContext,
290+
TSubComponents extends RouterComponentsBase,
291+
TSubRouterSDK extends RouterSDK<string, TypedRequest, TypedResponse>,
292+
>(
293+
subRouter: Router<TSubServerContext, TSubComponents, TSubRouterSDK>,
294+
): Router<TServerContext, TComponents, TRouterSDK & TSubRouterSDK>;
295+
use<
296+
const TPrefix extends string,
297+
TSubServerContext,
298+
TSubComponents extends RouterComponentsBase,
299+
TSubRouterSDK extends RouterSDK<string, TypedRequest, TypedResponse>,
300+
>(
301+
prefix: TPrefix,
302+
subRouter: Router<TSubServerContext, TSubComponents, TSubRouterSDK>,
303+
): Router<
304+
TServerContext,
305+
TComponents,
306+
TRouterSDK & {
307+
[TKey in keyof TSubRouterSDK as TKey extends string
308+
? `${TPrefix}${TKey}`
309+
: TKey]: TSubRouterSDK[TKey];
310+
}
311+
>;
288312
__client: TRouterSDK;
289313
__onRouterInitHooks: OnRouterInitHook<TServerContext>[];
314+
__routes: RouteWithSchemasOpts<
315+
any,
316+
RouterComponentsBase,
317+
RouteSchemas,
318+
HTTPMethod,
319+
string,
320+
TypedRequest,
321+
TypedResponse
322+
>[];
323+
__base: string;
290324
}
291325

292326
export type Router<

packages/fets/tests/router.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,4 +207,112 @@ describe('Router', () => {
207207
const json = await response.json();
208208
expect(json).toEqual({ foo: { bar: 'baz' } });
209209
});
210+
211+
describe('use() - router merging', () => {
212+
it('merges a sub-router without prefix', async () => {
213+
const usersRouter = createRouter<any, {}>().route({
214+
path: '/users',
215+
method: 'GET',
216+
handler: () => Response.json({ users: ['alice', 'bob'] }),
217+
});
218+
const mainRouter = createRouter<any, {}>().use(usersRouter);
219+
const response = await mainRouter.fetch('http://localhost:3000/users');
220+
expect(response.status).toBe(200);
221+
const json = await response.json();
222+
expect(json.users).toEqual(['alice', 'bob']);
223+
});
224+
225+
it('merges a sub-router with a prefix', async () => {
226+
const usersRouter = createRouter<any, {}>().route({
227+
path: '/users',
228+
method: 'GET',
229+
handler: () => Response.json({ users: ['alice', 'bob'] }),
230+
});
231+
const mainRouter = createRouter<any, {}>().use('/api', usersRouter);
232+
const response = await mainRouter.fetch('http://localhost:3000/api/users');
233+
expect(response.status).toBe(200);
234+
const json = await response.json();
235+
expect(json.users).toEqual(['alice', 'bob']);
236+
});
237+
238+
it('merges a sub-router that has its own base path', async () => {
239+
const usersRouter = createRouter<any, {}>({ base: '/users' }).route({
240+
path: '/:id',
241+
method: 'GET',
242+
handler: request => Response.json({ id: request.params.id }),
243+
});
244+
const mainRouter = createRouter<any, {}>().use(usersRouter);
245+
const response = await mainRouter.fetch('http://localhost:3000/users/42');
246+
expect(response.status).toBe(200);
247+
const json = await response.json();
248+
expect(json.id).toBe('42');
249+
});
250+
251+
it('merges multiple sub-routers', async () => {
252+
const usersRouter = createRouter<any, {}>().route({
253+
path: '/users',
254+
method: 'GET',
255+
handler: () => Response.json({ resource: 'users' }),
256+
});
257+
const postsRouter = createRouter<any, {}>().route({
258+
path: '/posts',
259+
method: 'GET',
260+
handler: () => Response.json({ resource: 'posts' }),
261+
});
262+
const mainRouter = createRouter<any, {}>().use(usersRouter).use(postsRouter);
263+
const usersResponse = await mainRouter.fetch('http://localhost:3000/users');
264+
expect(usersResponse.status).toBe(200);
265+
expect(await usersResponse.json()).toEqual({ resource: 'users' });
266+
const postsResponse = await mainRouter.fetch('http://localhost:3000/posts');
267+
expect(postsResponse.status).toBe(200);
268+
expect(await postsResponse.json()).toEqual({ resource: 'posts' });
269+
});
270+
271+
it('merges sub-router with prefix and route params', async () => {
272+
const itemsRouter = createRouter<any, {}>().route({
273+
path: '/:id',
274+
method: 'GET',
275+
handler: request => Response.json({ id: request.params.id }),
276+
});
277+
const mainRouter = createRouter<any, {}>().use('/items', itemsRouter);
278+
const response = await mainRouter.fetch('http://localhost:3000/items/99');
279+
expect(response.status).toBe(200);
280+
const json = await response.json();
281+
expect(json.id).toBe('99');
282+
});
283+
284+
it('does not register sub-router internal routes in merged router', async () => {
285+
const subRouter = createRouter<any, {}>({
286+
openAPI: { endpoint: '/openapi.json' },
287+
}).route({
288+
path: '/hello',
289+
method: 'GET',
290+
handler: () => Response.json({ hello: 'world' }),
291+
});
292+
const mainRouter = createRouter<any, {}>().use('/sub', subRouter);
293+
// The sub-router's internal /openapi.json should NOT be re-registered in the merged router
294+
const mergedPaths = mainRouter.__routes.map((r: any) => r.path);
295+
expect(mergedPaths).not.toContain('/sub/openapi.json');
296+
// The user-defined route should be in __routes with the prefix applied
297+
expect(mergedPaths).toContain('/sub/hello');
298+
// The merged route should be accessible
299+
const helloResponse = await mainRouter.fetch('http://localhost:3000/sub/hello');
300+
expect(helloResponse.status).toBe(200);
301+
});
302+
303+
it('handles transitive use() - router merged into another merged router', async () => {
304+
const deepRouter = createRouter<any, {}>({ landingPage: false }).route({
305+
path: '/deep',
306+
method: 'GET',
307+
handler: () => Response.json({ level: 'deep' }),
308+
});
309+
const midRouter = createRouter<any, {}>({ landingPage: false }).use('/mid', deepRouter);
310+
const topRouter = createRouter<any, {}>({ landingPage: false }).use('/top', midRouter);
311+
312+
const response = await topRouter.fetch('http://localhost:3000/top/mid/deep');
313+
expect(response.status).toBe(200);
314+
const json = await response.json();
315+
expect(json).toEqual({ level: 'deep' });
316+
});
317+
});
210318
});

website/src/pages/server/_meta.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export default {
33
'type-safety-and-validation': 'Type-Safety & Validation',
44
'error-handling': 'Error Handling',
55
'programmatic-schemas': 'Programmatic Schemas with TypeBox',
6+
'router-composition': 'Router Composition',
67
testing: 'Testing',
78
cors: 'CORS',
89
cookies: 'Cookies',

0 commit comments

Comments
 (0)