Skip to content

Commit 3519f8d

Browse files
os-litantclaude
andauthored
State SaveReportInput's requirements at the reports.save door (#12421)
* fix(client,rest): state SaveReportInput's requirements at the reports.save door `IReportService.saveReport` takes a `SaveReportInput`, on which `name`, `object` and `query` are all required. Nothing on the path said so: the SDK method declared its parameter `any` and the route forwarded `req.body ?? {}` unchecked, so the requirement held only as far as each reports implementation chose to re-derive it privately. - `client.reports.save` now takes `SaveReportInput` instead of `any`. - `POST /api/v1/reports` refuses a body missing any of the three required keys, and a `query` that is not a `ReportQuery` envelope, with 400 / VALIDATION_FAILED — ordered after the existing 501 for an unmounted service. The query-less literal that `client.test.ts` had been constructing invisibly is preserved verbatim and becomes a `@ts-expect-error` pin asserting the refusal. The REST pass-through test is re-driven through a door-valid body so it keeps pinning the service-raised VALIDATION_FAILED mapping instead of going vacuous. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd * refactor(rest): raise the reports door refusal through handleValidation The door check wrote its own 400 body. That put a second VALIDATION_FAILED construction site on one route — so the same refusal could reach a client in two different envelopes depending on whether the door or the service raised it — and added two non-conforming bodies to the `check:route-envelope` ratchet, which only ticks down (stringError 46 vs 44, siblingCode 71 vs 69). It now throws `VALIDATION_FAILED: …` from inside the existing try, so the route's single `handleValidation` builds the body exactly as it already did for a service-raised refusal. No new response body; the ratchet is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0c77ea4 commit 3519f8d

5 files changed

Lines changed: 192 additions & 7 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/client": minor
3+
"@objectstack/rest": minor
4+
---
5+
6+
fix(client,rest): state `SaveReportInput`'s requirements at the `reports.save` door (#11926)
7+
8+
**BREAKING** accept-set narrowing on `POST /api/v1/reports` and on the
9+
`client.reports.save` parameter type, shipped as `minor` under the repo's
10+
launch-window convention for breaking changes.
11+
12+
`IReportService.saveReport` takes a `SaveReportInput`, on which `name`, `object`
13+
and `query` are all required. Nothing on the path said so. The SDK method
14+
declared its parameter `any`, and the route forwarded `req.body ?? {}` straight
15+
through, so the requirement held only as far as each reports implementation
16+
chose to re-derive it privately — the bundled `@objectstack/plugin-reports` does
17+
re-derive all three, but a third-party implementation need not, and a caller
18+
could not tell which one it was talking to. This is the ADR-0078
19+
declared-but-unenforced shape arriving at an authoring surface: the producer
20+
accepted off-spec input and handed it to a service that requires more.
21+
22+
Both halves now state the contract:
23+
24+
- **`client.reports.save(report)`** takes `SaveReportInput` instead of `any`.
25+
Omitting `query` (or `name`, or `object`) is now a compile error at the call
26+
site rather than a surprise from whichever implementation is mounted. The SDK
27+
remains a transport and adds no runtime validation — it is not a second
28+
validator.
29+
- **`POST /api/v1/reports`** refuses a body missing any of the three required
30+
keys, and a `query` that is not a `ReportQuery` envelope (a scalar or an
31+
array), with `400` / `VALIDATION_FAILED` — the same envelope the route
32+
already produced for a service-raised validation error (ADR-0112). A
33+
JavaScript or `curl` caller that never sees the TypeScript type is refused
34+
too. The refusal is ordered **after** the existing `501` for an unmounted
35+
reports service: "no reports service on this deployment" is a deployment fact
36+
and outranks anything about the body. An empty `query: {}` stays legal —
37+
every field on `ReportQuery` is optional — and is pinned as such.
38+
39+
**Migration.** A caller that omitted `query` was already relying on
40+
implementation-specific behaviour; supply the `ReportQuery` envelope the report
41+
should run (`{}` for "no filters"). Callers already sending a complete
42+
definition are unaffected, and the bundled reports implementation already
43+
refused all three omissions, so no deployment running it changes behaviour —
44+
only the layer that produces the refusal moves, from the service to the door.
45+
46+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing over keys that already exist and are already required by the service contract: no key is removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. `SaveReportInput` is a cross-package TS contract with no spec schema and no stored-metadata form, so no authored artifact at rest carries the affected shape. What a query-less report definition was *meant* to query is authoring intent no migration entry can supply on an upgrader's behalf; the 400 at the door is the channel that reaches the author, naming the missing keys. Mirrors the disposition of the #11519 and #11842 accept-set narrowings. -->

