@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,503 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import ts from "typescript";
6
+ import { describe, expect, it } from "vitest";
7
+ import { z } from "zod";
8
+ import {
9
+ retrySource,
10
+ sdkMethods,
11
+ transportSource,
12
+ type SdkMethodsOptions,
13
+ type SdkOperation,
14
+ } from "./methods.ts";
15
+ import { writeGenerated } from "./write.ts";
16
+
17
+ const SEND: SdkOperation = {
18
+ name: "send_message",
19
+ method: "post",
20
+ path: "/messages",
21
+ summary: "Send a message.",
22
+ description: "Queues it and answers with its id.",
23
+ input: z.object({ to: z.string().describe("Who gets it."), text: z.string() }),
24
+ headers: [{ name: "Idempotency-Key" }],
25
+ sdk: { method: "sendMessage", returns: "MessageDto" },
26
+ };
27
+
28
+ const GET_NUMBER: SdkOperation = {
29
+ name: "get_number",
30
+ method: "get",
31
+ path: "/numbers/:id{[0-9a-z_]+}",
32
+ summary: "Read a number.",
33
+ input: z.object({ numberId: z.string() }),
34
+ params: { numberId: "id" },
35
+ sdk: { method: "numbers.get", returns: "NumberDto" },
36
+ };
37
+
38
+ const LIST_NUMBERS: SdkOperation = {
39
+ name: "list_numbers",
40
+ method: "get",
41
+ path: "/numbers",
42
+ summary: "List your numbers.",
43
+ input: z.object({ status: z.array(z.enum(["on", "off"])).optional() }),
44
+ sdk: { method: "numbers.list", returns: "Page<NumberDto>" },
45
+ };
46
+
47
+ const DELETE_NUMBER: SdkOperation = {
48
+ name: "delete_number",
49
+ method: "delete",
50
+ path: "/numbers/:id",
51
+ summary: "Delete a number.",
52
+ input: z.object({ id: z.string() }),
53
+ status: 204,
54
+ sdk: { method: "numbers.delete", returns: "void" },
55
+ };
56
+
57
+ const HEALTH: SdkOperation = {
58
+ name: "health",
59
+ method: "get",
60
+ path: "/health",
61
+ summary: "Check the API is up.",
62
+ sdk: { method: "health", returns: "HealthDto" },
63
+ };
64
+
65
+ /** An operation with no `sdk`: the API serves it, the SDK has no method for it. */
66
+ const INTERNAL: SdkOperation = {
67
+ name: "rebuild",
68
+ method: "post",
69
+ path: "/internal/rebuild",
70
+ summary: "Rebuild the index.",
71
+ };
72
+
73
+ const ALL = [SEND, GET_NUMBER, LIST_NUMBERS, DELETE_NUMBER, HEALTH, INTERNAL];
74
+
75
+ describe("sdkMethods", () => {
76
+ it("writes a method per placed operation, top level first, each with its spec", () => {
77
+ const { members } = sdkMethods([GET_NUMBER, SEND, INTERNAL]);
78
+ expect(members).toBe(`
79
+ /**
80
+ * Send a message.
81
+ *
82
+ * Queues it and answers with its id.
83
+ */
84
+ sendMessage(params: SendMessageParams, opts?: RequestOptions): Promise<MessageDto> {
85
+ return this.request({ method: "POST", path: "/messages", keyed: true }, params, opts);
86
+ }
87
+
88
+ readonly numbers = {
89
+ /**
90
+ * Read a number.
91
+ */
92
+ get: (params: GetNumberParams, opts?: RequestOptions): Promise<NumberDto> =>
93
+ this.request({ method: "GET", path: "/numbers/:numberId" }, params, opts),
94
+ };
95
+ `);
96
+ });
97
+
98
+ it("writes the params interfaces and lists the names to import", () => {
99
+ const result = sdkMethods(ALL);
100
+ expect(result.params).toBe(`/** Arguments for \`sendMessage()\`. */
101
+ export interface SendMessageParams {
102
+ /** Who gets it. */
103
+ to: string;
104
+ text: string;
105
+ }
106
+
107
+ /** Arguments for \`numbers.get()\`. */
108
+ export interface GetNumberParams {
109
+ numberId: string;
110
+ }
111
+
112
+ /** Arguments for \`numbers.list()\`. */
113
+ export interface ListNumbersParams {
114
+ status?: ("on" | "off")[];
115
+ }
116
+
117
+ /** Arguments for \`numbers.delete()\`. */
118
+ export interface DeleteNumberParams {
119
+ id: string;
120
+ }
121
+ `);
122
+ expect(result.paramTypes).toEqual([
123
+ "DeleteNumberParams",
124
+ "GetNumberParams",
125
+ "ListNumbersParams",
126
+ "SendMessageParams",
127
+ ]);
128
+ expect(result.returnTypes).toEqual(["HealthDto", "MessageDto", "NumberDto", "Page"]);
129
+ });
130
+
131
+ it("makes params optional when nothing in them is required, and takes none without input", () => {
132
+ const { members } = sdkMethods([LIST_NUMBERS, HEALTH]);
133
+ expect(members).toContain(
134
+ "list: (params?: ListNumbersParams, opts?: RequestOptions): Promise<Page<NumberDto>> =>",
135
+ );
136
+ expect(members).toContain("health(opts?: RequestOptions): Promise<HealthDto> {");
137
+ expect(members).toContain(`this.request({ method: "GET", path: "/health" }, undefined, opts);`);
138
+ });
139
+
140
+ it("leaves out the fields the route fills in itself", () => {
141
+ const op: SdkOperation = {
142
+ ...SEND,
143
+ input: z.object({ to: z.string(), type: z.literal("text") }),
144
+ fixed: ["type"],
145
+ };
146
+ expect(sdkMethods([op]).params).not.toContain("type");
147
+ });
148
+
149
+ it("keys an operation whose headers name an Idempotency-Key, in any case, and writes repeatable", () => {
150
+ const keyed = { ...SEND, headers: [{ name: "idempotency-key" }] };
151
+ expect(sdkMethods([keyed]).members).toContain("keyed: true");
152
+ expect(sdkMethods([{ ...SEND, headers: [] }]).members).not.toContain("keyed");
153
+ const search = { ...SEND, sdk: { method: "search", returns: "MessageDto", repeatable: true } };
154
+ expect(sdkMethods([search]).members).toContain(
155
+ `{ method: "POST", path: "/messages", keyed: true, repeatable: true }`,
156
+ );
157
+ });
158
+
159
+ it("puts namespaces in the order given, and otherwise in the order they appear", () => {
160
+ const jobs = { ...HEALTH, name: "get_job", sdk: { method: "jobs.get", returns: "HealthDto" } };
161
+ const byAppearance = sdkMethods([jobs, GET_NUMBER]).members;
162
+ expect(byAppearance.indexOf("readonly jobs")).toBeLessThan(
163
+ byAppearance.indexOf("readonly numbers"),
164
+ );
165
+ const ordered = sdkMethods([jobs, GET_NUMBER], { namespaces: ["numbers", "jobs", "unused"] });
166
+ expect(ordered.members.indexOf("readonly numbers")).toBeLessThan(
167
+ ordered.members.indexOf("readonly jobs"),
168
+ );
169
+ expect(ordered.members).not.toContain("unused");
170
+ });
171
+
172
+ it("writes the doc comment the hook gives, skipping undefined paragraphs, and none for none", () => {
173
+ const doc: SdkMethodsOptions["doc"] = (op) => [
174
+ op.summary,
175
+ op.description,
176
+ `\`${op.method.toUpperCase()} /v1${op.path}\``,
177
+ ];
178
+ expect(sdkMethods([HEALTH], { doc }).members).toContain(
179
+ " /**\n * Check the API is up.\n *\n * `GET /v1/health`\n */\n",
180
+ );
181
+ expect(sdkMethods([HEALTH], { doc: () => [] }).members).toBe(`
182
+ health(opts?: RequestOptions): Promise<HealthDto> {
183
+ return this.request({ method: "GET", path: "/health" }, undefined, opts);
184
+ }
185
+ `);
186
+ });
187
+
188
+ it("adds the spec fields the hook gives, quoting a key that needs it", () => {
189
+ const specExtra = () => ({ proxy: ["country", "city"], "x-cost": 2, skipped: undefined });
190
+ expect(sdkMethods([SEND], { specExtra }).members).toContain(
191
+ `{ method: "POST", path: "/messages", keyed: true, proxy: ["country","city"], "x-cost": 2 }`,
192
+ );
193
+ });
194
+
195
+ it("places hand-written members after the generated ones, in a namespace of their own too", () => {
196
+ const { members } = sdkMethods([GET_NUMBER], {
197
+ inject: [
198
+ {
199
+ method: "numbers.watch",
200
+ signature: "(numberId: string): AsyncIterable<NumberDto>",
201
+ call: "this.watchNumber(numberId)",
202
+ doc: ["Follow a number live."],
203
+ },
204
+ {
205
+ method: "jobs.wait",
206
+ signature: "(id: string): Promise<JobDto>",
207
+ call: "this.waitForJob(id)",
208
+ },
209
+ { method: "ping", signature: "(): Promise<void>", call: "this.ping()" },
210
+ ],
211
+ });
212
+ expect(members).toContain(`
213
+ ping(): Promise<void> {
214
+ return this.ping();
215
+ }
216
+ `);
217
+ expect(members)
218
+ .toContain(` this.request({ method: "GET", path: "/numbers/:numberId" }, params, opts),
219
+ /**
220
+ * Follow a number live.
221
+ */
222
+ watch: (numberId: string): AsyncIterable<NumberDto> =>
223
+ this.watchNumber(numberId),
224
+ };`);
225
+ expect(members).toContain(` readonly jobs = {
226
+ wait: (id: string): Promise<JobDto> =>
227
+ this.waitForJob(id),
228
+ };`);
229
+ });
230
+
231
+ it.each<[string, SdkOperation[], SdkMethodsOptions, RegExp]>([
232
+ [
233
+ "a name with two dots",
234
+ [{ ...HEALTH, sdk: { method: "a.b.c", returns: "HealthDto" } }],
235
+ {},
236
+ /`a\.b\.c` is not a method name/,
237
+ ],
238
+ [
239
+ "a name that is not an identifier",
240
+ [{ ...HEALTH, sdk: { method: "numbers.check-in", returns: "HealthDto" } }],
241
+ {},
242
+ /is not a method name/,
243
+ ],
244
+ [
245
+ "two operations in one place",
246
+ [GET_NUMBER, { ...LIST_NUMBERS, sdk: { method: "numbers.get", returns: "NumberDto" } }],
247
+ {},
248
+ /want to be `numbers\.get`: GET \S+ \(get_number\), and GET \/numbers \(list_numbers\)/,
249
+ ],
250
+ [
251
+ "a hand-written member in a generated one's place",
252
+ [GET_NUMBER],
253
+ { inject: [{ method: "numbers.get", signature: "()", call: "x" }] },
254
+ /\(get_number\), and The hand-written numbers\.get\. Rename one/,
255
+ ],
256
+ [
257
+ "a 204 that returns something",
258
+ [{ ...DELETE_NUMBER, sdk: { method: "numbers.delete", returns: "NumberDto" } }],
259
+ {},
260
+ /answers 204, which has no body, so `sdk\.returns` must be "void"/,
261
+ ],
262
+ [
263
+ "a 205 that returns something, as the reference refuses it",
264
+ [{ ...DELETE_NUMBER, status: 205, sdk: { method: "numbers.delete", returns: "NumberDto" } }],
265
+ {},
266
+ /answers 205, which has no body, so `sdk\.returns` must be "void"/,
267
+ ],
268
+ [
269
+ "a namespace the list does not have",
270
+ [GET_NUMBER],
271
+ { namespaces: ["jobs"] },
272
+ /The namespace `numbers` is not in `namespaces`/,
273
+ ],
274
+ [
275
+ "a name that is both a method and a namespace",
276
+ [GET_NUMBER, { ...HEALTH, sdk: { method: "numbers", returns: "HealthDto" } }],
277
+ {},
278
+ /`numbers` is both a method and a namespace/,
279
+ ],
280
+ [
281
+ "the name of the seam every method calls",
282
+ [{ ...HEALTH, sdk: { method: "request", returns: "HealthDto" } }],
283
+ {},
284
+ /`request` is a class member every SDK already has/,
285
+ ],
286
+ [
287
+ "a path slot filled by an optional field",
288
+ [{ ...GET_NUMBER, input: z.object({ numberId: z.string().optional() }) }],
289
+ {},
290
+ /":id" is filled from `numberId`, which the input does not require/,
291
+ ],
292
+ [
293
+ "a path slot with no input at all",
294
+ [{ ...HEALTH, path: "/health/:region" }],
295
+ {},
296
+ /":region" is filled from `region`, which the input does not require/,
297
+ ],
298
+ [
299
+ "a path slot filled by a field the route fixes",
300
+ [{ ...DELETE_NUMBER, fixed: ["id"] }],
301
+ {},
302
+ /":id" is filled from `id`, which the input does not require/,
303
+ ],
304
+ [
305
+ "params naming a slot the path lacks",
306
+ [{ ...GET_NUMBER, params: { numberId: "number" } }],
307
+ {},
308
+ /`params` sends numberId in ":number", which the path does not have/,
309
+ ],
310
+ [
311
+ "an optional slot",
312
+ [{ ...DELETE_NUMBER, path: "/numbers/:id?" }],
313
+ {},
314
+ /has an optional slot ":id\?"/,
315
+ ],
316
+ [
317
+ "a spec field the generator writes",
318
+ [SEND],
319
+ { specExtra: () => ({ path: "/elsewhere" }) },
320
+ /`specExtra` sets "path", which the generator writes itself/,
321
+ ],
322
+ [
323
+ "an input with a type JSON cannot carry",
324
+ [{ ...SEND, input: z.object({ at: z.date() }) }],
325
+ {},
326
+ /^POST \/messages \(send_message\): /,
327
+ ],
328
+ ])("refuses %s", (_label, operations, options, message) => {
329
+ expect(() => sdkMethods(operations, options)).toThrow(message);
330
+ });
331
+ });
332
+
333
+ describe("retrySource and transportSource", () => {
334
+ it("copies @gusnips/http's retry rule whole, with nothing to import", () => {
335
+ const source = retrySource();
336
+ for (const name of ["shouldRetry", "retryDelayMs", "retryAfterSecs", "parseRetryAfter"]) {
337
+ expect(source).toContain(`export function ${name}(`);
338
+ }
339
+ expect(source).not.toMatch(/^import /m);
340
+ });
341
+
342
+ it("copies the transport importing the rule from beside it, and nothing else", () => {
343
+ const specifiers = [...transportSource().matchAll(/^import [^;]* from "([^"]+)";$/gm)];
344
+ expect(specifiers.map((match) => match[1])).toEqual(["./retry.ts"]);
345
+ });
346
+ });
347
+
348
+ /** The type errors in `file` and what it imports, under strict settings plus `options`. */
349
+ function typeErrors(file: string, options: ts.CompilerOptions): string[] {
350
+ const program = ts.createProgram([file], {
351
+ target: ts.ScriptTarget.ESNext,
352
+ module: ts.ModuleKind.Preserve,
353
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
354
+ strict: true,
355
+ noUncheckedIndexedAccess: true,
356
+ verbatimModuleSyntax: true,
357
+ allowImportingTsExtensions: true,
358
+ noEmit: true,
359
+ skipLibCheck: true,
360
+ ...options,
361
+ });
362
+ return ts
363
+ .getPreEmitDiagnostics(program)
364
+ .map(
365
+ (d) => `${d.file?.fileName ?? ""}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`,
366
+ );
367
+ }
368
+
369
+ describe("SdkOperation", () => {
370
+ it("takes the operation list an API already hands buildOpenApi, unchanged", () => {
371
+ const here = fileURLToPath(new URL(".", import.meta.url));
372
+ const file = join(mkdtempSync(join(tmpdir(), "sdkgen-fits-")), "fits.ts");
373
+ writeFileSync(
374
+ file,
375
+ `import type { OpenApiOperation } from ${JSON.stringify(join(here, "../../server/src/openapi/index.ts"))};
376
+ import type { SdkOperation } from ${JSON.stringify(join(here, "methods.ts"))};
377
+ export const fits = (operations: readonly OpenApiOperation[]): readonly SdkOperation[] => operations;
378
+ `,
379
+ );
380
+ const errors = typeErrors(file, {
381
+ lib: ["lib.es2022.d.ts"],
382
+ types: ["node"],
383
+ typeRoots: [join(here, "../node_modules/@types")],
384
+ });
385
+ expect(errors).toEqual([]);
386
+ }, 60_000);
387
+ });
388
+
389
+ describe("a generated SDK", () => {
390
+ /** An SDK the way an adopter's script writes one, from these operations. */
391
+ async function generate(): Promise<string> {
392
+ const dir = mkdtempSync(join(tmpdir(), "sdkgen-sdk-"));
393
+ const { params, members, paramTypes, returnTypes } = sdkMethods(ALL, {
394
+ namespaces: ["numbers"],
395
+ });
396
+ await writeGenerated(
397
+ {
398
+ "contract.ts": `export interface MessageDto { id: string }
399
+ export interface NumberDto { id: string; status: "on" | "off" }
400
+ export interface Page<T> { items: T[] }
401
+ export interface HealthDto { ok: boolean }
402
+ `,
403
+ "params.ts": params,
404
+ "operations.ts": `import type { ${returnTypes.join(", ")} } from "./contract.ts";
405
+ import type { ${paramTypes.join(", ")} } from "./params.ts";
406
+ import type { RequestOptions, RequestSpec } from "./transport.ts";
407
+
408
+ export abstract class GeneratedOperations {
409
+ protected abstract request<T>(spec: RequestSpec, params?: object, opts?: RequestOptions): Promise<T>;
410
+ ${members}}
411
+ `,
412
+ "retry.ts": retrySource(),
413
+ "transport.ts": transportSource(),
414
+ "client.ts": `import { GeneratedOperations } from "./operations.ts";
415
+ import { send, type Failure, type RequestOptions, type RequestSpec, type Transport } from "./transport.ts";
416
+
417
+ export class ExampleError extends Error {
418
+ readonly status: number;
419
+ readonly code: string | undefined;
420
+ constructor(failure: Failure) {
421
+ super(failure.error?.message ?? \`\${failure.method} \${failure.path} failed with \${failure.status}.\`);
422
+ this.status = failure.status;
423
+ this.code = failure.error?.code;
424
+ }
425
+ }
426
+
427
+ export class Example extends GeneratedOperations {
428
+ private readonly transport: Transport;
429
+ constructor(options: { apiKey: string; fetch: typeof fetch }) {
430
+ super();
431
+ this.transport = {
432
+ baseUrl: "https://api.example.test/v1",
433
+ headers: { authorization: \`Bearer \${options.apiKey}\` },
434
+ fetch: options.fetch,
435
+ mintKeys: true,
436
+ error: (failure) => new ExampleError(failure),
437
+ };
438
+ }
439
+ protected async request<T>(spec: RequestSpec, params?: object, opts?: RequestOptions): Promise<T> {
440
+ return (await send<T>(this.transport, spec, params, opts)).data;
441
+ }
442
+ }
443
+ `,
444
+ },
445
+ { root: dir },
446
+ );
447
+ return dir;
448
+ }
449
+
450
+ // Written once for both tests: formatting six files is most of the time either one takes.
451
+ let written: Promise<string> | undefined;
452
+ const sdk = () => (written ??= generate());
453
+
454
+ it("typechecks under the settings SDKs publish with, with no Node types", async () => {
455
+ const dir = await sdk();
456
+ // The flags the SDKs on this stack publish with. DOM and no Node types: an SDK runs wherever
457
+ // fetch does, so the transport may lean on nothing only Node has.
458
+ const errors = typeErrors(join(dir, "client.ts"), {
459
+ lib: ["lib.esnext.d.ts", "lib.dom.d.ts"],
460
+ types: [],
461
+ noImplicitOverride: true,
462
+ noFallthroughCasesInSwitch: true,
463
+ });
464
+ expect(errors).toEqual([]);
465
+ }, 60_000);
466
+
467
+ it("calls the API through the generated methods", async () => {
468
+ const dir = await sdk();
469
+ const sent: { url: string; init: RequestInit }[] = [];
470
+ const replies = [
471
+ Response.json({ data: { id: "n 1", status: "on" } }),
472
+ Response.json({ data: { id: "m1" } }),
473
+ new Response(null, { status: 204 }),
474
+ Response.json({ error: { code: "NOT_FOUND", message: "No such number." } }, { status: 404 }),
475
+ ];
476
+ const fetch = async (input: string | URL | Request, init: RequestInit = {}) => {
477
+ sent.push({ url: String(input), init });
478
+ const reply = replies.shift();
479
+ if (reply === undefined) throw new Error("no reply left");
480
+ return reply;
481
+ };
482
+ const { Example } = await import(pathToFileURL(join(dir, "client.ts")).href);
483
+ const client = new Example({ apiKey: "k", fetch });
484
+
485
+ expect(await client.numbers.get({ numberId: "n 1" })).toEqual({ id: "n 1", status: "on" });
486
+ expect(sent[0]!.url).toBe("https://api.example.test/v1/numbers/n%201");
487
+
488
+ expect(await client.sendMessage({ to: "a", text: "hi" })).toEqual({ id: "m1" });
489
+ const headers = new Headers(sent[1]!.init.headers);
490
+ expect(headers.get("authorization")).toBe("Bearer k");
491
+ expect(headers.get("idempotency-key")).toMatch(/^[0-9a-f-]{36}$/);
492
+ expect(sent[1]!.init.body).toBe(`{"to":"a","text":"hi"}`);
493
+
494
+ expect(await client.numbers.delete({ id: "n1" })).toBeUndefined();
495
+ expect(sent[2]!.init.method).toBe("DELETE");
496
+
497
+ await expect(client.numbers.get({ numberId: "gone" })).rejects.toMatchObject({
498
+ status: 404,
499
+ code: "NOT_FOUND",
500
+ message: "No such number.",
501
+ });
502
+ }, 60_000);
503
+ });