Skip to content

Commit 9a810f8

Browse files
authored
fix(rest): register static data-action routes before greedy :object/:id (#2315)
1 parent df5a2b9 commit 9a810f8

3 files changed

Lines changed: 52 additions & 1 deletion

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): register static data-action routes before the greedy `:object/:id` matcher
6+
7+
The REST router matches first-registered-wins with no specificity sorting, but
8+
`registerDataActionEndpoints` (which holds `GET /data/:object/export`) ran AFTER
9+
`registerCrudEndpoints` (which holds the greedy `GET /data/:object/:id`). A
10+
request to `GET /data/<object>/export` was therefore captured by `:object/:id`
11+
`"export"` treated as a record id — returning `404 RECORD_NOT_FOUND` instead of
12+
streaming the export. The data-action registration now runs first, mirroring the
13+
existing `/meta/:type/:name/references`-before-`/meta/:type/:name` convention.
14+
Reordering is safe both ways: `registerDataActionEndpoints` contains no greedy
15+
2-segment `:object/:id` routes, so it cannot shadow any CRUD literal. A
16+
regression test asserts the export route registers ahead of the get-by-id route.

packages/rest/src/rest-server.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1552,10 +1552,22 @@ export class RestServer {
15521552
this.registerReportsEndpoints(bp);
15531553
this.registerApprovalsEndpoints(bp);
15541554
this.registerAnalyticsEndpoints(bp);
1555+
// Data-action routes (e.g. GET /data/:object/export, POST
1556+
// /data/:object/import) use static-literal action segments that
1557+
// MUST be registered BEFORE the greedy GET /data/:object/:id
1558+
// matcher in registerCrudEndpoints — the router is first-match-wins
1559+
// with no specificity sorting (see route-manager.ts), so otherwise
1560+
// a request to `.../export` is captured by `:id` and "export" is
1561+
// treated as a record id (404 RECORD_NOT_FOUND). This mirrors the
1562+
// /meta/:type/:name/references-before-/meta/:type/:name convention.
1563+
// Safe in the other direction too: registerDataActionEndpoints has
1564+
// no greedy 2-segment `:object/:id` routes (only literal actions and
1565+
// deeper `:id/clone`, `:id/shares` paths), so it cannot shadow any
1566+
// CRUD literal.
1567+
this.registerDataActionEndpoints(bp);
15551568
if (this.config.api.enableCrud) {
15561569
this.registerCrudEndpoints(bp);
15571570
}
1558-
this.registerDataActionEndpoints(bp);
15591571
if (this.config.api.enableBatch) {
15601572
this.registerBatchEndpoints(bp);
15611573
}

packages/rest/src/rest.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,29 @@ describe('RestServer', () => {
904904
// 1 header + 50 000 rows.
905905
expect(lines.length).toBe(50_001);
906906
});
907+
908+
// Regression: the router is first-match-wins with no specificity sorting,
909+
// so the static-literal `GET /data/:object/export` route MUST be registered
910+
// BEFORE the greedy `GET /data/:object/:id` matcher. Otherwise a request to
911+
// `/data/<object>/export` is captured by `:id` ("export" treated as a record
912+
// id) and 404s with RECORD_NOT_FOUND instead of streaming the export.
913+
it('registers GET /export ahead of the greedy GET /:object/:id matcher', () => {
914+
const rest = new RestServer(server as any, protocol as any);
915+
rest.registerRoutes();
916+
const routes = rest.getRoutes();
917+
918+
const exportIdx = routes.findIndex(
919+
(r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export',
920+
);
921+
const getByIdIdx = routes.findIndex(
922+
(r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/:id',
923+
);
924+
925+
expect(exportIdx).toBeGreaterThanOrEqual(0);
926+
expect(getByIdIdx).toBeGreaterThanOrEqual(0);
927+
// First match wins → the more-specific export route must come first.
928+
expect(exportIdx).toBeLessThan(getByIdIdx);
929+
});
907930
});
908931

909932
// -----------------------------------------------------------------------

0 commit comments

Comments
 (0)