Skip to content

Commit ecda20c

Browse files
os-zhuangclaude
andauthored
feat(client): close the 8 reports-family REST gaps (#3587 batch 2/5) (#3626)
New client.reports namespace speaking the plugin-reports REST surface: list / save / get / delete (schedules cascade), run, schedule, listSchedules, unschedule — each URL-pinned in client.test.ts. The two DELETE routes return 204; the client methods return { deleted: true } without parsing the empty body. Fixed path (/api/v1/reports is not in ApiRoutesSchema), matching the keys / share-links precedent. Ledger: 8 reports rows gap→sdk; ratchet 34→26. client-sdk.mdx surface table gains the reports row. Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc968af commit ecda20c

6 files changed

Lines changed: 187 additions & 22 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/client": minor
3+
"@objectstack/rest": patch
4+
---
5+
6+
feat(client): close the 8 reports-family REST gaps (#3587 batch 2/5)
7+
8+
New `client.reports` namespace speaking the plugin-reports REST surface:
9+
`list` / `save` / `get` / `delete` (schedules cascade), `run`, `schedule`,
10+
`listSchedules`, `unschedule`. The two DELETE routes return 204 — the client
11+
methods return `{ deleted: true }` without attempting to parse an empty body.
12+
Fixed path (`/api/v1/reports` is not in `ApiRoutesSchema`), matching the
13+
keys / share-links precedent. REST route-ledger ratchet: 34 → 26.

content/docs/api/client-sdk.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
116116
| **analytics** || 3 | Analytics queries |
117117
| **automation** || 18 | Flow CRUD, trigger/execute, runs, screen-flow resume, descriptor/status registries |
118118
| **actions** || 2 | Server-registered action handlers (`engine.registerAction`) |
119+
| **reports** || 8 | Saved reports: definitions, execution, recurring email schedules (501 without `@objectstack/plugin-reports`) |
119120
| **keys** || 1 | API key minting (one-time secret) |
120121
| **shareLinks** || 3 | Record share-link management |
121122
| **security** || 3 | Suggested audience bindings (admin) |

packages/client/src/client.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,70 @@ describe('ObjectStackClient', () => {
198198
});
199199
});
200200

