@gusnips/sdkgen 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ /**
2
+ * The transport, driven through a fake fetch. Each row of the retry matrix is a call the retry
3
+ * rule answers differently for a read and a write, with and without a key: the rule itself is
4
+ * tested in `@gusnips/http`, and what is tested here is that the transport asks it the right
5
+ * question.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import {
9
+ send,
10
+ type Failure,
11
+ type RequestOptions,
12
+ type RequestSpec,
13
+ type Transport,
14
+ } from "./transport.ts";
15
+
16
+ const BASE = "https://api.example.test/v1";
17
+
18
+ /** A failure turned into an error the way an SDK would, keeping the whole failure to assert on. */
19
+ class SdkError extends Error {
20
+ constructor(readonly failure: Failure) {
21
+ super(failure.error?.message ?? `${failure.method} ${failure.path} failed (${failure.status})`);
22
+ }
23
+ }
24
+
25
+ type Reply = () => Response;
26
+
27
+ const ok =
28
+ (data: unknown, meta?: unknown): Reply =>
29
+ () =>
30
+ Response.json(meta === undefined ? { data } : { data, meta });
31
+
32
+ /** A refusal in the envelope: `{ error: { code, message, details } }`. */
33
+ function refuse(
34
+ status: number,
35
+ {
36
+ code = "REFUSED",
37
+ details,
38
+ headers,
39
+ }: { code?: string; details?: unknown; headers?: Record<string, string> } = {},
40
+ ): Reply {
41
+ return () =>
42
+ Response.json(
43
+ {
44
+ error: {
45
+ code,
46
+ message: `Refused with ${status}.`,
47
+ ...(details !== undefined && { details }),
48
+ },
49
+ },
50
+ { status, headers },
51
+ );
52
+ }
53
+
54
+ /** No answer at all: what fetch does offline. */
55
+ const offline: Reply = () => {
56
+ throw new TypeError("fetch failed");
57
+ };
58
+
59
+ interface Call {
60
+ url: URL;
61
+ method: string | undefined;
62
+ headers: Headers;
63
+ body: unknown;
64
+ at: number;
65
+ }
66
+
67
+ /** A fetch that answers with `replies` in order, repeating the last one. */
68
+ function fakeFetch(replies: Reply[]) {
69
+ const calls: Call[] = [];
70
+ const fetch = async (
71
+ input: string | URL | Request,
72
+ init: RequestInit = {},
73
+ ): Promise<Response> => {
74
+ calls.push({
75
+ url: new URL(String(input)),
76
+ method: init.method,
77
+ headers: new Headers(init.headers),
78
+ body: init.body,
79
+ at: Date.now(),
80
+ });
81
+ const reply = replies[Math.min(calls.length, replies.length) - 1];
82
+ if (reply === undefined) throw new Error("fakeFetch needs at least one reply");
83
+ return reply();
84
+ };
85
+ return { fetch, calls };
86
+ }
87
+
88
+ type Outcome = { ok: true; data: unknown; meta: unknown } | { ok: false; error: unknown };
89
+
90
+ /**
91
+ * Sends one call and plays every timer it sets, so a retry's wait takes no real time while
92
+ * `Date.now()` still moves by it.
93
+ */
94
+ async function call(
95
+ spec: RequestSpec,
96
+ replies: Reply[],
97
+ {
98
+ params,
99
+ opts,
100
+ transport,
101
+ }: { params?: object; opts?: RequestOptions; transport?: Partial<Transport> } = {},
102
+ ) {
103
+ const { fetch, calls } = fakeFetch(replies);
104
+ let outcome: Outcome | undefined;
105
+ send(
106
+ { baseUrl: BASE, fetch, error: (failure) => new SdkError(failure), ...transport },
107
+ spec,
108
+ params,
109
+ opts,
110
+ ).then(
111
+ ({ data, meta }) => {
112
+ outcome = { ok: true, data, meta };
113
+ },
114
+ (error: unknown) => {
115
+ outcome = { ok: false, error };
116
+ },
117
+ );
118
+ while (outcome === undefined) {
119
+ await new Promise((resolve) => setImmediate(resolve));
120
+ vi.advanceTimersToNextTimer();
121
+ }
122
+ return { calls, outcome };
123
+ }
124
+
125
+ function failureOf(outcome: Outcome): Failure {
126
+ if (outcome.ok || !(outcome.error instanceof SdkError)) {
127
+ throw new Error(`expected an SdkError, got ${JSON.stringify(outcome)}`);
128
+ }
129
+ return outcome.error.failure;
130
+ }
131
+
132
+ beforeEach(() => {
133
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
134
+ vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
135
+ // Backoff lands on 1 s·2ⁿ exactly.
136
+ vi.spyOn(Math, "random").mockReturnValue(0.5);
137
+ });
138
+
139
+ afterEach(() => {
140
+ vi.useRealTimers();
141
+ vi.restoreAllMocks();
142
+ });
143
+
144
+ const GET: RequestSpec = { method: "GET", path: "/things" };
145
+ const WRITE: RequestSpec = { method: "POST", path: "/things" };
146
+ const KEYED: RequestSpec = { method: "POST", path: "/things", keyed: true };
147
+ const KEY = { idempotencyKey: "k-1" };
148
+
149
+ describe("send: which failures are tried again", () => {
150
+ it.each<
151
+ [
152
+ string,
153
+ RequestSpec,
154
+ Reply[],
155
+ number,
156
+ { opts?: RequestOptions; transport?: Partial<Transport> }?,
157
+ ]
158
+ >([
159
+ ["a read after a 503", GET, [refuse(503), ok(1)], 2],
160
+ ["a write after a 503: never, it may have run", WRITE, [refuse(503), ok(1)], 1],
161
+ [
162
+ "a keyed write with the caller's key after a 503",
163
+ KEYED,
164
+ [refuse(503), ok(1)],
165
+ 2,
166
+ { opts: KEY },
167
+ ],
168
+ ["a keyed write with no key, none minted", KEYED, [refuse(503), ok(1)], 1],
169
+ [
170
+ "a keyed write with a minted key",
171
+ KEYED,
172
+ [refuse(503), ok(1)],
173
+ 2,
174
+ { transport: { mintKeys: true } },
175
+ ],
176
+ ["a write the API marks repeatable", { ...WRITE, repeatable: true }, [refuse(503), ok(1)], 2],
177
+ ["a read marked not repeatable", { ...GET, repeatable: false }, [refuse(503), ok(1)], 1],
178
+ ["a read with no answer", GET, [offline, ok(1)], 2],
179
+ ["a write with no answer: never, it may have run", WRITE, [offline, ok(1)], 1],
180
+ ["a write after a 408: nothing ran", WRITE, [refuse(408), ok(1)], 2],
181
+ ["a write after a 425: nothing ran", WRITE, [refuse(425), ok(1)], 2],
182
+ ["a write after a 429 with no wait: nothing ran", WRITE, [refuse(429), ok(1)], 2],
183
+ [
184
+ "a keyed write after a 409 that states a wait: its first run is still going",
185
+ KEYED,
186
+ [refuse(409, { headers: { "retry-after": "1" } }), ok(1)],
187
+ 2,
188
+ { opts: KEY },
189
+ ],
190
+ [
191
+ "a keyed write after a 409 with no wait: an answer",
192
+ KEYED,
193
+ [refuse(409), ok(1)],
194
+ 1,
195
+ { opts: KEY },
196
+ ],
197
+ [
198
+ "a write with no key after a 409 with a wait",
199
+ WRITE,
200
+ [refuse(409, { headers: { "retry-after": "1" } }), ok(1)],
201
+ 1,
202
+ ],
203
+ [
204
+ "a read refused with a durable code",
205
+ GET,
206
+ [refuse(503, { code: "QUOTA_SPENT" }), ok(1)],
207
+ 1,
208
+ { transport: { durableCodes: ["QUOTA_SPENT"] } },
209
+ ],
210
+ [
211
+ "a read whose answer says waiting never helps",
212
+ GET,
213
+ [refuse(503, { details: { retryAfterSecs: null } }), ok(1)],
214
+ 1,
215
+ ],
216
+ [
217
+ "a read whose stated wait is over the longest worth holding",
218
+ GET,
219
+ [refuse(429, { headers: { "retry-after": "11" } }), ok(1)],
220
+ 1,
221
+ ],
222
+ [
223
+ "a read whose header says 2 s and body says 20 s: the header is read first",
224
+ GET,
225
+ [refuse(429, { headers: { "retry-after": "2" }, details: { retryAfterSecs: 20 } }), ok(1)],
226
+ 2,
227
+ ],
228
+ ["a read after a 400: an answer", GET, [refuse(400), ok(1)], 1],
229
+ ["a read after 503s, until the retries run out", GET, [refuse(503)], 3],
230
+ [
231
+ "a read with retries turned off",
232
+ GET,
233
+ [refuse(503), ok(1)],
234
+ 1,
235
+ { transport: { maxRetries: 0 } },
236
+ ],
237
+ ])("%s", async (_label, spec, replies, attempts, options) => {
238
+ const { calls, outcome } = await call(spec, replies, options);
239
+ expect(calls).toHaveLength(attempts);
240
+ const worked = replies.length > 1 && attempts === replies.length;
241
+ expect(outcome.ok).toBe(worked);
242
+ });
243
+
244
+ it("waits what the header says, in seconds or as a date, and backs off otherwise", async () => {
245
+ const seconds = await call(GET, [refuse(429, { headers: { "retry-after": "3" } }), ok(1)]);
246
+ expect(seconds.calls[1]!.at - seconds.calls[0]!.at).toBe(3000);
247
+
248
+ const date = new Date(Date.now() + 3000).toUTCString();
249
+ const dated = await call(GET, [refuse(503, { headers: { "retry-after": date } }), ok(1)]);
250
+ expect(dated.calls[1]!.at - dated.calls[0]!.at).toBe(3000);
251
+
252
+ const backoff = await call(GET, [refuse(503), refuse(503), ok(1)]);
253
+ const [first, second, third] = backoff.calls.map((c) => c.at);
254
+ expect([second! - first!, third! - second!]).toEqual([1000, 2000]);
255
+ });
256
+ });
257
+
258
+ describe("send: the idempotency key", () => {
259
+ it("mints one key per call and sends it on every attempt", async () => {
260
+ const { calls, outcome } = await call(KEYED, [refuse(503), ok(1)], {
261
+ transport: { mintKeys: true },
262
+ });
263
+ expect(outcome.ok).toBe(true);
264
+ const keys = calls.map((c) => c.headers.get("idempotency-key"));
265
+ expect(keys[0]).toMatch(/^[0-9a-f-]{36}$/);
266
+ expect(keys[1]).toBe(keys[0]);
267
+ });
268
+
269
+ it("sends the caller's key, and hands it to the error so a later retry can reuse it", async () => {
270
+ const { calls, outcome } = await call(KEYED, [refuse(400)], {
271
+ opts: KEY,
272
+ transport: { mintKeys: true },
273
+ });
274
+ expect(calls[0]!.headers.get("idempotency-key")).toBe("k-1");
275
+ expect(failureOf(outcome).idempotencyKey).toBe("k-1");
276
+ });
277
+
278
+ it("sends no key on a call that takes none, even when minting", async () => {
279
+ const { calls } = await call(WRITE, [ok(1)], { transport: { mintKeys: true } });
280
+ expect(calls[0]!.headers.has("idempotency-key")).toBe(false);
281
+ });
282
+
283
+ it("refuses a key for a call that takes none, before sending anything", async () => {
284
+ const { calls, outcome } = await call(WRITE, [ok(1)], { opts: KEY });
285
+ expect(calls).toHaveLength(0);
286
+ expect(outcome.ok ? undefined : outcome.error).toBeInstanceOf(TypeError);
287
+ });
288
+ });
289
+
290
+ describe("send: the request", () => {
291
+ it("fills the path, and puts the rest of a read in the query, a list as the name repeated", async () => {
292
+ const { calls } = await call({ method: "GET", path: "/numbers/:numberId/messages" }, [ok([])], {
293
+ params: {
294
+ numberId: "n 1/a",
295
+ status: ["sent", "read"],
296
+ limit: 10,
297
+ cursor: undefined,
298
+ after: null,
299
+ },
300
+ });
301
+ const { url, body, headers } = calls[0]!;
302
+ expect(url.pathname).toBe("/v1/numbers/n%201%2Fa/messages");
303
+ expect(url.search).toBe("?status=sent&status=read&limit=10");
304
+ expect(body).toBeUndefined();
305
+ expect(headers.has("content-type")).toBe(false);
306
+ });
307
+
308
+ it("puts a DELETE's params in the query and sends no body", async () => {
309
+ const { calls } = await call({ method: "DELETE", path: "/numbers/:id" }, [ok(null)], {
310
+ params: { id: "n1", force: true },
311
+ });
312
+ expect(calls[0]!.method).toBe("DELETE");
313
+ expect(calls[0]!.url.search).toBe("?force=true");
314
+ expect(calls[0]!.body).toBeUndefined();
315
+ });
316
+
317
+ it("sends a write's params as JSON, without the ones the path took, and `{}` when there are none", async () => {
318
+ const { calls } = await call({ method: "PATCH", path: "/numbers/:id" }, [ok(1)], {
319
+ params: { id: "n1", name: "Sales", tags: ["a"] },
320
+ });
321
+ expect(calls[0]!.headers.get("content-type")).toBe("application/json");
322
+ expect(JSON.parse(String(calls[0]!.body))).toEqual({ name: "Sales", tags: ["a"] });
323
+
324
+ const empty = await call(WRITE, [ok(1)]);
325
+ expect(empty.calls[0]!.body).toBe("{}");
326
+ });
327
+
328
+ it("refuses a path value it does not have, rather than send an empty segment", async () => {
329
+ const spec: RequestSpec = { method: "POST", path: "/numbers/:id/pair" };
330
+ for (const params of [{}, { id: "" }, { id: null }, { id: { nested: true } }]) {
331
+ const { calls, outcome } = await call(spec, [ok(1)], { params });
332
+ expect(calls).toHaveLength(0);
333
+ expect(outcome.ok ? undefined : outcome.error).toBeInstanceOf(TypeError);
334
+ }
335
+ });
336
+
337
+ it("refuses an object in a query string, rather than send [object Object]", async () => {
338
+ const { calls, outcome } = await call(GET, [ok(1)], { params: { filter: { a: 1 } } });
339
+ expect(calls).toHaveLength(0);
340
+ expect(outcome.ok ? undefined : outcome.error).toBeInstanceOf(TypeError);
341
+ });
342
+
343
+ it("sends the SDK's headers, asks for JSON, and joins a base URL with a trailing slash", async () => {
344
+ const { calls } = await call(GET, [ok(1)], {
345
+ transport: { baseUrl: `${BASE}/`, headers: { authorization: "Bearer k" } },
346
+ });
347
+ expect(calls[0]!.url.toString()).toBe(`${BASE}/things`);
348
+ expect(calls[0]!.headers.get("authorization")).toBe("Bearer k");
349
+ expect(calls[0]!.headers.get("accept")).toBe("application/json");
350
+ });
351
+
352
+ it("gives the timeout hook the spec and params, and lets a call override it", async () => {
353
+ const timeoutMs = vi.fn(() => 5000);
354
+ const hooked = await call(WRITE, [offline], { params: { a: 1 }, transport: { timeoutMs } });
355
+ expect(timeoutMs).toHaveBeenCalledWith(WRITE, { a: 1 });
356
+ expect(failureOf(hooked.outcome).timeoutMs).toBe(5000);
357
+
358
+ const own = await call(WRITE, [offline], {
359
+ opts: { timeoutMs: 700 },
360
+ transport: { timeoutMs },
361
+ });
362
+ expect(failureOf(own.outcome).timeoutMs).toBe(700);
363
+
364
+ const fallback = await call(WRITE, [offline]);
365
+ expect(failureOf(fallback.outcome).timeoutMs).toBe(30_000);
366
+ });
367
+ });
368
+
369
+ describe("send: the answer", () => {
370
+ it("returns data and meta", async () => {
371
+ const { outcome } = await call(GET, [ok({ id: "a" }, { total: 1 })]);
372
+ expect(outcome).toEqual({ ok: true, data: { id: "a" }, meta: { total: 1 } });
373
+ });
374
+
375
+ it("gives undefined data for a 204 or an empty 200", async () => {
376
+ const noContent = await call(WRITE, [() => new Response(null, { status: 204 })]);
377
+ expect(noContent.outcome).toEqual({ ok: true, data: undefined, meta: undefined });
378
+ const empty = await call(WRITE, [() => new Response("", { status: 200 })]);
379
+ expect(empty.outcome).toEqual({ ok: true, data: undefined, meta: undefined });
380
+ });
381
+
382
+ it("hands the error builder the envelope, the wait and the request id", async () => {
383
+ const reply = refuse(429, {
384
+ code: "RATE_LIMITED",
385
+ details: { limit: 5 },
386
+ headers: { "retry-after": "60", "x-request-id": "req_1" },
387
+ });
388
+ const failure = failureOf((await call(WRITE, [reply])).outcome);
389
+ expect(failure).toMatchObject({
390
+ method: "POST",
391
+ path: "/things",
392
+ status: 429,
393
+ timedOut: false,
394
+ error: { code: "RATE_LIMITED", message: "Refused with 429.", details: { limit: 5 } },
395
+ text: undefined,
396
+ retryAfterSecs: 60,
397
+ requestId: "req_1",
398
+ });
399
+ });
400
+
401
+ it("keeps the text of an answer that is not the envelope, a 2xx included", async () => {
402
+ const html = () => new Response("<html>Bad gateway</html>", { status: 502 });
403
+ const gateway = failureOf((await call(WRITE, [html])).outcome);
404
+ expect(gateway).toMatchObject({
405
+ status: 502,
406
+ error: undefined,
407
+ text: "<html>Bad gateway</html>",
408
+ });
409
+
410
+ const notJson = () => new Response("<html>Welcome</html>", { status: 200 });
411
+ const portal = failureOf((await call(GET, [notJson])).outcome);
412
+ expect(portal).toMatchObject({ status: 200, text: "<html>Welcome</html>" });
413
+ });
414
+
415
+ it("reports no answer as status 0, and says when it was the timeout", async () => {
416
+ const timeout: Reply = () => {
417
+ throw new DOMException("The operation timed out.", "TimeoutError");
418
+ };
419
+ const failure = failureOf((await call(WRITE, [timeout])).outcome);
420
+ expect(failure).toMatchObject({ status: 0, timedOut: true });
421
+ expect(failureOf((await call(WRITE, [offline])).outcome)).toMatchObject({
422
+ status: 0,
423
+ timedOut: false,
424
+ });
425
+ });
426
+ });