Skip to content

Commit d78d0d4

Browse files
authored
feat(http): validate API responses at runtime with valibot instead of blind casts (#266)
1 parent 61bc167 commit d78d0d4

3 files changed

Lines changed: 461 additions & 15 deletions

File tree

src/lib/http.ts

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { randomUUID } from 'node:crypto';
2+
import * as v from 'valibot';
23
import type { ErrorCode } from './errors.js';
34
import { ApiError, InterruptError, RequestTimeoutError, TransportError } from './errors.js';
45
import { VERSION } from '../version.js';
6+
import {
7+
BATCH_RERUN_RESPONSE_SCHEMA,
8+
BATCH_RUN_FRESH_RESPONSE_SCHEMA,
9+
LIST_RUNS_RESPONSE_SCHEMA,
10+
RERUN_RESPONSE_SCHEMA,
11+
RUN_RESPONSE_SCHEMA,
12+
TRIGGER_RUN_RESPONSE_SCHEMA,
13+
} from './response-schemas.js';
514
import type {
615
TriggerRunBody,
716
TriggerRunResponse,
@@ -108,10 +117,26 @@ export interface HttpClientOptions {
108117
shutdownSignal?: AbortSignal;
109118
}
110119

111-
export interface RequestOptions {
120+
export interface RequestOptions<T = unknown> {
112121
query?: Record<string, string | number | boolean | undefined>;
113122
signal?: AbortSignal;
114123
requestId?: string;
124+
/**
125+
* Optional valibot schema for the parsed 2xx response body (issue #102).
126+
*
127+
* When present, `requestWithMeta` runs `v.safeParse` on the OK-path JSON:
128+
* success returns the parsed output (unknown extra keys preserved via
129+
* `looseObject`); failure throws an INTERNAL `ApiError` envelope naming the
130+
* request path and the first {@link MAX_SCHEMA_ISSUES_IN_DETAILS} mismatched
131+
* field paths (never the body itself). When absent, behavior is unchanged:
132+
* the body is returned via the historical blind `as T` cast.
133+
*
134+
* Wired by the typed run helpers only (`triggerRun`, `triggerRunWithMeta`,
135+
* `triggerRerun`, `triggerBatchRerun`, `triggerBatchRunFresh`, `getRun`,
136+
* `listTestRuns`); generic `get`/`post`/... callers stay opt-in.
137+
* sourceRef: response-schemas.ts.
138+
*/
139+
schema?: v.GenericSchema<unknown, T>;
115140
/**
116141
* Optional JSON body for non-GET requests. Serialized with
117142
* `JSON.stringify`; `Content-Type: application/json` is auto-attached
@@ -168,6 +193,11 @@ const MAX_RATE_LIMITED_DELAY_MS = 60_000;
168193
const CONFLICT_DELAY_MS = 1000;
169194
const INTERNAL_DELAY_MS = 500;
170195

196+
// Cap on how many valibot issues a shape-mismatch INTERNAL envelope carries in
197+
// `details.issues` (path + message each). Keeps the envelope readable and
198+
// guarantees the response body itself is never echoed back to the operator.
199+
const MAX_SCHEMA_ISSUES_IN_DETAILS = 3;
200+
171201
/**
172202
* Result of a successful HTTP request, including the parsed body and the
173203
* `x-request-id` that was sent (useful for surfacing in happy-path output).
@@ -222,23 +252,23 @@ export class HttpClient {
222252
}
223253
}
224254

225-
async get<T>(path: string, options: RequestOptions = {}): Promise<T> {
255+
async get<T>(path: string, options: RequestOptions<T> = {}): Promise<T> {
226256
return this.requestWithMeta<T>('GET', path, options).then(r => r.body);
227257
}
228258

229-
async post<T>(path: string, options: RequestOptions = {}): Promise<T> {
259+
async post<T>(path: string, options: RequestOptions<T> = {}): Promise<T> {
230260
return this.requestWithMeta<T>('POST', path, options).then(r => r.body);
231261
}
232262

233-
async put<T>(path: string, options: RequestOptions = {}): Promise<T> {
263+
async put<T>(path: string, options: RequestOptions<T> = {}): Promise<T> {
234264
return this.requestWithMeta<T>('PUT', path, options).then(r => r.body);
235265
}
236266

237-
async patch<T>(path: string, options: RequestOptions = {}): Promise<T> {
267+
async patch<T>(path: string, options: RequestOptions<T> = {}): Promise<T> {
238268
return this.requestWithMeta<T>('PATCH', path, options).then(r => r.body);
239269
}
240270

241-
async delete<T>(path: string, options: RequestOptions = {}): Promise<T> {
271+
async delete<T>(path: string, options: RequestOptions<T> = {}): Promise<T> {
242272
return this.requestWithMeta<T>('DELETE', path, options).then(r => r.body);
243273
}
244274

@@ -247,23 +277,26 @@ export class HttpClient {
247277
* `requestId` and `status`, so callers can surface the requestId in
248278
* happy-path output (dogfood item 1).
249279
*/
250-
async getWithMeta<T>(path: string, options: RequestOptions = {}): Promise<RequestResult<T>> {
280+
async getWithMeta<T>(path: string, options: RequestOptions<T> = {}): Promise<RequestResult<T>> {
251281
return this.requestWithMeta<T>('GET', path, options);
252282
}
253283

254-
async postWithMeta<T>(path: string, options: RequestOptions = {}): Promise<RequestResult<T>> {
284+
async postWithMeta<T>(path: string, options: RequestOptions<T> = {}): Promise<RequestResult<T>> {
255285
return this.requestWithMeta<T>('POST', path, options);
256286
}
257287

258-
async putWithMeta<T>(path: string, options: RequestOptions = {}): Promise<RequestResult<T>> {
288+
async putWithMeta<T>(path: string, options: RequestOptions<T> = {}): Promise<RequestResult<T>> {
259289
return this.requestWithMeta<T>('PUT', path, options);
260290
}
261291

262-
async patchWithMeta<T>(path: string, options: RequestOptions = {}): Promise<RequestResult<T>> {
292+
async patchWithMeta<T>(path: string, options: RequestOptions<T> = {}): Promise<RequestResult<T>> {
263293
return this.requestWithMeta<T>('PATCH', path, options);
264294
}
265295

266-
async deleteWithMeta<T>(path: string, options: RequestOptions = {}): Promise<RequestResult<T>> {
296+
async deleteWithMeta<T>(
297+
path: string,
298+
options: RequestOptions<T> = {},
299+
): Promise<RequestResult<T>> {
267300
return this.requestWithMeta<T>('DELETE', path, options);
268301
}
269302

@@ -282,6 +315,7 @@ export class HttpClient {
282315
body,
283316
headers: { 'idempotency-key': options.idempotencyKey },
284317
signal: options.signal,
318+
schema: TRIGGER_RUN_RESPONSE_SCHEMA,
285319
// 409 on POST /runs means "another run is already in flight" — a
286320
// persistent condition, not a transient snapshot conflict. Retrying
287321
// would enqueue a second run once the first finishes.
@@ -310,6 +344,7 @@ export class HttpClient {
310344
body,
311345
headers: { 'idempotency-key': options.idempotencyKey },
312346
signal: options.signal,
347+
schema: TRIGGER_RUN_RESPONSE_SCHEMA,
313348
retryOnConflict: false,
314349
// Default true: single `test run` / `test create --run` retain 429 retry.
315350
// Batch call site passes false to keep outer-loop as sole rate-limit owner.
@@ -334,6 +369,7 @@ export class HttpClient {
334369
body,
335370
headers: { 'idempotency-key': options.idempotencyKey },
336371
signal: options.signal,
372+
schema: RERUN_RESPONSE_SCHEMA,
337373
retryOnConflict: false,
338374
}).then(r => r.body);
339375
}
@@ -353,6 +389,7 @@ export class HttpClient {
353389
body,
354390
headers: { 'idempotency-key': options.idempotencyKey },
355391
signal: options.signal,
392+
schema: BATCH_RERUN_RESPONSE_SCHEMA,
356393
retryOnConflict: false,
357394
}).then(r => r.body);
358395
}
@@ -373,6 +410,7 @@ export class HttpClient {
373410
body,
374411
headers: { 'idempotency-key': options.idempotencyKey },
375412
signal: options.signal,
413+
schema: BATCH_RUN_FRESH_RESPONSE_SCHEMA,
376414
retryOnConflict: false,
377415
}).then(r => r.body);
378416
}
@@ -391,7 +429,10 @@ export class HttpClient {
391429
if (query.pageSize !== undefined) q.pageSize = query.pageSize;
392430
if (query.source !== undefined) q.source = query.source;
393431
if (query.since !== undefined) q.since = query.since;
394-
return this.get<ListRunsResponse>(`/tests/${encodeURIComponent(testId)}/runs`, { query: q });
432+
return this.get<ListRunsResponse>(`/tests/${encodeURIComponent(testId)}/runs`, {
433+
query: q,
434+
schema: LIST_RUNS_RESPONSE_SCHEMA,
435+
});
395436
}
396437

397438
/**
@@ -421,6 +462,7 @@ export class HttpClient {
421462
return this.get<RunResponse>(`/runs/${encodeURIComponent(runId)}`, {
422463
query: Object.keys(query).length > 0 ? query : undefined,
423464
signal: options?.signal,
465+
schema: RUN_RESPONSE_SCHEMA,
424466
});
425467
}
426468

@@ -481,7 +523,7 @@ export class HttpClient {
481523
async requestWithMeta<T>(
482524
method: string,
483525
path: string,
484-
options: RequestOptions = {},
526+
options: RequestOptions<T> = {},
485527
): Promise<RequestResult<T>> {
486528
if (!this.apiKey) throw ApiError.authRequired();
487529

@@ -594,8 +636,9 @@ export class HttpClient {
594636
requestId,
595637
durationMs,
596638
});
639+
let raw: unknown;
597640
try {
598-
return { body: (await response.json()) as T, requestId, status: response.status };
641+
raw = await response.json();
599642
} catch (err) {
600643
// Interrupt passthrough (see the fetch catch above).
601644
if (err instanceof InterruptError) throw err;
@@ -609,6 +652,34 @@ export class HttpClient {
609652
// and break the --output json envelope contract.
610653
throw malformedResponseError(response, requestId, err);
611654
}
655+
if (options.schema !== undefined) {
656+
const parsed = v.safeParse(options.schema, raw);
657+
if (!parsed.success) {
658+
const issues = parsed.issues.slice(0, MAX_SCHEMA_ISSUES_IN_DETAILS).map(issue => ({
659+
path: v.getDotPath(issue) ?? '(root)',
660+
message: issue.message,
661+
}));
662+
// Shape drift is a server-side contract break: surface a typed
663+
// INTERNAL envelope (requestId + the first mismatched paths,
664+
// never the body) instead of letting a blind cast poison
665+
// downstream output with undefined fields or a raw TypeError.
666+
throw ApiError.fromEnvelope(
667+
{
668+
error: {
669+
code: 'INTERNAL',
670+
message: `Response shape mismatch from ${shortPath(path)}.`,
671+
nextAction:
672+
'Retry; if it persists, report this requestId (the server returned an unexpected shape).',
673+
requestId,
674+
details: { issues },
675+
},
676+
},
677+
response.status,
678+
);
679+
}
680+
return { body: parsed.output as T, requestId, status: response.status };
681+
}
682+
return { body: raw as T, requestId, status: response.status };
612683
}
613684

614685
let rawBody: unknown;
@@ -790,7 +861,7 @@ export class HttpClient {
790861
* `client.request(...)` directly. New callers should use
791862
* `requestWithMeta` or the typed helpers (`get`, `post`, etc.).
792863
*/
793-
async request<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
864+
async request<T>(method: string, path: string, options: RequestOptions<T> = {}): Promise<T> {
794865
return this.requestWithMeta<T>(method, path, options).then(r => r.body);
795866
}
796867
}

src/lib/response-schemas.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Dedicated tests for the response schemas (issue #102): the schemas must be
3+
* loose (additive server fields pass), mirror nullability, and turn drift
4+
* into a typed INTERNAL envelope at the HttpClient boundary.
5+
*/
6+
7+
import { describe, expect, it } from 'vitest';
8+
import * as v from 'valibot';
9+
import { HttpClient } from './http.js';
10+
import { RUN_RESPONSE_SCHEMA, TRIGGER_RUN_RESPONSE_SCHEMA } from './response-schemas.js';
11+
12+
const VALID_RUN = {
13+
runId: 'run_1',
14+
testId: 'test_1',
15+
projectId: 'p_1',
16+
userId: 'u_1',
17+
status: 'passed',
18+
source: 'cli',
19+
createdAt: '2026-06-01T10:00:00.000Z',
20+
startedAt: null,
21+
finishedAt: null,
22+
codeVersion: 'v1',
23+
targetUrl: 'https://example.com',
24+
createdFrom: null,
25+
failedStepIndex: null,
26+
failureKind: null,
27+
error: null,
28+
videoUrl: null,
29+
stepSummary: { total: 0, completed: 0, passedCount: 0, failedCount: 0 },
30+
};
31+
32+
function makeClient(fetchImpl: typeof fetch): HttpClient {
33+
return new HttpClient({
34+
baseUrl: 'https://api.example.com/api/cli/v1',
35+
apiKey: 'sk-test',
36+
fetchImpl,
37+
sleep: () => Promise.resolve(),
38+
random: () => 0,
39+
});
40+
}
41+
42+
describe('RUN_RESPONSE_SCHEMA', () => {
43+
it('accepts a valid run and preserves unknown extra keys (additive drift is non-breaking)', () => {
44+
const parsed = v.safeParse(RUN_RESPONSE_SCHEMA, {
45+
...VALID_RUN,
46+
someFutureField: 'kept',
47+
});
48+
expect(parsed.success).toBe(true);
49+
if (parsed.success) {
50+
expect((parsed.output as { someFutureField?: string }).someFutureField).toBe('kept');
51+
}
52+
});
53+
54+
it('rejects a run missing a required field, naming the path', () => {
55+
const withoutStatus: Record<string, unknown> = { ...VALID_RUN };
56+
delete withoutStatus.status;
57+
const parsed = v.safeParse(RUN_RESPONSE_SCHEMA, withoutStatus);
58+
expect(parsed.success).toBe(false);
59+
if (!parsed.success) {
60+
expect(parsed.issues.some(issue => v.getDotPath(issue) === 'status')).toBe(true);
61+
}
62+
});
63+
});
64+
65+
describe('HttpClient schema hook', () => {
66+
it('getRun surfaces drift as a typed INTERNAL envelope with issue paths (never a blind cast)', async () => {
67+
const drifted: Record<string, unknown> = { ...VALID_RUN };
68+
delete drifted.status;
69+
const fetchImpl = (async () =>
70+
new Response(JSON.stringify(drifted), {
71+
status: 200,
72+
headers: { 'content-type': 'application/json' },
73+
})) as typeof fetch;
74+
const client = makeClient(fetchImpl);
75+
const rejection = await client.getRun('run_1').catch((error: unknown) => error);
76+
expect(rejection).toMatchObject({ code: 'INTERNAL' });
77+
const issues = (rejection as { getDetail: (key: string) => unknown }).getDetail('issues');
78+
expect(Array.isArray(issues)).toBe(true);
79+
expect(JSON.stringify(issues)).toContain('status');
80+
});
81+
82+
it('a schemaless generic get still returns whatever JSON came back (unchanged behavior)', async () => {
83+
const fetchImpl = (async () =>
84+
new Response(JSON.stringify({ anything: true }), {
85+
status: 200,
86+
headers: { 'content-type': 'application/json' },
87+
})) as typeof fetch;
88+
const client = makeClient(fetchImpl);
89+
await expect(client.get('/me')).resolves.toEqual({ anything: true });
90+
});
91+
});
92+
93+
describe('TRIGGER_RUN_RESPONSE_SCHEMA', () => {
94+
it('accepts the queued-run envelope', () => {
95+
const parsed = v.safeParse(TRIGGER_RUN_RESPONSE_SCHEMA, {
96+
runId: 'run_1',
97+
status: 'queued',
98+
enqueuedAt: '2026-06-01T10:00:00.000Z',
99+
codeVersion: 'v1',
100+
targetUrl: 'https://example.com',
101+
});
102+
expect(parsed.success).toBe(true);
103+
});
104+
});

0 commit comments

Comments
 (0)