201+
describe('Reports namespace (#3587 gap closure)', () => {
202+
it('reports.list pins GET /reports with filters and unwraps {data}', async () => {
203+
const { client, fetchMock } = createMockClient({ data: [{ id: 'r1' }] });
204+
const rows = await client.reports.list({ object: 'lead', ownerId: 'u1' });
205+
expect(String(fetchMock.mock.calls[0][0])).toBe(
206+
'http://localhost:3000/api/v1/reports?object=lead&ownerId=u1',
207+
);
208+
expect(rows).toEqual([{ id: 'r1' }]);
209+
});
210+
211+
it('reports.save pins POST /reports', async () => {
212+
const { client, fetchMock } = createMockClient({ id: 'r1' });
213+
await client.reports.save({ name: 'Pipeline', object: 'lead' });
214+
const [url, init] = fetchMock.mock.calls[0];
215+
expect(String(url)).toBe('http://localhost:3000/api/v1/reports');
216+
expect(init.method).toBe('POST');
217+
expect(JSON.parse(init.body)).toEqual({ name: 'Pipeline', object: 'lead' });
218+
});
219+
220+
it('reports.get / delete pin /reports/:id and delete tolerates 204', async () => {
221+
const { client, fetchMock } = createMockClient({ id: 'r1' });
222+
await client.reports.get('r1');
223+
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/reports/r1');
224+
225+
const del = createMockClient(undefined, 204);
226+
// A 204 has no JSON body — the method must not try to parse one.
227+
del.fetchMock.mockResolvedValue({ ok: true, status: 204, statusText: 'No Content', json: async () => { throw new Error('no body'); }, headers: new Headers() });
228+
const out = await del.client.reports.delete('r1');
229+
expect(String(del.fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/reports/r1');
230+
expect(del.fetchMock.mock.calls[0][1].method).toBe('DELETE');
231+
expect(out).toEqual({ deleted: true });
232+
});
233+
234+
it('reports.run pins POST /reports/:id/run', async () => {
235+
const { client, fetchMock } = createMockClient({ rows: [] });
236+
await client.reports.run('r1');
237+
const [url, init] = fetchMock.mock.calls[0];
238+
expect(String(url)).toBe('http://localhost:3000/api/v1/reports/r1/run');
239+
expect(init.method).toBe('POST');
240+
});
241+
242+
it('reports.schedule pins POST /reports/:id/schedule with the schedule body', async () => {
243+
const { client, fetchMock } = createMockClient({ id: 's1' });
244+
await client.reports.schedule('r1', { recipients: ['a@example.com'], cronExpression: '0 8 * * 1' });
245+
const [url, init] = fetchMock.mock.calls[0];
246+
expect(String(url)).toBe('http://localhost:3000/api/v1/reports/r1/schedule');
247+
expect(JSON.parse(init.body)).toEqual({ recipients: ['a@example.com'], cronExpression: '0 8 * * 1' });
248+
});
249+
250+
it('reports.listSchedules / unschedule pin the schedule routes', async () => {
251+
const { client, fetchMock } = createMockClient({ data: [{ id: 's1' }] });
252+
const rows = await client.reports.listSchedules('r1');
253+
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/reports/r1/schedules');
254+
expect(rows).toEqual([{ id: 's1' }]);
255+
256+
const del = createMockClient(undefined, 204);
257+
del.fetchMock.mockResolvedValue({ ok: true, status: 204, statusText: 'No Content', json: async () => { throw new Error('no body'); }, headers: new Headers() });
258+
const out = await del.client.reports.unschedule('s1');
259+
expect(String(del.fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/reports/schedules/s1');
260+
expect(del.fetchMock.mock.calls[0][1].method).toBe('DELETE');
261+
expect(out).toEqual({ deleted: true });
262+
});
263+
});
264+
201265
describe('Approvals namespace (ADR-0019)', () => {
202266
it('should list approval requests with filters', async () => {
203267
const { client, fetchMock } = createMockClient({

packages/client/src/index.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2953,6 +2953,101 @@ export class ObjectStackClient {
29532953
}
29542954
};
29552955

2956+
/**
2957+
* Saved reports (#3587 gap closure)
2958+
*
2959+
* Tenant-wide report definitions, execution, and recurring email
2960+
* schedules, served by `@objectstack/plugin-reports` behind the REST
2961+
* surface. Every route 501s [NOT_IMPLEMENTED] on deployments without the
2962+
* reports service. Fixed path — `reports` is not in `ApiRoutesSchema`.
2963+
*/
2964+
reports = {
2965+
/** List saved reports, optionally filtered by object or owner. */
2966+
list: async (opts?: { object?: string; ownerId?: string }): Promise<any[]> => {
2967+
const params = new URLSearchParams();
2968+
if (opts?.object) params.set('object', opts.object);
2969+
if (opts?.ownerId) params.set('ownerId', opts.ownerId);
2970+
const qs = params.toString();
2971+
const res = await this.fetch(`${this.baseUrl}/api/v1/reports${qs ? `?${qs}` : ''}`);
2972+
const body = await this.unwrapResponse<{ data?: any[] } | any[]>(res);
2973+
return Array.isArray(body) ? body : (body?.data ?? []);
2974+
},
2975+
2976+
/** Create or update a saved report definition. 400 [VALIDATION_FAILED] on a bad spec. */
2977+
save: async (report: any): Promise<any> => {
2978+
const res = await this.fetch(`${this.baseUrl}/api/v1/reports`, {
2979+
method: 'POST',
2980+
body: JSON.stringify(report ?? {}),
2981+
});
2982+
return this.unwrapResponse<any>(res);
2983+
},
2984+
2985+
/** Get a saved report by id. 404 [REPORT_NOT_FOUND] when absent. */
2986+
get: async (id: string): Promise<any> => {
2987+
const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}`);
2988+
return this.unwrapResponse<any>(res);
2989+
},
2990+
2991+
/** Delete a saved report; its schedules cascade. */
2992+
delete: async (id: string): Promise<{ deleted: boolean }> => {
2993+
const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}`, {
2994+
method: 'DELETE',
2995+
});
2996+
if (res.status === 204) return { deleted: true };
2997+
return this.unwrapResponse<{ deleted: boolean }>(res);
2998+
},
2999+
3000+
/** Execute a saved report and return its rendered output. */
3001+
run: async (id: string): Promise<any> => {
3002+
const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}/run`, {
3003+
method: 'POST',
3004+
body: JSON.stringify({}),
3005+
});
3006+
return this.unwrapResponse<any>(res);
3007+
},
3008+
3009+
/**
3010+
* Create a recurring email schedule for a report. Provide either
3011+
* `intervalMinutes` or `cronExpression`; `recipients` is required.
3012+
*/
3013+
schedule: async (
3014+
id: string,
3015+
opts: {
3016+
recipients: string[];
3017+
name?: string;
3018+
intervalMinutes?: number;
3019+
cronExpression?: string;
3020+
timezone?: string;
3021+
format?: string;
3022+
subjectTemplate?: string;
3023+
ownerId?: string;
3024+
active?: boolean;
3025+
},
3026+
): Promise<any> => {
3027+
const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}/schedule`, {
3028+
method: 'POST',
3029+
body: JSON.stringify(opts),
3030+
});
3031+
return this.unwrapResponse<any>(res);
3032+
},
3033+
3034+
/** List the recurring schedules attached to a report. */
3035+
listSchedules: async (id: string): Promise<any[]> => {
3036+
const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}/schedules`);
3037+
const body = await this.unwrapResponse<{ data?: any[] } | any[]>(res);
3038+
return Array.isArray(body) ? body : (body?.data ?? []);
3039+
},
3040+
3041+
/** Delete a schedule by its id (report-independent path). */
3042+
unschedule: async (scheduleId: string): Promise<{ deleted: boolean }> => {
3043+
const res = await this.fetch(`${this.baseUrl}/api/v1/reports/schedules/${encodeURIComponent(scheduleId)}`, {
3044+
method: 'DELETE',
3045+
});
3046+
if (res.status === 204) return { deleted: true };
3047+
return this.unwrapResponse<{ deleted: boolean }>(res);
3048+
},
3049+
};
3050+
29563051
// The former `views` CRUD namespace was removed in #3612 — no server
29573052
// surface mounts /ui/views (both surfaces serve only /ui/view/:object…).
29583053
// View definitions are metadata: read and save them via `meta.*`.

packages/rest/src/rest-route-ledger.conformance.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,13 +144,13 @@ describe('REST route ledger hygiene', () => {
144144
});
145145

146146
it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
147-
// Ratchet, not aspiration: 43 audited gaps at #3587 PR-1; 34 after the
148-
// metadata batch closed its 9 (data-actions 2, search 1, email 1,
149-
// analytics 1, security-explain 2, record-shares 3, sharing-rules 5,
150-
// reports 8, approvals 6, external-datasource 5 remain). Closing a gap =
151-
// reclassify to `sdk` AND lower this bound. Raising it demands an
147+
// Ratchet, not aspiration: 43 audited gaps at #3587 PR-1; 26 after the
148+
// metadata batch closed its 9 and the reports batch its 8 (data-actions 2,
149+
// search 1, email 1, analytics 1, security-explain 2, record-shares 3,
150+
// sharing-rules 5, approvals 6, external-datasource 5 remain). Closing a
151+
// gap = reclassify to `sdk` AND lower this bound. Raising it demands an
152152
// explicit, reviewed decision.
153153
const gaps = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
154-
expect(gaps).toBeLessThanOrEqual(34);
154+
expect(gaps).toBeLessThanOrEqual(26);
155155
});
156156
});

packages/rest/src/rest-route-ledger.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -181,22 +181,14 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
181181
note: 'duplicate mount with the dispatcher /security domain' },
182182

183183
// ── reports ───────────────────────────────────────────────────────────────
184-
{ route: 'GET /api/v1/reports', family: 'reports', source: 'route-manager', disposition: 'gap',
185-
note: 'saved-report listing with no SDK expression' },
186-
{ route: 'POST /api/v1/reports', family: 'reports', source: 'route-manager', disposition: 'gap',
187-
note: 'saved-report upsert with no SDK expression' },
188-
{ route: 'GET /api/v1/reports/:id', family: 'reports', source: 'route-manager', disposition: 'gap',
189-
note: 'saved-report read with no SDK expression' },
190-
{ route: 'DELETE /api/v1/reports/:id', family: 'reports', source: 'route-manager', disposition: 'gap',
191-
note: 'saved-report delete (cascades schedules) with no SDK expression' },
192-
{ route: 'POST /api/v1/reports/:id/run', family: 'reports', source: 'route-manager', disposition: 'gap',
193-
note: 'report execution with no SDK expression' },
194-
{ route: 'POST /api/v1/reports/:id/schedule', family: 'reports', source: 'route-manager', disposition: 'gap',
195-
note: 'recurring email schedule creation with no SDK expression' },
196-
{ route: 'GET /api/v1/reports/:id/schedules', family: 'reports', source: 'route-manager', disposition: 'gap',
197-
note: 'schedule listing with no SDK expression' },
198-
{ route: 'DELETE /api/v1/reports/schedules/:scheduleId', family: 'reports', source: 'route-manager', disposition: 'gap',
199-
note: 'schedule delete with no SDK expression' },
184+
{ route: 'GET /api/v1/reports', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.list' },
185+
{ route: 'POST /api/v1/reports', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.save' },
186+
{ route: 'GET /api/v1/reports/:id', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.get' },
187+
{ route: 'DELETE /api/v1/reports/:id', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.delete' },
188+
{ route: 'POST /api/v1/reports/:id/run', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.run' },
189+
{ route: 'POST /api/v1/reports/:id/schedule', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.schedule' },
190+
{ route: 'GET /api/v1/reports/:id/schedules', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.listSchedules' },
191+
{ route: 'DELETE /api/v1/reports/schedules/:scheduleId', family: 'reports', source: 'route-manager', disposition: 'sdk', client: 'reports.unschedule' },
200192

201193
// ── approvals ─────────────────────────────────────────────────────────────
202194
{ route: 'GET /api/v1/approvals/requests', family: 'approvals', source: 'route-manager', disposition: 'sdk', client: 'approvals.listRequests' },

0 commit comments

Comments
 (0)