Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/rest-export-route-before-getbyid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/rest": patch
---

fix(rest): register static data-action routes before the greedy `:object/:id` matcher

The REST router matches first-registered-wins with no specificity sorting, but
`registerDataActionEndpoints` (which holds `GET /data/:object/export`) ran AFTER
`registerCrudEndpoints` (which holds the greedy `GET /data/:object/:id`). A
request to `GET /data/<object>/export` was therefore captured by `:object/:id` —
`"export"` treated as a record id — returning `404 RECORD_NOT_FOUND` instead of
streaming the export. The data-action registration now runs first, mirroring the
existing `/meta/:type/:name/references`-before-`/meta/:type/:name` convention.
Reordering is safe both ways: `registerDataActionEndpoints` contains no greedy
2-segment `:object/:id` routes, so it cannot shadow any CRUD literal. A
regression test asserts the export route registers ahead of the get-by-id route.
14 changes: 13 additions & 1 deletion packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1552,10 +1552,22 @@ export class RestServer {
this.registerReportsEndpoints(bp);
this.registerApprovalsEndpoints(bp);
this.registerAnalyticsEndpoints(bp);
// Data-action routes (e.g. GET /data/:object/export, POST
// /data/:object/import) use static-literal action segments that
// MUST be registered BEFORE the greedy GET /data/:object/:id
// matcher in registerCrudEndpoints — the router is first-match-wins
// with no specificity sorting (see route-manager.ts), so otherwise
// a request to `.../export` is captured by `:id` and "export" is
// treated as a record id (404 RECORD_NOT_FOUND). This mirrors the
// /meta/:type/:name/references-before-/meta/:type/:name convention.
// Safe in the other direction too: registerDataActionEndpoints has
// no greedy 2-segment `:object/:id` routes (only literal actions and
// deeper `:id/clone`, `:id/shares` paths), so it cannot shadow any
// CRUD literal.
this.registerDataActionEndpoints(bp);
if (this.config.api.enableCrud) {
this.registerCrudEndpoints(bp);
}
this.registerDataActionEndpoints(bp);
if (this.config.api.enableBatch) {
this.registerBatchEndpoints(bp);
}
Expand Down
23 changes: 23 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,29 @@ describe('RestServer', () => {
// 1 header + 50 000 rows.
expect(lines.length).toBe(50_001);
});

// Regression: the router is first-match-wins with no specificity sorting,
// so the static-literal `GET /data/:object/export` route MUST be registered
// BEFORE the greedy `GET /data/:object/:id` matcher. Otherwise a request to
// `/data/<object>/export` is captured by `:id` ("export" treated as a record
// id) and 404s with RECORD_NOT_FOUND instead of streaming the export.
it('registers GET /export ahead of the greedy GET /:object/:id matcher', () => {
const rest = new RestServer(server as any, protocol as any);
rest.registerRoutes();
const routes = rest.getRoutes();

const exportIdx = routes.findIndex(
(r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export',
);
const getByIdIdx = routes.findIndex(
(r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/:id',
);

expect(exportIdx).toBeGreaterThanOrEqual(0);
expect(getByIdIdx).toBeGreaterThanOrEqual(0);
// First match wins → the more-specific export route must come first.
expect(exportIdx).toBeLessThan(getByIdIdx);
});
});

// -----------------------------------------------------------------------
Expand Down