@zerolith/client 0.0.0-stage → 0.1.0-alpha.2

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,572 @@
1
+ /**
2
+ * Generates the contract module from an OpenAPI 3.x document: one type per
3
+ * component schema, an `Api` interface with one entry per operation, and
4
+ * the `routes` table the client runs on. Pure string work, so it runs
5
+ * anywhere; the `zerolith-client` command wraps it for the shell.
6
+ */
7
+
8
+ /** A JSON Schema object as OpenAPI 3.1 and later carry it (draft 2020-12 plus the OAS vocabulary). */
9
+ export interface SchemaObject
10
+ {
11
+ $ref?: string;
12
+ type?: string | string[];
13
+ nullable?: boolean;
14
+ enum?: unknown[];
15
+ const?: unknown;
16
+ properties?: Record<string, SchemaObject>;
17
+ required?: string[];
18
+ additionalProperties?: boolean | SchemaObject;
19
+ items?: SchemaObject;
20
+ prefixItems?: SchemaObject[];
21
+ oneOf?: SchemaObject[];
22
+ anyOf?: SchemaObject[];
23
+ allOf?: SchemaObject[];
24
+ description?: string;
25
+ title?: string;
26
+ }
27
+
28
+ /** A Parameter Object or a reference to one. */
29
+ export interface ParameterObject
30
+ {
31
+ $ref?: string;
32
+ name?: string;
33
+ in?: "query" | "querystring" | "header" | "path" | "cookie";
34
+ required?: boolean;
35
+ description?: string;
36
+ schema?: SchemaObject;
37
+ content?: Record<string, MediaTypeObject>;
38
+ }
39
+
40
+ /** A Media Type Object. */
41
+ export interface MediaTypeObject
42
+ {
43
+ schema?: SchemaObject;
44
+ }
45
+
46
+ /** A Request Body Object or a reference to one. */
47
+ export interface RequestBodyObject
48
+ {
49
+ $ref?: string;
50
+ description?: string;
51
+ required?: boolean;
52
+ content?: Record<string, MediaTypeObject>;
53
+ }
54
+
55
+ /** A Response Object or a reference to one. */
56
+ export interface ResponseObject
57
+ {
58
+ $ref?: string;
59
+ description?: string;
60
+ content?: Record<string, MediaTypeObject>;
61
+ }
62
+
63
+ /** An Operation Object. */
64
+ export interface OperationObject
65
+ {
66
+ operationId?: string;
67
+ summary?: string;
68
+ description?: string;
69
+ deprecated?: boolean;
70
+ parameters?: ParameterObject[];
71
+ requestBody?: RequestBodyObject;
72
+ responses?: Record<string, ResponseObject>;
73
+ }
74
+
75
+ /** A Path Item Object. */
76
+ export interface PathItemObject
77
+ {
78
+ $ref?: string;
79
+ summary?: string;
80
+ description?: string;
81
+ parameters?: ParameterObject[];
82
+ get?: OperationObject;
83
+ put?: OperationObject;
84
+ post?: OperationObject;
85
+ delete?: OperationObject;
86
+ options?: OperationObject;
87
+ head?: OperationObject;
88
+ patch?: OperationObject;
89
+ trace?: OperationObject;
90
+ query?: OperationObject;
91
+ additionalOperations?: Record<string, OperationObject>;
92
+ }
93
+
94
+ /** The parts of an OpenAPI document the generator reads. */
95
+ export interface OpenApiDocument
96
+ {
97
+ openapi: string;
98
+ info?: { title?: string; version?: string };
99
+ paths?: Record<string, PathItemObject>;
100
+ components?: {
101
+ schemas?: Record<string, SchemaObject>;
102
+ parameters?: Record<string, ParameterObject>;
103
+ requestBodies?: Record<string, RequestBodyObject>;
104
+ responses?: Record<string, ResponseObject>;
105
+ pathItems?: Record<string, PathItemObject>;
106
+ };
107
+ }
108
+
109
+ /** Options for `generateClient`. */
110
+ export interface GenerateOptions
111
+ {
112
+ /** The module the generated file imports `RouteTable` from. Defaults to `@zerolith/client`. */
113
+ clientSpecifier?: string;
114
+ /** The name of the generated contract interface. Defaults to `Api`. */
115
+ apiName?: string;
116
+ }
117
+
118
+ const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace", "query"] as const;
119
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
120
+
121
+ interface Context
122
+ {
123
+ document: OpenApiDocument;
124
+ apiName: string;
125
+ }
126
+
127
+ function fail(message: string): never
128
+ {
129
+ throw new Error(`zerolith/client: ${message}`);
130
+ }
131
+
132
+ function words(text: string): string[]
133
+ {
134
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter((word) => word !== "");
135
+ }
136
+
137
+ function pascal(text: string): string
138
+ {
139
+ return words(text).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
140
+ }
141
+
142
+ function camel(text: string): string
143
+ {
144
+ const name = pascal(text);
145
+ return name.charAt(0).toLowerCase() + name.slice(1);
146
+ }
147
+
148
+ function typeName(text: string): string
149
+ {
150
+ const name = pascal(text);
151
+ return /^[0-9]/.test(name) ? `_${name}` : name === "" ? "_" : name;
152
+ }
153
+
154
+ function key(name: string): string
155
+ {
156
+ return IDENTIFIER.test(name) ? name : JSON.stringify(name);
157
+ }
158
+
159
+ function comment(text: string | undefined, indent: string): string
160
+ {
161
+ if (text === undefined || text.trim() === "")
162
+ {
163
+ return "";
164
+ }
165
+ const lines = text.trim().replace(/\*\//g, "* /").split(/\r?\n/);
166
+ return lines.length === 1 ? `${indent}/** ${lines[0]} */\n` : `${indent}/**\n${lines.map((line) => `${indent} * ${line}`.trimEnd()).join("\n")}\n${indent} */\n`;
167
+ }
168
+
169
+ function indentLines(text: string, indent: string): string
170
+ {
171
+ return text.split("\n").map((line, index) => (index === 0 ? line : `${indent}${line}`)).join("\n");
172
+ }
173
+
174
+ function resolveRef<T>(context: Context, ref: string, section: string): T
175
+ {
176
+ const prefix = `#/components/${section}/`;
177
+ if (!ref.startsWith(prefix))
178
+ {
179
+ fail(`only local references into components are supported, found ${ref}`);
180
+ }
181
+ const name = decodeURIComponent(ref.slice(prefix.length).replace(/~1/g, "/").replace(/~0/g, "~"));
182
+ const table = (context.document.components as Record<string, Record<string, T> | undefined> | undefined)?.[section];
183
+ const found = table?.[name];
184
+ if (found === undefined)
185
+ {
186
+ fail(`${ref} does not exist`);
187
+ }
188
+ return found;
189
+ }
190
+
191
+ function deref<T extends { $ref?: string }>(context: Context, value: T, section: string): T
192
+ {
193
+ let current = value;
194
+ for (let depth = 0; current.$ref !== undefined; depth++)
195
+ {
196
+ if (depth > 16)
197
+ {
198
+ fail(`reference loop at ${current.$ref}`);
199
+ }
200
+ current = resolveRef<T>(context, current.$ref, section);
201
+ }
202
+ return current;
203
+ }
204
+
205
+ function schemaRefName(ref: string): string
206
+ {
207
+ const prefix = "#/components/schemas/";
208
+ if (!ref.startsWith(prefix))
209
+ {
210
+ fail(`only local schema references are supported, found ${ref}`);
211
+ }
212
+ return typeName(decodeURIComponent(ref.slice(prefix.length)));
213
+ }
214
+
215
+ function literal(value: unknown): string
216
+ {
217
+ return value === undefined ? "undefined" : JSON.stringify(value);
218
+ }
219
+
220
+ /** Renders a schema as a TypeScript type. `indent` is the current indentation for nested object types. */
221
+ function typeOf(schema: SchemaObject | undefined, indent: string): string
222
+ {
223
+ if (schema === undefined)
224
+ {
225
+ return "unknown";
226
+ }
227
+ if (schema.$ref !== undefined)
228
+ {
229
+ return schemaRefName(schema.$ref);
230
+ }
231
+ const parts: string[] = [];
232
+ if (schema.const !== undefined)
233
+ {
234
+ parts.push(literal(schema.const));
235
+ }
236
+ else if (schema.enum !== undefined)
237
+ {
238
+ parts.push(schema.enum.map(literal).join(" | "));
239
+ }
240
+ else if (schema.oneOf !== undefined || schema.anyOf !== undefined)
241
+ {
242
+ parts.push((schema.oneOf ?? schema.anyOf ?? []).map((option) => typeOf(option, indent)).join(" | "));
243
+ }
244
+ else if (schema.allOf !== undefined)
245
+ {
246
+ parts.push(schema.allOf.map((option) => wrap(typeOf(option, indent))).join(" & "));
247
+ }
248
+ else
249
+ {
250
+ const types = Array.isArray(schema.type) ? schema.type : schema.type === undefined ? [] : [schema.type];
251
+ if (types.length === 0 && (schema.properties !== undefined || schema.additionalProperties !== undefined))
252
+ {
253
+ types.push("object");
254
+ }
255
+ if (types.length === 0 && (schema.items !== undefined || schema.prefixItems !== undefined))
256
+ {
257
+ types.push("array");
258
+ }
259
+ if (types.length === 0)
260
+ {
261
+ parts.push("unknown");
262
+ }
263
+ for (const type of types)
264
+ {
265
+ parts.push(primitive(type, schema, indent));
266
+ }
267
+ }
268
+ if (schema.nullable === true && !parts.includes("null"))
269
+ {
270
+ parts.push("null");
271
+ }
272
+ return [...new Set(parts)].join(" | ");
273
+ }
274
+
275
+ function wrap(type: string): string
276
+ {
277
+ return type.includes(" | ") ? `(${type})` : type;
278
+ }
279
+
280
+ function primitive(type: string, schema: SchemaObject, indent: string): string
281
+ {
282
+ switch (type)
283
+ {
284
+ case "string":
285
+ return "string";
286
+ case "number":
287
+ case "integer":
288
+ return "number";
289
+ case "boolean":
290
+ return "boolean";
291
+ case "null":
292
+ return "null";
293
+ case "array":
294
+ if (schema.prefixItems !== undefined)
295
+ {
296
+ const rest = schema.items === undefined ? "" : `, ...${wrap(typeOf(schema.items, indent))}[]`;
297
+ return `[${schema.prefixItems.map((item) => typeOf(item, indent)).join(", ")}${rest}]`;
298
+ }
299
+ return `${wrap(typeOf(schema.items, indent))}[]`;
300
+ case "object":
301
+ return objectType(schema, indent);
302
+ default:
303
+ return "unknown";
304
+ }
305
+ }
306
+
307
+ function objectType(schema: SchemaObject, indent: string): string
308
+ {
309
+ const properties = Object.entries(schema.properties ?? {});
310
+ const required = new Set(schema.required ?? []);
311
+ const extra = schema.additionalProperties;
312
+ const record = extra === undefined || extra === false ? undefined : `Record<string, ${extra === true ? "unknown" : typeOf(extra, indent)}>`;
313
+ if (properties.length === 0)
314
+ {
315
+ return record ?? (extra === false ? "Record<string, never>" : "Record<string, unknown>");
316
+ }
317
+ const inner = `${indent} `;
318
+ const lines = properties.map(([name, property]) =>
319
+ {
320
+ const optional = required.has(name) ? "" : "?";
321
+ return `${comment(property.description, inner)}${inner}${key(name)}${optional}: ${indentLines(typeOf(property, inner), "")};`;
322
+ });
323
+ const body = `{\n${lines.join("\n")}\n${indent}}`;
324
+ return record === undefined ? body : `${body} & ${record}`;
325
+ }
326
+
327
+ function jsonSchemaOf(content: Record<string, MediaTypeObject> | undefined): { kind: "json"; schema: SchemaObject | undefined } | { kind: "form" } | { kind: "urlencoded" } | { kind: "text" } | { kind: "none" } | { kind: "other" }
328
+ {
329
+ if (content === undefined || Object.keys(content).length === 0)
330
+ {
331
+ return { kind: "none" };
332
+ }
333
+ for (const [type, media] of Object.entries(content))
334
+ {
335
+ const bare = type.split(";")[0]?.trim().toLowerCase() ?? "";
336
+ if (bare === "application/json" || bare.endsWith("+json"))
337
+ {
338
+ return { kind: "json", schema: media.schema };
339
+ }
340
+ }
341
+ const types = Object.keys(content).map((type) => type.split(";")[0]?.trim().toLowerCase() ?? "");
342
+ if (types.includes("multipart/form-data"))
343
+ {
344
+ return { kind: "form" };
345
+ }
346
+ if (types.includes("application/x-www-form-urlencoded"))
347
+ {
348
+ return { kind: "urlencoded" };
349
+ }
350
+ if (types.some((type) => type.startsWith("text/")))
351
+ {
352
+ return { kind: "text" };
353
+ }
354
+ return { kind: "other" };
355
+ }
356
+
357
+ interface Entry
358
+ {
359
+ name: string;
360
+ method: string;
361
+ path: string;
362
+ lines: string[];
363
+ doc: string | undefined;
364
+ }
365
+
366
+ function operationName(operation: OperationObject, method: string, path: string): string
367
+ {
368
+ if (operation.operationId !== undefined && operation.operationId.trim() !== "")
369
+ {
370
+ return camel(operation.operationId);
371
+ }
372
+ const segments = path.split("/").filter((segment) => segment !== "").map((segment) =>
373
+ {
374
+ const match = /^\{(.+)\}$/.exec(segment);
375
+ return match === null ? pascal(segment) : `By${pascal(match[1] ?? "")}`;
376
+ });
377
+ return camel(`${method} ${segments.join(" ")}`);
378
+ }
379
+
380
+ function parameterGroups(context: Context, item: PathItemObject, operation: OperationObject): { params: string[]; query: string[]; queryRequired: boolean }
381
+ {
382
+ const merged = new Map<string, ParameterObject>();
383
+ for (const list of [item.parameters ?? [], operation.parameters ?? []])
384
+ {
385
+ for (const raw of list)
386
+ {
387
+ const parameter = deref(context, raw, "parameters");
388
+ if (parameter.name === undefined || parameter.in === undefined)
389
+ {
390
+ fail(`a parameter needs a name and a location`);
391
+ }
392
+ merged.set(`${parameter.in}:${parameter.name}`, parameter);
393
+ }
394
+ }
395
+ const params: string[] = [];
396
+ const query: string[] = [];
397
+ let queryRequired = false;
398
+ for (const parameter of merged.values())
399
+ {
400
+ const name = parameter.name ?? "";
401
+ const schema = parameter.schema ?? (parameter.content === undefined ? undefined : Object.values(parameter.content)[0]?.schema);
402
+ const type = typeOf(schema, " ");
403
+ const doc = comment(parameter.description, " ");
404
+ if (parameter.in === "path")
405
+ {
406
+ params.push(`${doc} ${key(name)}: ${type};`);
407
+ }
408
+ else if (parameter.in === "query")
409
+ {
410
+ const required = parameter.required === true;
411
+ queryRequired ||= required;
412
+ query.push(`${doc} ${key(name)}${required ? "" : "?"}: ${type}${required ? "" : " | undefined"};`);
413
+ }
414
+ else if (parameter.in === "querystring")
415
+ {
416
+ queryRequired ||= parameter.required === true;
417
+ query.push(`${doc} [name: string]: unknown;`);
418
+ }
419
+ }
420
+ return { params, query, queryRequired };
421
+ }
422
+
423
+ function bodyType(context: Context, operation: OperationObject): { type: string; required: boolean } | undefined
424
+ {
425
+ if (operation.requestBody === undefined)
426
+ {
427
+ return undefined;
428
+ }
429
+ const body = deref(context, operation.requestBody, "requestBodies");
430
+ const content = jsonSchemaOf(body.content);
431
+ const required = body.required === true;
432
+ switch (content.kind)
433
+ {
434
+ case "json":
435
+ return { type: typeOf(content.schema, " "), required };
436
+ case "form":
437
+ return { type: "FormData", required };
438
+ case "urlencoded":
439
+ return { type: "URLSearchParams", required };
440
+ case "text":
441
+ return { type: "string", required };
442
+ case "none":
443
+ return undefined;
444
+ default:
445
+ return { type: "BodyInit", required };
446
+ }
447
+ }
448
+
449
+ function outputType(context: Context, operation: OperationObject): string
450
+ {
451
+ const responses = Object.entries(operation.responses ?? {});
452
+ const successful = responses.filter(([status]) => /^2(\d\d|XX)$/.test(status)).toSorted(([a], [b]) => a.localeCompare(b));
453
+ const chosen = successful.length > 0 ? successful : responses.filter(([status]) => status === "default");
454
+ if (chosen.length === 0)
455
+ {
456
+ return "unknown";
457
+ }
458
+ const types = new Set<string>();
459
+ for (const [, raw] of chosen)
460
+ {
461
+ const response = deref(context, raw, "responses");
462
+ const content = jsonSchemaOf(response.content);
463
+ switch (content.kind)
464
+ {
465
+ case "json":
466
+ types.add(typeOf(content.schema, " "));
467
+ break;
468
+ case "text":
469
+ types.add("string");
470
+ break;
471
+ case "none":
472
+ types.add("undefined");
473
+ break;
474
+ default:
475
+ types.add("unknown");
476
+ }
477
+ }
478
+ return [...types].join(" | ");
479
+ }
480
+
481
+ function entryOf(context: Context, path: string, item: PathItemObject, method: string, operation: OperationObject): Entry
482
+ {
483
+ const { params, query, queryRequired } = parameterGroups(context, item, operation);
484
+ const lines = [` method: "${method}";`, ` path: ${JSON.stringify(path)};`];
485
+ if (params.length > 0)
486
+ {
487
+ lines.push(` params: {\n${params.join("\n")}\n };`);
488
+ }
489
+ if (query.length > 0)
490
+ {
491
+ lines.push(` query${queryRequired ? "" : "?"}: {\n${query.join("\n")}\n };`);
492
+ }
493
+ const body = bodyType(context, operation);
494
+ if (body !== undefined)
495
+ {
496
+ lines.push(` body${body.required ? "" : "?"}: ${body.type}${body.required ? "" : " | undefined"};`);
497
+ }
498
+ lines.push(` output: ${outputType(context, operation)};`);
499
+ const doc = [operation.summary, operation.description, operation.deprecated === true ? "@deprecated" : undefined].filter((part) => part !== undefined && part !== "").join("\n\n");
500
+ return { name: operationName(operation, method, path), method, path, lines, doc: doc === "" ? undefined : doc };
501
+ }
502
+
503
+ /**
504
+ * Generates the contract module for an OpenAPI 3.x document given as a
505
+ * parsed object (JSON; convert YAML first). Schemas under
506
+ * `components.schemas` become exported types, every operation becomes an
507
+ * entry of the `Api` interface named by its `operationId` (or by its
508
+ * method and path), and `routes` carries the method and path of each.
509
+ * Header and cookie parameters, security, and non-local references are
510
+ * left out; the client sends headers through its options.
511
+ */
512
+ export function generateClient(document: OpenApiDocument, options: GenerateOptions = {}): string
513
+ {
514
+ if (typeof document.openapi !== "string" || !document.openapi.startsWith("3."))
515
+ {
516
+ fail(`expected an OpenAPI 3.x document, found openapi: ${JSON.stringify(document.openapi)}`);
517
+ }
518
+ const context: Context = { document, apiName: options.apiName ?? "Api" };
519
+ const specifier = options.clientSpecifier ?? "@zerolith/client";
520
+ const out: string[] = [];
521
+ const title = document.info?.title === undefined ? "an OpenAPI document" : `${document.info.title}${document.info.version === undefined ? "" : ` ${document.info.version}`}`;
522
+ out.push(`// Generated by @zerolith/client from ${title}. Do not edit; regenerate instead.`);
523
+ out.push(`import type { RouteTable } from ${JSON.stringify(specifier)};`);
524
+ out.push("");
525
+
526
+ const schemas = Object.entries(document.components?.schemas ?? {}).toSorted(([a], [b]) => a.localeCompare(b));
527
+ for (const [name, schema] of schemas)
528
+ {
529
+ out.push(`${comment(schema.description ?? schema.title, "")}export type ${typeName(name)} = ${typeOf(schema, "")};`);
530
+ out.push("");
531
+ }
532
+
533
+ const entries: Entry[] = [];
534
+ const seen = new Set<string>();
535
+ for (const [path, rawItem] of Object.entries(document.paths ?? {}).toSorted(([a], [b]) => a.localeCompare(b)))
536
+ {
537
+ const item = deref(context, rawItem, "pathItems");
538
+ const operations: [string, OperationObject][] = [];
539
+ for (const method of METHODS)
540
+ {
541
+ const operation = item[method];
542
+ if (operation !== undefined)
543
+ {
544
+ operations.push([method, operation]);
545
+ }
546
+ }
547
+ for (const [method, operation] of Object.entries(item.additionalOperations ?? {}))
548
+ {
549
+ operations.push([method.toLowerCase(), operation]);
550
+ }
551
+ for (const [method, operation] of operations)
552
+ {
553
+ const entry = entryOf(context, path, item, method, operation);
554
+ if (seen.has(entry.name))
555
+ {
556
+ fail(`two operations resolve to the name "${entry.name}"; give them distinct operationId values`);
557
+ }
558
+ seen.add(entry.name);
559
+ entries.push(entry);
560
+ }
561
+ }
562
+
563
+ out.push(`export interface ${context.apiName}\n{`);
564
+ out.push(entries.map((entry) => `${comment(entry.doc, " ")} ${key(entry.name)}: {\n${entry.lines.join("\n")}\n };`).join("\n"));
565
+ out.push("}");
566
+ out.push("");
567
+ out.push("export const routes = {");
568
+ out.push(entries.map((entry) => ` ${key(entry.name)}: { method: "${entry.method}", path: ${JSON.stringify(entry.path)} }`).join(",\n"));
569
+ out.push(`} as const satisfies RouteTable<${context.apiName}>;`);
570
+ out.push("");
571
+ return out.join("\n");
572
+ }