Skip to content

Commit 524befb

Browse files
fix(rest)!: export refuses an unserved format and clone refuses a non-object overrides (#27659)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuPMZ8BHWtPf6nBTp51qSM64)_ Fixes #27500 Fixes #27489 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no skill teaches either door's malformed values, and `content/docs/api/data-api.mdx` describes only the accepted shapes, which are unchanged ## 维护者速读 - **改了什么**:导出接口 `GET /data/OBJ/export` 收到不支持的 `format`(如 pdf、xml、空值)时返回 400,不再悄悄给一份 CSV;克隆接口 `POST /data/OBJ/ID/clone` 收到不是对象的 `overrides`(字符串、数字、数组、null)时返回 400,不再悄悄当成普通复制。 - **为什么改**:调用方要的东西没给,却拿到 200/201 成功,脚本和集成无法察觉;数组 overrides 还会被按下标合并进新记录。 - **风险与回滚**:风险是曾经发送这类错误值并"侥幸成功"的调用方现在会收到 400;不传 `format`、传 csv/json/xlsx、传对象 overrides 或不传 body 的行为完全不变。回滚即还原 `data-action-routes.ts` 的两处读取。 - **先看**:`packages/rest/src/data-action-routes.ts`(导出的 `readExportFormat` 与克隆的 `isFieldMap` 守卫)、`packages/rest/src/export-format-refusal.test.ts`、`packages/rest/src/clone-overrides-refusal.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run src/export-format-refusal.test.ts src/clone-overrides-refusal.test.ts`,预期 2 个文件 28 条全部通过。 - **需要你做的**:无,席位落地。 ## What changes One door, one reading per parameter, both in `packages/rest/src/data-action-routes.ts`; one commit per card. - **#27500, export `format`.** `readExportFormat` reads the parameter: absent is `csv`; a present value, trimmed and case-insensitive (the reading the route's own `template=true` mode already gives `format`), must be one of the route's served set `csv`, `json`, `xlsx`. Anything else answers `400` through the shared nested writer (`sendError` from `@objectstack/types`): `error.code: 'VALIDATION_ERROR'`, a message naming the value and the served set. The refusal comes before `findData`, so no row is read and no export header is set. The set is the route's own, not the spec's wider `ExportFormat` enum (`jsonl`, `parquet` and the rest were never served and are refused); `packages/spec` is not edited. - **`format=` (empty) is decided: refused.** An empty value is a value the caller wrote, the same reading this route already gives `limit=` (`emptyIsAbsent: false`) and that `template=true` gives `format=`. - **#27489, clone `overrides`.** The handler still accepts both `{ "overrides": {...} }` and a bare field map. A nested `overrides`, or a bare body, that is not a JSON object (a string, number, boolean, `null` or an array) answers `400` `VALIDATION_ERROR` naming `overrides`, before `cloneData` is called. That is the triage's locus: at the REST clone door, before `cloneData`. `packages/metadata-protocol` is not edited. - **`"overrides": null` is decided: refused**, not read as absent. A bare `null` body stays what the transport already makes it (`req.body ?? {}`, a plain copy), and no body at all is a plain copy as before. - **Envelope and code.** Both refusals use the nested ADR-0112 envelope that this route's query gates already answer, through the shared writer, so `check:route-envelope` counts no new hand-built body. `VALIDATION_ERROR` is a `StandardErrorCode` already on the ledger; no code is minted. ## Tests Both suites drive the real `HonoHttpServer` (the adapter `os serve` mounts), so the query string and the JSON body are parsed by the production code. - `packages/rest/src/export-format-refusal.test.ts`: refusal for `pdf`, `xml`, empty, blank, and `jsonl` (in the spec enum, never served). Each asserts `status 400`, `error.code`, that the message names `"format"` and the set, that there is no `X-Export-Format`, and that `findData` was never called. Preservation: absent → csv with the row streamed; `csv`/`CSV`/`json`/`JSON`/` json `/`xlsx`/`Xlsx` each answer their own `X-Export-Format`. - `packages/rest/src/clone-overrides-refusal.test.ts`: refusal for nested string, number, `true`, `null`, array, empty array, and bare array, string and number bodies. Each asserts `status 400`, `error.code`, the message naming `"overrides"`, and that `cloneData` was never called. Preservation: nested map, bare map, `{"overrides":{}}`, `{}` and bare `null` reach `cloneData` with the expected `overrides`, and no body at all is a `201` plain copy. ## Acceptance notes - **The changeset declares `Clause-②: no (narrowing)` where both claims say `Clause-②: no`.** Both doors now refuse values they used to accept and answer as success, which is an accept-set narrowing (AGENTS.md Post-Task Checklist step 3). So the changeset carries the BREAKING banner, its migration, and the ADR-0087 disposition (`not-required (no-migration-prescription)`), at `patch` level because the kind is `none`. Seat: confirm or correct the arm. - **Not changed, noted.** A body carrying `overrides` and other top-level keys still reads only `overrides` and ignores the rest, as before. This is outside both cards. - **Commit shape.** There are two card commits, then `test(rest): type the clone refusal test's parsed body` (a cast that `tsconfig.test.json` needed, folded separately because rewriting the pushed branch was refused in this session), then the changeset commit, then one merge of `origin/main`. ## Verification All readings are at head `035ecdcf` (after one merge of `origin/main`) unless a line says otherwise. Box seconds are shared-box seconds. <details><summary>Package tests and typecheck: @objectstack/rest green</summary> - `pnpm --filter @objectstack/rest build && pnpm --filter @objectstack/rest test && pnpm --filter @objectstack/rest typecheck` under the verify lock: `VERDICT command-exit 0`. Test Files 337 passed (337), Tests 6615 passed and 326 skipped. `tsc --noEmit` was clean, and `check:test-typecheck` was OK with 0 files and 0 errors in the debt ledger. - New suites alone: Test Files 2 passed (2), Tests 28 passed (28). - `eslint --no-inline-config packages/rest`: exit 0. </details> <details><summary>Ablations: both negative pins go red with the old coercion put back, and are restored to blob == HEAD</summary> Each leg used `node scripts/ablation-replace.mjs` in wrap mode, with a shell trap restoring `HEAD`. The tests import `./rest-server` from `src/`, so no `dist/` leg applies. - **#27500.** `readExportFormat`'s return was replaced with the old `value === 'json' ? 'json' : value === 'xlsx' ? 'xlsx' : 'csv'`. The anchor went from 1 to 0 and the blob from `4a4b352aa086` to `39d51ee7fd0d`. Result: `export-format-refusal.test.ts` had 5 failed and 8 passed (all five refusal cases: `expected 200 to be 400`). Restored: blob after restore `4a4b352aa086` == HEAD, `git diff HEAD` empty. - **#27489.** The guard was removed and the old `...(overrides && typeof overrides === 'object' ? { overrides } : {})` pass-through put back. The anchor went from 1 to 0 and the blob from `4a4b352aa086` to `8491a123aeeb`. Result: `clone-overrides-refusal.test.ts` had 9 failed and 6 passed (all nine refusal cases: `expected 201 to be 400`). Restored: blob == HEAD, `git diff HEAD` empty. </details> <details><summary>Derived gate families: 59 derived, 59 run, all exit 0</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 59 commands at `035ecdcf`. Each was run with its exit code captured before any pipe, and all 59 exited 0. - `--ran` reconciliation: `59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED (a DERIVED zero — all 59 recorded an exit code and none of them is 3)`. - On the earlier pre-merge run, two families answered PREREQUISITE NOT MET (exit 3): `check:dual-build-cjs-loads` had no `dist/` yet, and `check-plugin-teardown-shape --self-test` was on a shallow checkout. Both exited 0 on the final-head run above. - Open PRs touching the same files: 0 of 43, so no `merge-tree` probe was needed. </details> <details><summary>Dogfood battery (required by the narrowing arm): 2 timeouts, both unrelated and green on re-run</summary> - Under the lock, `pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test` at `035ecdcf`: `VERDICT command-exit 1`, held 3349 s. Test Files: 2 failed, 349 passed, 3 skipped (354). Tests: 2 failed, 2820 passed, 20 skipped (2842). - The two failures were both `Test timed out in 5000ms`: `object-designer-field-reorder.dogfood.test.ts` (creates and publishes the object in its declared order) and `wall-package-refusal.dogfood.test.ts` (a package install, upgrade and uninstall round trip). Neither file calls the data export or data clone door. - Re-run of those two files alone, while another worktree's turbo build held the box: the same two tests timed out at about 5.4 s. With `--testTimeout=30000`: Test Files 2 passed (2), Tests 24 passed (24). Read: box load, not this diff. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUCizoYWQVJWjhdR6F --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7de844 commit 524befb

4 files changed

Lines changed: 288 additions & 8 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest)!: the export door refuses an unserved `format`, and the clone door refuses a non-object `overrides`, instead of answering as if they were not sent (#27500, #27489)
6+
7+
Clause-②: no (narrowing) — `GET /data/:object/export` refuses a `format` outside csv, json and xlsx, and `POST /data/:object/:id/clone` refuses an `overrides` (or bare body) that is not a JSON object; both used to answer success with the value ignored. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: two REST doors now refuse request values they used to ignore. There is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at two REST data doors (`@objectstack/rest`).
13+
14+
- **Export, before.** Any `format` other than `json` or `xlsx` was answered as csv: `?format=pdf`, `?format=xml` and `?format=` (empty) answered `200` with a csv attachment and `X-Export-Format: csv`.
15+
- **Export, now.** A present `format` must be `csv`, `json` or `xlsx` (case-insensitive, surrounding blanks trimmed). Anything else, the empty value included, answers `400` with `error.code: 'VALIDATION_ERROR'`, naming the value and the served set, before any row is read. The spec's `ExportFormat` enum lists more values (for example `jsonl`, `parquet`); this route never served them and refuses them too.
16+
- **Clone, before.** `{"overrides": "x"}` (or a number, `true`, `null`) was dropped and the door answered `201` with a plain copy; an array reached the clone and was merged index-wise. A bare array or string body behaved the same way.
17+
- **Clone, now.** A nested `overrides`, or a bare body, that is not a JSON object answers `400` with `error.code: 'VALIDATION_ERROR'`, naming `overrides`, before the clone runs. `"overrides": null` is refused, not read as absent.
18+
- **Unchanged.** An absent `format` is csv. An object `overrides`, a bare field map, `{}`, and no body at all clone exactly as before. The SDK's `data.clone` and `data.export` send only accepted shapes.
19+
- **Who sees it.** An API caller or script that sends an unserved export format, or a clone body whose `overrides` is not an object. Such a request used to succeed while meaning something other than what was sent.
20+
21+
**The remedy.** Export: send `csv`, `json` or `xlsx`, or omit `format` for csv. Clone: send `overrides` as an object of field values, or omit it (or send no body) for a plain copy.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27489] `POST /data/:object/:id/clone` refuses an `overrides` that is not
5+
* an object, instead of dropping it.
6+
*
7+
* The defect: the door passed `overrides` on only when `typeof` said
8+
* `object`, so `{"overrides":"x"}` answered `201` with a plain copy and
9+
* nothing said the caller's value was ignored, while an array passed that
10+
* test and reached `cloneData`, which merged it index-wise. The same held for
11+
* a bare (un-nested) body.
12+
*
13+
* Both halves are pinned here, through the real `HonoHttpServer` (the adapter
14+
* `os serve` mounts), so the JSON body is parsed by the code that parses it in
15+
* production:
16+
*
17+
* - refusal: a non-object `overrides` or bare body answers `400` with the
18+
* nested ADR-0112 `VALIDATION_ERROR` naming `overrides`, and `cloneData` is
19+
* never called;
20+
* - preservation: the nested map, the bare map, `{}` and no body at all reach
21+
* `cloneData` exactly as before.
22+
*
23+
* `null` is decided: a caller who wrote `"overrides": null` wrote a value, so
24+
* it is refused, not read as absent. A bare `null` body is the transport's
25+
* empty body and stays a plain copy.
26+
*/
27+
28+
import { describe, it, expect, vi } from 'vitest';
29+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
30+
import { RestServer } from './rest-server';
31+
32+
const ACCOUNT = { name: 'account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } };
33+
34+
function boot() {
35+
const cloneData = vi.fn().mockResolvedValue({ object: 'account', id: 'a2', sourceId: 'a1', record: { id: 'a2' } });
36+
const protocol: any = {
37+
getMetaItems: vi.fn().mockResolvedValue({ items: [ACCOUNT] }),
38+
getMetaItem: vi.fn().mockResolvedValue({ type: 'object', name: 'account', item: ACCOUNT }),
39+
cloneData,
40+
};
41+
const server = new HonoHttpServer(0);
42+
const rest = new RestServer(server as any, protocol, { api: { requireAuth: false } } as any);
43+
(rest as any).resolveExecCtx = async () => ({ userId: 'u_clone_overrides' });
44+
rest.registerRoutes();
45+
return { app: server.getRawApp(), cloneData };
46+
}
47+
48+
async function clone(body: string | undefined) {
49+
const { app, cloneData } = boot();
50+
const res: Response = await app.fetch(new Request('http://local/api/v1/data/account/a1/clone', {
51+
method: 'POST',
52+
...(body === undefined ? {} : { headers: { 'content-type': 'application/json' }, body }),
53+
}));
54+
return { status: res.status, body: (await res.json()) as any, cloneData };
55+
}
56+
57+
describe('[#27489] clone — a non-object overrides is refused before cloneData', () => {
58+
it.each([
59+
['a string', '{"overrides":"x"}'],
60+
['a number', '{"overrides":1}'],
61+
['true', '{"overrides":true}'],
62+
['null', '{"overrides":null}'],
63+
['an array', '{"overrides":[{"name":"Copy"}]}'],
64+
['an empty array', '{"overrides":[]}'],
65+
['a bare array body', '[{"name":"Copy"}]'],
66+
['a bare string body', '"x"'],
67+
['a bare number body', '7'],
68+
])('overrides as %s → 400 VALIDATION_ERROR naming overrides, cloneData never called', async (_label, body) => {
69+
const out = await clone(body);
70+
71+
expect(out.status).toBe(400);
72+
expect(out.body?.error?.code).toBe('VALIDATION_ERROR');
73+
expect(out.body?.error?.message).toContain('"overrides"');
74+
expect(out.cloneData).not.toHaveBeenCalled();
75+
});
76+
});
77+
78+
describe('[#27489] clone — object overrides and absent bodies keep their answers', () => {
79+
it.each([
80+
['nested overrides', '{"overrides":{"name":"Copy"}}', { name: 'Copy' }],
81+
['a bare field map', '{"name":"Bare"}', { name: 'Bare' }],
82+
['an empty nested map', '{"overrides":{}}', {}],
83+
['an empty body object', '{}', {}],
84+
['a bare null body', 'null', {}],
85+
])('%s → 201, cloneData receives %s', async (_label, body, expected) => {
86+
const out = await clone(body);
87+
88+
expect(out.status).toBe(201);
89+
expect(out.cloneData).toHaveBeenCalledTimes(1);
90+
expect(out.cloneData.mock.calls[0][0]).toMatchObject({ object: 'account', id: 'a1', overrides: expected });
91+
});
92+
93+
it('no body at all → 201, a plain copy', async () => {
94+
const out = await clone(undefined);
95+
96+
expect(out.status).toBe(201);
97+
expect(out.cloneData).toHaveBeenCalledTimes(1);
98+
expect(out.cloneData.mock.calls[0][0]).toMatchObject({ object: 'account', id: 'a1' });
99+
});
100+
});

‎packages/rest/src/data-action-routes.ts‎

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,33 @@ import {
165165
*/
166166
export type ServerScopedDataRequest<R> = R & { environmentId?: string; context?: unknown };
167167

168+
/** [#27489] A JSON object: what the clone door reads as a field map. Arrays and `null` are not. */
169+
function isFieldMap(value: unknown): value is Record<string, unknown> {
170+
return typeof value === 'object' && value !== null && !Array.isArray(value);
171+
}
172+
173+
/** [#27489] The kind of a refused clone body or `overrides`, named without echoing the value. */
174+
function describeNonFieldMap(value: unknown): string {
175+
if (value === null) return 'null';
176+
if (Array.isArray(value)) return 'an array';
177+
return `a ${typeof value}`;
178+
}
179+
180+
/** [#27500] The formats `GET /data/:object/export` serves, csv first as the default. */
181+
const EXPORT_FORMATS = ['csv', 'json', 'xlsx'] as const;
182+
type ExportFormatServed = typeof EXPORT_FORMATS[number];
183+
184+
/**
185+
* [#27500] The export's `?format=`: absent is csv, a served format is itself,
186+
* anything else (the empty value included) is `undefined`, which the route
187+
* refuses.
188+
*/
189+
function readExportFormat(raw: unknown): ExportFormatServed | undefined {
190+
if (raw === undefined) return 'csv';
191+
const value = String(raw).trim().toLowerCase();
192+
return (EXPORT_FORMATS as readonly string[]).includes(value) ? value as ExportFormatServed : undefined;
193+
}
194+
168195
/**
169196
* [#24183] One export row as a json object carrying exactly `fields`, in that
170197
* order — the json arm's counterpart of the column list `formatRowCells` writes
@@ -296,6 +323,7 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
296323
// object's `enable.clone` capability, default on). Optional JSON body
297324
// `{ overrides?: {...} }` (or a bare field map) is applied on top of
298325
// the copied values, e.g. to set a new name or clear a unique field.
326+
// Any other `overrides` (or bare body) is a 400.
299327
// Distinct path segment (`/clone`) keeps it clear of the greedy
300328
// `/data/:object/:id` matchers.
301329
this.routeManager.register({
@@ -316,13 +344,29 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
316344
const body = req.body ?? {};
317345
// Accept both `{ overrides: {...} }` and a bare field map so
318346
// callers can POST overrides directly without nesting.
319-
const overrides = (body && typeof body === 'object' && 'overrides' in body)
320-
? body.overrides
321-
: body;
347+
const nested = isFieldMap(body) && 'overrides' in body;
348+
const overrides = nested ? body.overrides : body;
349+
// [#27489] Refused, not dropped: a non-object `overrides`
350+
// (`"x"`, `1`, `true`, `null`) used to be skipped and answer
351+
// `201` with a plain copy, and an array passed the `typeof`
352+
// test and reached `cloneData`, which merged it index-wise.
353+
// Refused here, at the door, before `cloneData` is called. An
354+
// absent body or member is still a plain copy; `null` is a
355+
// value the caller wrote, so it is refused rather than read as
356+
// absent.
357+
if (overrides !== undefined && !isFieldMap(overrides)) {
358+
const got = describeNonFieldMap(overrides);
359+
sendEnvelopeError(res, 400, 'VALIDATION_ERROR', nested
360+
? `The clone body's "overrides" must be an object of field values, got ${got}. `
361+
+ 'Omit it for a plain copy.'
362+
: `The clone body must be an object, { "overrides": {...} } or a bare field map, got ${got}. `
363+
+ 'Send no body for a plain copy.');
364+
return;
365+
}
322366
const result = await cloneData.call(p, {
323367
object: req.params.object,
324368
id: req.params.id,
325-
...(overrides && typeof overrides === 'object' ? { overrides } : {}),
369+
...(overrides !== undefined ? { overrides } : {}),
326370
...(environmentId ? { environmentId } : {}),
327371
...(context ? { context } : {}),
328372
});
@@ -945,7 +989,8 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
945989
// GET /data/:object/export — streaming export (M10.21 / C.21)
946990
//
947991
// Query params:
948-
// format=csv|json|xlsx (default: csv. json emits a JSON array, xlsx a workbook.)
992+
// format=csv|json|xlsx (default: csv. json emits a JSON array, xlsx a workbook.
993+
// Any other value, empty included, is a 400.)
949994
// fields=a,b,c (every format. csv / xlsx write exactly these columns and
950995
// json rows carry exactly these keys, in list order; a key
951996
// the read did not return is absent from a json row.
@@ -1071,9 +1116,22 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
10711116
await this.answerImportTemplate(req, res, p, environmentId, objectName, context, q);
10721117
return;
10731118
}
1074-
const fmtRaw = String(q.format ?? 'csv').toLowerCase();
1075-
const format: 'csv' | 'json' | 'xlsx' =
1076-
fmtRaw === 'json' ? 'json' : fmtRaw === 'xlsx' ? 'xlsx' : 'csv';
1119+
// [#27500] Read, not coerced: a value outside the set this door
1120+
// serves used to fall through to csv, so `format=pdf`,
1121+
// `format=xml` and `format=` answered `200` with a csv file the
1122+
// caller never asked for. An ABSENT `format` is still csv; a
1123+
// present one must name a served format, the empty value
1124+
// included (as `limit=` above is refused rather than read as
1125+
// absent). Trimmed and case-insensitive, the reading the
1126+
// template mode above gives the same parameter. The set is the
1127+
// one this route serves, not the spec's wider `ExportFormat`.
1128+
const format = readExportFormat(q.format);
1129+
if (format === undefined) {
1130+
sendEnvelopeError(res, 400, 'VALIDATION_ERROR',
1131+
`The "format" query parameter takes ${EXPORT_FORMATS.join(', ')}, got `
1132+
+ `${JSON.stringify(q.format)}. Omit it for csv.`);
1133+
return;
1134+
}
10771135
// Header row toggle (csv / xlsx). Default on; `header=false` omits it.
10781136
const includeHeader = String(q.header ?? 'true').toLowerCase() !== 'false';
10791137
const HARD_CAP = 50_000;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27500] `GET /data/:object/export?format=` is READ, not coerced.
5+
*
6+
* The defect: any `format` other than `json` or `xlsx` fell through to csv, so
7+
* `format=pdf`, `format=xml` and `format=` (empty) answered `200` with a csv
8+
* attachment and `X-Export-Format: csv`. A caller that branches on the status,
9+
* or saves the file under the extension it asked for, could not tell its
10+
* request was not honoured.
11+
*
12+
* Both halves are pinned here, through the real `HonoHttpServer` (the adapter
13+
* `os serve` mounts), so the query string is parsed by the code that parses it
14+
* in production:
15+
*
16+
* - refusal: an unserved value answers `400` with the nested ADR-0112
17+
* `VALIDATION_ERROR`, and `findData` is never called, so no row is read;
18+
* - preservation: an absent `format` is still csv, and each served value
19+
* (any case, surrounding blanks trimmed) answers its own format.
20+
*/
21+
22+
import { describe, it, expect, vi } from 'vitest';
23+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
24+
import { RestServer } from './rest-server';
25+
26+
const TASK = {
27+
name: 'task',
28+
label: 'Task',
29+
fields: {
30+
id: { type: 'text', label: 'ID' },
31+
title: { type: 'text', label: 'Title' },
32+
},
33+
};
34+
35+
function boot() {
36+
const findData = vi.fn().mockResolvedValue({ object: 'task', records: [{ id: '1', title: 'write code' }] });
37+
const protocol: any = {
38+
getMetaItems: vi.fn().mockResolvedValue({ items: [TASK] }),
39+
getMetaItem: vi.fn().mockResolvedValue({ type: 'object', name: 'task', item: TASK }),
40+
findData,
41+
};
42+
const server = new HonoHttpServer(0);
43+
const rest = new RestServer(server as any, protocol, { api: { requireAuth: false } } as any);
44+
(rest as any).resolveExecCtx = async () => ({ userId: 'u_export_format' });
45+
rest.registerRoutes();
46+
return { app: server.getRawApp(), findData };
47+
}
48+
49+
async function exportWith(query: string) {
50+
const { app, findData } = boot();
51+
const res: Response = await app.fetch(new Request(`http://local/api/v1/data/task/export${query}`));
52+
const text = await res.text();
53+
let body: any;
54+
try { body = JSON.parse(text); } catch { body = undefined; }
55+
return { status: res.status, format: res.headers.get('x-export-format'), body, text, findData };
56+
}
57+
58+
describe('[#27500] export ?format= — an unserved value is refused, not answered as csv', () => {
59+
it.each([
60+
['pdf', '?format=pdf'],
61+
['xml', '?format=xml'],
62+
['empty', '?format='],
63+
['blank', '?format=%20'],
64+
['jsonl (in the spec enum, never served by this route)', '?format=jsonl'],
65+
])('format %s → 400 VALIDATION_ERROR, and no row is read', async (_label, query) => {
66+
const out = await exportWith(query);
67+
68+
expect(out.status).toBe(400);
69+
expect(out.body?.error?.code).toBe('VALIDATION_ERROR');
70+
// The refusal names the parameter and the served set.
71+
expect(out.body?.error?.message).toContain('"format"');
72+
expect(out.body?.error?.message).toContain('csv, json, xlsx');
73+
expect(out.format).toBeNull();
74+
expect(out.findData).not.toHaveBeenCalled();
75+
});
76+
});
77+
78+
describe('[#27500] export ?format= — absent and served values keep their answers', () => {
79+
it('an absent format is csv', async () => {
80+
const out = await exportWith('');
81+
expect(out.status).toBe(200);
82+
expect(out.format).toBe('csv');
83+
expect(out.text).toContain('write code');
84+
expect(out.findData).toHaveBeenCalled();
85+
});
86+
87+
it.each([
88+
['?format=csv', 'csv'],
89+
['?format=CSV', 'csv'],
90+
['?format=json', 'json'],
91+
['?format=JSON', 'json'],
92+
['?format=%20json%20', 'json'],
93+
['?format=xlsx', 'xlsx'],
94+
['?format=Xlsx', 'xlsx'],
95+
])('%s answers %s', async (query, expected) => {
96+
const out = await exportWith(query);
97+
expect(out.status).toBe(200);
98+
expect(out.format).toBe(expected);
99+
expect(out.findData).toHaveBeenCalled();
100+
});
101+
});

0 commit comments

Comments
 (0)