packages/client/src/client.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,11 +375,37 @@ describe('Reports namespace (#3587 gap closure)', () => {
375375

376376
it('reports.save pins POST /reports', async () => {
377377
const { client, fetchMock } = createMockClient({ id: 'r1' });
378-
await client.reports.save({ name: 'Pipeline', object: 'lead' });
378+
await client.reports.save({ name: 'Pipeline', object: 'lead', query: { fields: ['id'] } });
379379
const [url, init] = fetchMock.mock.calls[0];
380380
expect(String(url)).toBe('http://localhost:3000/api/v1/reports');
381381
expect(init.method).toBe('POST');
382-
expect(JSON.parse(init.body)).toEqual({ name: 'Pipeline', object: 'lead' });
382+
expect(JSON.parse(init.body)).toEqual({ name: 'Pipeline', object: 'lead', query: { fields: ['id'] } });
383+
});
384+
385+
// [#11926] The literal below is the ORIGINAL fixture of the test above,
386+
// preserved verbatim rather than repaired. For as long as `reports.save`
387+
// took `any` it sat there constructing an input the service contract
388+
// REFUSES — `SaveReportInput.query` is required — against a mock transport
389+
// that never reaches a service, so no run could ever have failed on it. It
390+
// is evidence, and giving it a `query` would have silenced the evidence
391+
// without closing anything. So it moves here, and the compiler asserts the
392+
// refusal instead.
393+
//
394+
// This is a bidirectional pin, not a comment. `client.test.ts` is compiled
395+
// by `tsconfig.test.json` — named by this package's `typecheck` script —
396+
// and holds no `test-typecheck-debt.json` entry, so an unlisted file must
397+
// have zero errors. Widen the parameter back to `any` and the directive
398+
// below stops matching an error: tsc reds with TS2578, "unused
399+
// '@ts-expect-error' directive". It cannot rot into a phantom check.
400+
it('[#11926] reports.save refuses a query-less report at the type level', async () => {
401+
const { client, fetchMock } = createMockClient({ id: 'r1' });
402+
// @ts-expect-error — `query` is required by `SaveReportInput`.
403+
await client.reports.save({ name: 'Pipeline', object: 'lead' });
404+
// The SDK is a transport, not a second validator: the request still
405+
// goes out unaltered. The refusal ON THE WIRE belongs to the route and
406+
// is pinned in packages/rest/src/rest.test.ts — see
407+
// 'POST /reports refuses a body the service contract requires more of'.
408+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ name: 'Pipeline', object: 'lead' });
383409
});
384410

