@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.
- package/LICENSE +201 -0
- package/README.md +25 -2
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +61 -0
- package/dist/cli.js.map +1 -0
- package/dist/generate.d.ts +112 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +345 -0
- package/dist/generate.js.map +1 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +121 -0
- package/dist/index.js.map +1 -0
- package/dist/port.d.ts +50 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +81 -0
- package/dist/port.js.map +1 -0
- package/package.json +59 -5
- package/src/cli.ts +85 -0
- package/src/generate.ts +572 -0
- package/src/index.ts +228 -0
- package/src/port.ts +140 -0
package/src/generate.ts
ADDED
|
@@ -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
|
+
}
|