From 7c8d32ac2a0deb99cddad29b615d709149078adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Thu, 25 Jun 2026 11:51:22 +0800 Subject: [PATCH 1/2] fix(rest): register static data-action routes before greedy :object/:id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REST router (RouteManager → IHttpServer/Express adapter) matches first-registered-wins with no specificity sorting. registerDataActionEndpoints (which holds GET /data/:object/export) ran AFTER registerCrudEndpoints (which holds the greedy GET /data/:object/:id), so a request to `GET /data//export` was captured by `:object/:id` — "export" treated as a record id — returning 404 RECORD_NOT_FOUND instead of streaming the export. Reorder so the static-literal action routes register before the greedy :object/:id matcher, mirroring the existing /meta/:type/:name/references-before -/meta/:type/:name convention. The reorder is safe in both directions: registerDataActionEndpoints has no greedy 2-segment :object/:id routes, and the only per-method collision in the data namespace is GET export vs GET :id. Add a regression test asserting the export route's registration index precedes the get-by-id route's. --- packages/rest/src/rest-server.ts | 14 +++++++++++++- packages/rest/src/rest.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b3e8fcd166..a416cd6b62 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -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); } diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 4e604753c9..cedc659845 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -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//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); + }); }); // ----------------------------------------------------------------------- From 222a7e571539a91fc43207d684fa58dce89762ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Thu, 25 Jun 2026 11:52:51 +0800 Subject: [PATCH 2/2] chore(changeset): add changeset for rest export route fix --- .changeset/rest-export-route-before-getbyid.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/rest-export-route-before-getbyid.md diff --git a/.changeset/rest-export-route-before-getbyid.md b/.changeset/rest-export-route-before-getbyid.md new file mode 100644 index 0000000000..283c535577 --- /dev/null +++ b/.changeset/rest-export-route-before-getbyid.md @@ -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//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.