385411
it('reports.get / delete pin /reports/:id and delete tolerates 204', async () => {

packages/client/src/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import type {
104104
RemoteTable,
105105
ReportRunResult,
106106
ReportSchedule,
107+
SaveReportInput,
107108
SavedReport,
108109
SchemaValidationReport,
109110
ScreenSpec,
@@ -4383,8 +4384,17 @@ export class ObjectStackClient {
43834384
return Array.isArray(body) ? body : (body?.data ?? []);
43844385
},
43854386

4386-
/** Create or update a saved report definition. 400 [VALIDATION_FAILED] on a bad spec. */
4387-
save: async (report: any): Promise<SavedReport> => {
4387+
/**
4388+
* Create or update a saved report definition.
4389+
*
4390+
* [#11926] The parameter is the service contract's own `SaveReportInput`,
4391+
* not `any`: `name`, `object` and `query` are required, and omitting one is
4392+
* a compile error here rather than a surprise from whichever reports
4393+
* implementation the deployment mounts. The wire refusal is the route's —
4394+
* `POST /reports` answers 400 [VALIDATION_FAILED] for the same three keys,
4395+
* so a JavaScript caller that never sees this type is refused too.
4396+
*/
4397+
save: async (report: SaveReportInput): Promise<SavedReport> => {
43884398
const res = await this.fetch(`${this.baseUrl}/api/v1/reports`, {
43894399
method: 'POST',
43904400
body: JSON.stringify(report ?? {}),

packages/rest/src/rest-server.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10225,6 +10225,40 @@ export class RestServer {
1022510225
code: 'NOT_IMPLEMENTED',
1022610226
message: 'Reports service is not configured on this deployment',
1022710227
});
10228+
// [#11926] The door states the contract for `POST /reports` below.
10229+
// `IReportService.saveReport` takes a `SaveReportInput`
10230+
// (`packages/spec/src/contracts/report-service.ts`), on which `name`,
10231+
// `object` and `query` are all REQUIRED — but an HTTP body is untyped,
10232+
// so forwarding it unchecked handed the service a value merely CLAIMED
10233+
// to be a `SaveReportInput`. That left the requirement for every
10234+
// implementation to re-derive privately: the bundled
10235+
// `@objectstack/plugin-reports` does re-derive it, a third-party one
10236+
// need not, and a caller could not tell which one it was talking to.
10237+
// Refusing here makes the contract true for every implementation, in
10238+
// the same envelope `handleValidation` already produces (400 /
10239+
// VALIDATION_FAILED, ADR-0112).
10240+
// It THROWS rather than writing a response, and that is the design, not
10241+
// a detour: `handleValidation` below is this surface's single place for
10242+
// building a VALIDATION_FAILED body. Writing a second one here would
10243+
// make one route answer the same refusal in two different envelopes —
10244+
// and would add a non-conforming body to the `check:route-envelope`
10245+
// ratchet, which only ticks down. Raised before `saveReport` is called,
10246+
// so the door refuses rather than the service.
10247+
const assertSaveReportInput = (body: any): void => {
10248+
const missing = (['name', 'object', 'query'] as const)
10249+
.filter((field) => body?.[field] === undefined || body?.[field] === null);
10250+
if (missing.length > 0) {
10251+
throw new Error(
10252+
`VALIDATION_FAILED: ${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} required`,
10253+
);
10254+
}
10255+
// `query` is a `ReportQuery` envelope — never a scalar and never a
10256+
// list. A string here is the shape an authoring mistake actually
10257+
// takes, and it reaches storage as a stringified scalar otherwise.
10258+
if (typeof body.query !== 'object' || Array.isArray(body.query)) {
10259+
throw new Error('VALIDATION_FAILED: query must be a ReportQuery object');
10260+
}
10261+
};
1022810262
const handleValidation = (res: any, err: any): boolean => {
1022910263
const msg = String(err?.message ?? err ?? '');
1023010264
if (msg.startsWith('VALIDATION_FAILED')) {
@@ -10278,6 +10312,11 @@ export class RestServer {
1027810312
const svc = await resolveService(environmentId);
1027910313
if (!svc) return respond501(res);
1028010314
try {
10315+
// AFTER the 501 on purpose: "no reports service is
10316+
// mounted" is a deployment fact and outranks anything
10317+
// about the body. Inside the try so the refusal reaches
10318+
// `handleValidation` like any other VALIDATION_FAILED.
10319+
assertSaveReportInput(req.body ?? {});
1028110320
const row = await svc.saveReport(req.body ?? {}, context ?? {});
1028210321
res.status(201).json(row);
1028310322
} catch (err: any) {

packages/rest/src/rest.test.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1605,16 +1605,80 @@ describe('RestServer', () => {
16051605
expect(saveReport).toHaveBeenCalled();
16061606
});
16071607

1608-
it('POST /reports surfaces VALIDATION_FAILED as 400', async () => {
1609-
const saveReport = vi.fn(async () => { throw new Error('VALIDATION_FAILED: name is required'); });
1608+
// [#11926] This pin's subject is the PASS-THROUGH: a VALIDATION_FAILED
1609+
// raised by the SERVICE is mapped to 400 by `handleValidation`. It used to
1610+
// drive `body: {}`, which the route now refuses at the door before the
1611+
// service is ever consulted — the assertions below would still have gone
1612+
// green, but on the door's refusal rather than the service's, pinning
1613+
// nothing. So the body is now one the door ACCEPTS and the double throws
1614+
// for a reason only a service can have, and `saveReport` is asserted to
1615+
// have actually been called so this cannot quietly go vacuous again.
1616+
it('POST /reports surfaces a service-raised VALIDATION_FAILED as 400', async () => {
1617+
const saveReport = vi.fn(async () => { throw new Error('VALIDATION_FAILED: format must be one of csv, json, html_table'); });
16101618
const rest = makeRest(async () => ({ saveReport }));
16111619
const { save } = getReportRoutes(rest);
16121620
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() };
1613-
await save!.handler({ body: {} } as any, res as any);
1621+
await save!.handler({ body: { name: 'X', object: 'lead', query: {}, format: 'pdf' } } as any, res as any);
1622+
expect(saveReport).toHaveBeenCalled();
16141623
expect(res.status).toHaveBeenCalledWith(400);
16151624
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'VALIDATION_FAILED' }));
16161625
});
16171626

1627+
// [#11926] The door itself. `IReportService.saveReport` takes a
1628+
// `SaveReportInput`, on which `name`, `object` and `query` are required;
1629+
// the route used to forward `req.body ?? {}` unchecked, so the requirement
1630+
// held only as far as each implementation chose to re-derive it. These pin
1631+
// that the ROUTE states it — note `saveReport` is asserted NOT to have been
1632+
// called, which is the whole difference from the pass-through pin above.
1633+
it('POST /reports refuses a body the service contract requires more of', async () => {
1634+
const cases: Array<{ label: string; body: any }> = [
1635+
{ label: 'no query', body: { name: 'X', object: 'lead' } },
1636+
{ label: 'null query', body: { name: 'X', object: 'lead', query: null } },
1637+
{ label: 'no object', body: { name: 'X', query: {} } },
1638+
{ label: 'no name', body: { object: 'lead', query: {} } },
1639+
{ label: 'empty body', body: {} },
1640+
];
1641+
for (const { label, body } of cases) {
1642+
const saveReport = vi.fn(async (input: any) => ({ id: 'rpt_new', ...input }));
1643+
const rest = makeRest(async () => ({ saveReport }));
1644+
const { save } = getReportRoutes(rest);
1645+
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() };
1646+
await save!.handler({ body } as any, res as any);
1647+
expect(res.status, label).toHaveBeenCalledWith(400);
1648+
expect(res.json, label).toHaveBeenCalledWith(expect.objectContaining({ code: 'VALIDATION_FAILED' }));
1649+
expect(saveReport, label).not.toHaveBeenCalled();
1650+
}
1651+
});
1652+
1653+
// [#11926] `query` is a `ReportQuery` envelope. A scalar or a list is the
1654+
// shape an authoring mistake actually takes, and it is present, so the
1655+
// required-key check above cannot catch it.
1656+
it('POST /reports refuses a query that is not a ReportQuery envelope', async () => {
1657+
for (const query of ['object=lead', 42, true, [{ field: 'id' }]]) {
1658+
const saveReport = vi.fn(async (input: any) => ({ id: 'rpt_new', ...input }));
1659+
const rest = makeRest(async () => ({ saveReport }));
1660+
const { save } = getReportRoutes(rest);
1661+
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() };
1662+
await save!.handler({ body: { name: 'X', object: 'lead', query } } as any, res as any);
1663+
expect(res.status, JSON.stringify(query)).toHaveBeenCalledWith(400);
1664+
expect(saveReport, JSON.stringify(query)).not.toHaveBeenCalled();
1665+
}
1666+
});
1667+
1668+
// [#11926] An empty `ReportQuery` is LEGAL — every field on it is optional
1669+
// — so the door must not over-refuse. Without this, tightening `query` to
1670+
// "present and an object" could drift into "present and non-empty" with no
1671+
// test noticing.
1672+
it('POST /reports accepts an empty query object', async () => {
1673+
const saveReport = vi.fn(async (input: any) => ({ id: 'rpt_new', ...input }));
1674+
const rest = makeRest(async () => ({ saveReport }));
1675+
const { save } = getReportRoutes(rest);
1676+
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() };
1677+
await save!.handler({ body: { name: 'X', object: 'lead', query: {} } } as any, res as any);
1678+
expect(saveReport).toHaveBeenCalled();
1679+
expect(res.status).toHaveBeenCalledWith(201);
1680+
});
1681+
16181682
it('GET /reports/:id returns 404 when missing', async () => {
16191683
const getReport = vi.fn(async () => null);
16201684
const rest = makeRest(async () => ({ getReport }));

0 commit comments

Comments
 (0)