@zerolith/client 0.0.0-canary.20261102.g8292c77
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 +32 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +79 -0
- package/dist/cli.js.map +1 -0
- package/dist/generate.d.ts +175 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +455 -0
- package/dist/generate.js.map +1 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +155 -0
- package/dist/index.js.map +1 -0
- package/dist/port.d.ts +66 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +86 -0
- package/dist/port.js.map +1 -0
- package/package.json +60 -0
- package/src/cli.ts +109 -0
- package/src/generate.ts +765 -0
- package/src/index.ts +280 -0
- package/src/port.ts +163 -0
package/src/generate.ts
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
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
|
+
/** A reference to `#/components/schemas/<name>`, rendered as that component's generated type, intersected with the keywords beside it that shape a type, such as `properties`, as OpenAPI 3.1 allows; any other target fails generation. */
|
|
12
|
+
$ref?: string;
|
|
13
|
+
/** The JSON type, or since OpenAPI 3.1 an array of types rendered as a union; `integer` renders as `number`, and when it is absent the object or array keywords imply one, else the type is `unknown`. */
|
|
14
|
+
type?: string | string[];
|
|
15
|
+
/** The OpenAPI 3.0 flag that also allows `null`; when `true`, `null` joins the type. */
|
|
16
|
+
nullable?: boolean;
|
|
17
|
+
/** The allowed values, rendered as a union of their literal types; it takes precedence over `type`, and intersects with a composition keyword or `$ref` beside it. */
|
|
18
|
+
enum?: unknown[];
|
|
19
|
+
/** The single allowed value (OpenAPI 3.1 and later), rendered as its literal type; it takes precedence over `enum` and `type`, and intersects with a composition keyword or `$ref` beside it. */
|
|
20
|
+
const?: unknown;
|
|
21
|
+
/** The object's properties, each generated as a member that is optional unless `required` lists it, with the property's `description` as its doc comment. */
|
|
22
|
+
properties?: Record<string, SchemaObject>;
|
|
23
|
+
/** The names in `properties` that must be present; every other property is generated as optional. */
|
|
24
|
+
required?: string[];
|
|
25
|
+
/** The schema of keys beyond `properties`, rendered as a `Record<string, ...>` joined to them with `&`; `true` allows `unknown` values, and `false` makes an object without properties `Record<string, never>`. */
|
|
26
|
+
additionalProperties?: boolean | SchemaObject;
|
|
27
|
+
/** The schema of each array element, rendered as `T[]`, or `unknown[]` without it; beside `prefixItems` it types the elements after the tuple. */
|
|
28
|
+
items?: SchemaObject;
|
|
29
|
+
/** The schemas of the leading array elements by position (OpenAPI 3.1 and later), rendered as a tuple; without `items` the tuple has no rest element. */
|
|
30
|
+
prefixItems?: SchemaObject[];
|
|
31
|
+
/** Alternatives of which exactly one matches, rendered as a union; it takes precedence over `anyOf` and `allOf`, and the keywords beside it that shape a type, such as `properties`, intersect with it. */
|
|
32
|
+
oneOf?: SchemaObject[];
|
|
33
|
+
/** Alternatives of which at least one matches, rendered as a union unless `oneOf` is present; the keywords beside it that shape a type, such as `properties`, intersect with it. */
|
|
34
|
+
anyOf?: SchemaObject[];
|
|
35
|
+
/** Schemas that all match, rendered as an intersection unless `oneOf` or `anyOf` is present; the keywords beside it that shape a type, such as `properties`, join the intersection. */
|
|
36
|
+
allOf?: SchemaObject[];
|
|
37
|
+
/** Becomes the doc comment on a component schema's generated type, ahead of `title`, and on an object property's member. */
|
|
38
|
+
description?: string;
|
|
39
|
+
/** The doc comment on a component schema's generated type when `description` is absent; the type's name comes from the component's key, not from `title`. */
|
|
40
|
+
title?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A Parameter Object or a reference to one. */
|
|
44
|
+
export interface ParameterObject
|
|
45
|
+
{
|
|
46
|
+
/** A reference to `#/components/parameters/<name>`, which replaces this object; any other target, or a missing one, fails generation. */
|
|
47
|
+
$ref?: string;
|
|
48
|
+
/** The member's key in the generated `params` or `query`, matching the `{name}` placeholder for a path parameter; a parameter without `name` or `in` fails generation. */
|
|
49
|
+
name?: string;
|
|
50
|
+
/** Where the parameter travels: `path` fills `params`, `query` adds a member to `query`, `querystring` (OpenAPI 3.2) lets `query` take any key, and `header` and `cookie` are left out. */
|
|
51
|
+
in?: "query" | "querystring" | "header" | "path" | "cookie";
|
|
52
|
+
/** Makes a query parameter's member, and the whole `query` input, required; path parameters are generated as required either way, since OpenAPI demands `true` for them. */
|
|
53
|
+
required?: boolean;
|
|
54
|
+
/** The doc comment on the parameter's generated member. */
|
|
55
|
+
description?: string;
|
|
56
|
+
/** The schema of the value, rendered as the member's type; without it or `content`, the type is `unknown`. */
|
|
57
|
+
schema?: SchemaObject;
|
|
58
|
+
/** The single media type entry OpenAPI allows in place of `schema`; when `schema` is absent, the entry's `schema` gives the member's type. */
|
|
59
|
+
content?: Record<string, MediaTypeObject>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A Media Type Object. */
|
|
63
|
+
export interface MediaTypeObject
|
|
64
|
+
{
|
|
65
|
+
/** The content's schema, read for a JSON media type in a body and for any media type in a parameter's `content`; when absent, the type is `unknown`. */
|
|
66
|
+
schema?: SchemaObject;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A Request Body Object or a reference to one. */
|
|
70
|
+
export interface RequestBodyObject
|
|
71
|
+
{
|
|
72
|
+
/** A reference to `#/components/requestBodies/<name>`, which replaces this object; any other target, or a missing one, fails generation. */
|
|
73
|
+
$ref?: string;
|
|
74
|
+
/** A brief description of the body; the generator does not carry it into the output. */
|
|
75
|
+
description?: string;
|
|
76
|
+
/** Makes the generated `body` input required; OpenAPI's default is `false`, which generates it as optional. */
|
|
77
|
+
required?: boolean;
|
|
78
|
+
/** The body by media type, where a JSON type takes precedence and gives its schema's type; otherwise `multipart/form-data` gives `FormData`, `application/x-www-form-urlencoded` gives `URLSearchParams`, `text/*` gives `string`, and any other type gives `BodyInit`. */
|
|
79
|
+
content?: Record<string, MediaTypeObject>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A Response Object or a reference to one. */
|
|
83
|
+
export interface ResponseObject
|
|
84
|
+
{
|
|
85
|
+
/** A reference to `#/components/responses/<name>`, which replaces this object; any other target, or a missing one, fails generation. */
|
|
86
|
+
$ref?: string;
|
|
87
|
+
/** A description of the response; the generator does not carry it into the output. */
|
|
88
|
+
description?: string;
|
|
89
|
+
/** The response body by media type: a JSON type, when present, gives its schema's type, `text/*` gives `string`, any other type gives `Blob`, as the client reads it, and no content gives `undefined`. */
|
|
90
|
+
content?: Record<string, MediaTypeObject>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** An Operation Object. */
|
|
94
|
+
export interface OperationObject
|
|
95
|
+
{
|
|
96
|
+
/** Names the operation's entry in `Api` and `routes`, in camelCase; without it the name comes from the method and path, such as `putNotesById` for `PUT /notes/{id}`, and two entries with one name fail generation. */
|
|
97
|
+
operationId?: string;
|
|
98
|
+
/** The first paragraph of the doc comment on the operation's entry in `Api`. */
|
|
99
|
+
summary?: string;
|
|
100
|
+
/** Follows `summary` in the doc comment on the operation's entry in `Api`. */
|
|
101
|
+
description?: string;
|
|
102
|
+
/** When `true`, the doc comment on the operation's entry ends with `@deprecated`. */
|
|
103
|
+
deprecated?: boolean;
|
|
104
|
+
/** The operation's parameters, merged with the path item's; one with the same `name` and `in` as a path item parameter replaces it. */
|
|
105
|
+
parameters?: ParameterObject[];
|
|
106
|
+
/** The request body, generated as the entry's `body` input; without it the entry has no `body`. */
|
|
107
|
+
requestBody?: RequestBodyObject;
|
|
108
|
+
/** The responses by status code; `output` is the union of every 2xx response, including `2XX`, or of `default` when there is none, and `unknown` without either. */
|
|
109
|
+
responses?: Record<string, ResponseObject>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A Path Item Object. */
|
|
113
|
+
export interface PathItemObject
|
|
114
|
+
{
|
|
115
|
+
/** A reference to `#/components/pathItems/<name>`, which replaces this item; any other target, or a missing one, fails generation. */
|
|
116
|
+
$ref?: string;
|
|
117
|
+
/** A summary meant for every operation on the path; the generator does not carry it into the output. */
|
|
118
|
+
summary?: string;
|
|
119
|
+
/** A description meant for every operation on the path; the generator does not carry it into the output. */
|
|
120
|
+
description?: string;
|
|
121
|
+
/** Parameters shared by every operation on the path; an operation's own parameter with the same `name` and `in` replaces one. */
|
|
122
|
+
parameters?: ParameterObject[];
|
|
123
|
+
/** The `GET` operation on this path. */
|
|
124
|
+
get?: OperationObject;
|
|
125
|
+
/** The `PUT` operation on this path. */
|
|
126
|
+
put?: OperationObject;
|
|
127
|
+
/** The `POST` operation on this path. */
|
|
128
|
+
post?: OperationObject;
|
|
129
|
+
/** The `DELETE` operation on this path. */
|
|
130
|
+
delete?: OperationObject;
|
|
131
|
+
/** The `OPTIONS` operation on this path. */
|
|
132
|
+
options?: OperationObject;
|
|
133
|
+
/** The `HEAD` operation on this path. */
|
|
134
|
+
head?: OperationObject;
|
|
135
|
+
/** The `PATCH` operation on this path. */
|
|
136
|
+
patch?: OperationObject;
|
|
137
|
+
/** The `TRACE` operation on this path. */
|
|
138
|
+
trace?: OperationObject;
|
|
139
|
+
/** The `QUERY` operation on this path (OpenAPI 3.2): a safe, idempotent request that carries a body. */
|
|
140
|
+
query?: OperationObject;
|
|
141
|
+
/** Operations for other HTTP methods (OpenAPI 3.2), keyed by method name such as `COPY`; each key is lowercased, and one that is not a valid method token fails generation. */
|
|
142
|
+
additionalOperations?: Record<string, OperationObject>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The parts of an OpenAPI document the generator reads. */
|
|
146
|
+
export interface OpenApiDocument
|
|
147
|
+
{
|
|
148
|
+
/** The OpenAPI version, such as `3.1.0`; a value that does not start with `3.` fails generation. */
|
|
149
|
+
openapi: string;
|
|
150
|
+
/** The API's title and version, which the generated file's header comment names as its source. */
|
|
151
|
+
info?: { title?: string; version?: string };
|
|
152
|
+
/** Path templates mapped to their items; every operation under them becomes an entry of `Api` and `routes`, sorted by path. */
|
|
153
|
+
paths?: Record<string, PathItemObject>;
|
|
154
|
+
/** Reusable objects: each entry of `schemas` becomes an exported type named after its key, and `$ref` values resolve into the other sections. */
|
|
155
|
+
components?: {
|
|
156
|
+
schemas?: Record<string, SchemaObject>;
|
|
157
|
+
parameters?: Record<string, ParameterObject>;
|
|
158
|
+
requestBodies?: Record<string, RequestBodyObject>;
|
|
159
|
+
responses?: Record<string, ResponseObject>;
|
|
160
|
+
pathItems?: Record<string, PathItemObject>;
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Options for `generateClient`. */
|
|
165
|
+
export interface GenerateOptions
|
|
166
|
+
{
|
|
167
|
+
/** The module the generated file imports `RouteTable` from. Defaults to `@zerolith/client`. */
|
|
168
|
+
clientSpecifier?: string;
|
|
169
|
+
/** The name of the generated contract interface. Defaults to `Api`. */
|
|
170
|
+
apiName?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The methods a Path Item names that the client can send; `trace` is left out, since `fetch` refuses TRACE. */
|
|
174
|
+
const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "query"] as const;
|
|
175
|
+
|
|
176
|
+
/** Methods `fetch` refuses to send, left out of `additionalOperations` too. */
|
|
177
|
+
const FORBIDDEN_METHODS: ReadonlySet<string> = new Set(["connect", "trace", "track"]);
|
|
178
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
179
|
+
|
|
180
|
+
interface Context
|
|
181
|
+
{
|
|
182
|
+
document: OpenApiDocument;
|
|
183
|
+
apiName: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function fail(message: string): never
|
|
187
|
+
{
|
|
188
|
+
throw new Error(`zerolith/client: ${message}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function words(text: string): string[]
|
|
192
|
+
{
|
|
193
|
+
return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter((word) => word !== "");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function pascal(text: string): string
|
|
197
|
+
{
|
|
198
|
+
return words(text).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function camel(text: string): string
|
|
202
|
+
{
|
|
203
|
+
const name = pascal(text);
|
|
204
|
+
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function typeName(text: string): string
|
|
208
|
+
{
|
|
209
|
+
const name = pascal(text);
|
|
210
|
+
return /^[0-9]/.test(name) ? `_${name}` : name === "" ? "_" : name;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function key(name: string): string
|
|
214
|
+
{
|
|
215
|
+
return IDENTIFIER.test(name) ? name : JSON.stringify(name);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Line breaks, CR LF counted once, which would end a `//` comment and turn the rest of its line into code. */
|
|
219
|
+
const LINE_BREAKS = new RegExp(`${String.fromCodePoint(13, 10)}|[${String.fromCodePoint(10, 13, 0x2028, 0x2029)}]`, "g");
|
|
220
|
+
|
|
221
|
+
/** Text for a `//` comment, kept on one line, so it cannot end the comment. */
|
|
222
|
+
function commentText(text: string): string
|
|
223
|
+
{
|
|
224
|
+
return text.replace(LINE_BREAKS, " ");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function comment(text: string | undefined, indent: string): string
|
|
228
|
+
{
|
|
229
|
+
if (text === undefined || text.trim() === "")
|
|
230
|
+
{
|
|
231
|
+
return "";
|
|
232
|
+
}
|
|
233
|
+
const lines = text.trim().replace(/\*\//g, "* /").split(/\r?\n/);
|
|
234
|
+
return lines.length === 1 ? `${indent}/** ${lines[0]} */\n` : `${indent}/**\n${lines.map((line) => `${indent} * ${line}`.trimEnd()).join("\n")}\n${indent} */\n`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function indentLines(text: string, indent: string): string
|
|
238
|
+
{
|
|
239
|
+
return text.split("\n").map((line, index) => (index === 0 ? line : `${indent}${line}`)).join("\n");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function resolveRef<T>(context: Context, ref: string, section: string): T
|
|
243
|
+
{
|
|
244
|
+
const prefix = `#/components/${section}/`;
|
|
245
|
+
if (!ref.startsWith(prefix))
|
|
246
|
+
{
|
|
247
|
+
fail(`only local references into components are supported, found ${ref}`);
|
|
248
|
+
}
|
|
249
|
+
const name = decodeURIComponent(ref.slice(prefix.length).replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
250
|
+
const table = (context.document.components as Record<string, Record<string, T> | undefined> | undefined)?.[section];
|
|
251
|
+
const found = table?.[name];
|
|
252
|
+
if (found === undefined)
|
|
253
|
+
{
|
|
254
|
+
fail(`${ref} does not exist`);
|
|
255
|
+
}
|
|
256
|
+
return found;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function deref<T extends { $ref?: string }>(context: Context, value: T, section: string): T
|
|
260
|
+
{
|
|
261
|
+
let current = value;
|
|
262
|
+
for (let depth = 0; current.$ref !== undefined; depth++)
|
|
263
|
+
{
|
|
264
|
+
if (depth > 16)
|
|
265
|
+
{
|
|
266
|
+
fail(`reference loop at ${current.$ref}`);
|
|
267
|
+
}
|
|
268
|
+
current = resolveRef<T>(context, current.$ref, section);
|
|
269
|
+
}
|
|
270
|
+
return current;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The type name each schema of the document being generated is written under, by schema name, which a `$ref` to the schema uses; `writeModule` sets it before it writes a type. */
|
|
274
|
+
let schemaTypes: ReadonlyMap<string, string> = new Map();
|
|
275
|
+
|
|
276
|
+
/** The built-in type names the module being generated writes, as `Record` for an object of free keys or `Blob` for a binary response; `writeModule` empties it before it writes. */
|
|
277
|
+
let builtInsUsed = new Set<string>();
|
|
278
|
+
|
|
279
|
+
/** A built-in type name, recorded as written by the module being generated. */
|
|
280
|
+
function builtIn(name: string): string
|
|
281
|
+
{
|
|
282
|
+
builtInsUsed.add(name);
|
|
283
|
+
return name;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function schemaRefName(ref: string): string
|
|
287
|
+
{
|
|
288
|
+
const prefix = "#/components/schemas/";
|
|
289
|
+
if (!ref.startsWith(prefix))
|
|
290
|
+
{
|
|
291
|
+
fail(`only local schema references are supported, found ${ref}`);
|
|
292
|
+
}
|
|
293
|
+
const name = decodeURIComponent(ref.slice(prefix.length));
|
|
294
|
+
return schemaTypes.get(name) ?? typeName(name);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function literal(value: unknown): string
|
|
298
|
+
{
|
|
299
|
+
return value === undefined ? "undefined" : JSON.stringify(value);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Renders a schema as a TypeScript type. `indent` is the current indentation for nested object types. */
|
|
303
|
+
/** The keywords that combine other schemas; the type they give is intersected with the schema's own keywords. */
|
|
304
|
+
const COMPOSITION_KEYWORDS: ReadonlySet<string> = new Set(["$ref", "allOf", "oneOf", "anyOf"]);
|
|
305
|
+
|
|
306
|
+
/** The keywords that shape a type on their own, beside a composition keyword. */
|
|
307
|
+
const OWN_KEYWORDS: ReadonlySet<string> = new Set(["const", "enum", "type", "properties", "additionalProperties", "items", "prefixItems"]);
|
|
308
|
+
|
|
309
|
+
function typeOf(schema: SchemaObject | undefined, indent: string): string
|
|
310
|
+
{
|
|
311
|
+
if (schema === undefined)
|
|
312
|
+
{
|
|
313
|
+
return "unknown";
|
|
314
|
+
}
|
|
315
|
+
const keywords = Object.keys(schema);
|
|
316
|
+
if (keywords.some((keyword) => COMPOSITION_KEYWORDS.has(keyword)) && keywords.some((keyword) => OWN_KEYWORDS.has(keyword)))
|
|
317
|
+
{
|
|
318
|
+
const part = (keep: (keyword: string) => boolean): SchemaObject => Object.fromEntries(Object.entries(schema).filter(([keyword]) => keep(keyword))) as SchemaObject;
|
|
319
|
+
return `${wrap(typeOf(part((keyword) => !OWN_KEYWORDS.has(keyword)), indent))} & ${wrap(typeOf(part((keyword) => !COMPOSITION_KEYWORDS.has(keyword)), indent))}`;
|
|
320
|
+
}
|
|
321
|
+
if (schema.$ref !== undefined)
|
|
322
|
+
{
|
|
323
|
+
return schemaRefName(schema.$ref);
|
|
324
|
+
}
|
|
325
|
+
const parts: string[] = [];
|
|
326
|
+
if (schema.const !== undefined)
|
|
327
|
+
{
|
|
328
|
+
parts.push(literal(schema.const));
|
|
329
|
+
}
|
|
330
|
+
else if (schema.enum !== undefined)
|
|
331
|
+
{
|
|
332
|
+
parts.push(schema.enum.map(literal).join(" | "));
|
|
333
|
+
}
|
|
334
|
+
else if (schema.oneOf !== undefined || schema.anyOf !== undefined)
|
|
335
|
+
{
|
|
336
|
+
parts.push((schema.oneOf ?? schema.anyOf ?? []).map((option) => typeOf(option, indent)).join(" | "));
|
|
337
|
+
}
|
|
338
|
+
else if (schema.allOf !== undefined)
|
|
339
|
+
{
|
|
340
|
+
parts.push(schema.allOf.map((option) => wrap(typeOf(option, indent))).join(" & "));
|
|
341
|
+
}
|
|
342
|
+
else
|
|
343
|
+
{
|
|
344
|
+
const types = Array.isArray(schema.type) ? schema.type : schema.type === undefined ? [] : [schema.type];
|
|
345
|
+
if (types.length === 0 && (schema.properties !== undefined || schema.additionalProperties !== undefined))
|
|
346
|
+
{
|
|
347
|
+
types.push("object");
|
|
348
|
+
}
|
|
349
|
+
if (types.length === 0 && (schema.items !== undefined || schema.prefixItems !== undefined))
|
|
350
|
+
{
|
|
351
|
+
types.push("array");
|
|
352
|
+
}
|
|
353
|
+
if (types.length === 0)
|
|
354
|
+
{
|
|
355
|
+
parts.push("unknown");
|
|
356
|
+
}
|
|
357
|
+
for (const type of types)
|
|
358
|
+
{
|
|
359
|
+
parts.push(primitive(type, schema, indent));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (schema.nullable === true && !parts.includes("null"))
|
|
363
|
+
{
|
|
364
|
+
parts.push("null");
|
|
365
|
+
}
|
|
366
|
+
return [...new Set(parts)].join(" | ");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function wrap(type: string): string
|
|
370
|
+
{
|
|
371
|
+
return type.includes(" | ") ? `(${type})` : type;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function primitive(type: string, schema: SchemaObject, indent: string): string
|
|
375
|
+
{
|
|
376
|
+
switch (type)
|
|
377
|
+
{
|
|
378
|
+
case "string":
|
|
379
|
+
return "string";
|
|
380
|
+
case "number":
|
|
381
|
+
case "integer":
|
|
382
|
+
return "number";
|
|
383
|
+
case "boolean":
|
|
384
|
+
return "boolean";
|
|
385
|
+
case "null":
|
|
386
|
+
return "null";
|
|
387
|
+
case "array":
|
|
388
|
+
if (schema.prefixItems !== undefined)
|
|
389
|
+
{
|
|
390
|
+
const rest = schema.items === undefined ? "" : `, ...${wrap(typeOf(schema.items, indent))}[]`;
|
|
391
|
+
return `[${schema.prefixItems.map((item) => typeOf(item, indent)).join(", ")}${rest}]`;
|
|
392
|
+
}
|
|
393
|
+
return `${wrap(typeOf(schema.items, indent))}[]`;
|
|
394
|
+
case "object":
|
|
395
|
+
return objectType(schema, indent);
|
|
396
|
+
default:
|
|
397
|
+
return "unknown";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function objectType(schema: SchemaObject, indent: string): string
|
|
402
|
+
{
|
|
403
|
+
const properties = Object.entries(schema.properties ?? {});
|
|
404
|
+
const required = new Set(schema.required ?? []);
|
|
405
|
+
const extra = schema.additionalProperties;
|
|
406
|
+
const record = extra === undefined || extra === false ? undefined : `${builtIn("Record")}<string, ${extra === true ? "unknown" : typeOf(extra, indent)}>`;
|
|
407
|
+
if (properties.length === 0)
|
|
408
|
+
{
|
|
409
|
+
return record ?? `${builtIn("Record")}<string, ${extra === false ? "never" : "unknown"}>`;
|
|
410
|
+
}
|
|
411
|
+
const inner = `${indent} `;
|
|
412
|
+
const lines = properties.map(([name, property]) =>
|
|
413
|
+
{
|
|
414
|
+
const optional = required.has(name) ? "" : "?";
|
|
415
|
+
return `${comment(property.description, inner)}${inner}${key(name)}${optional}: ${indentLines(typeOf(property, inner), "")};`;
|
|
416
|
+
});
|
|
417
|
+
const body = `{\n${lines.join("\n")}\n${indent}}`;
|
|
418
|
+
return record === undefined ? body : `${body} & ${record}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function jsonSchemaOf(content: Record<string, MediaTypeObject> | undefined): { kind: "json"; schema: SchemaObject | undefined } | { kind: "form" } | { kind: "urlencoded" } | { kind: "text" } | { kind: "none" } | { kind: "other" }
|
|
422
|
+
{
|
|
423
|
+
if (content === undefined || Object.keys(content).length === 0)
|
|
424
|
+
{
|
|
425
|
+
return { kind: "none" };
|
|
426
|
+
}
|
|
427
|
+
for (const [type, media] of Object.entries(content))
|
|
428
|
+
{
|
|
429
|
+
const bare = type.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
430
|
+
if (bare === "application/json" || bare.endsWith("+json"))
|
|
431
|
+
{
|
|
432
|
+
return { kind: "json", schema: media.schema };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const types = Object.keys(content).map((type) => type.split(";")[0]?.trim().toLowerCase() ?? "");
|
|
436
|
+
if (types.includes("multipart/form-data"))
|
|
437
|
+
{
|
|
438
|
+
return { kind: "form" };
|
|
439
|
+
}
|
|
440
|
+
if (types.includes("application/x-www-form-urlencoded"))
|
|
441
|
+
{
|
|
442
|
+
return { kind: "urlencoded" };
|
|
443
|
+
}
|
|
444
|
+
if (types.some((type) => type.startsWith("text/")))
|
|
445
|
+
{
|
|
446
|
+
return { kind: "text" };
|
|
447
|
+
}
|
|
448
|
+
return { kind: "other" };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
interface Entry
|
|
452
|
+
{
|
|
453
|
+
name: string;
|
|
454
|
+
method: string;
|
|
455
|
+
path: string;
|
|
456
|
+
lines: string[];
|
|
457
|
+
doc: string | undefined;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function operationName(operation: OperationObject, method: string, path: string): string
|
|
461
|
+
{
|
|
462
|
+
if (operation.operationId !== undefined && operation.operationId.trim() !== "")
|
|
463
|
+
{
|
|
464
|
+
return camel(operation.operationId);
|
|
465
|
+
}
|
|
466
|
+
const segments = path.split("/").filter((segment) => segment !== "").map((segment) =>
|
|
467
|
+
{
|
|
468
|
+
const match = /^\{(.+)\}$/.exec(segment);
|
|
469
|
+
return match === null ? pascal(segment) : `By${pascal(match[1] ?? "")}`;
|
|
470
|
+
});
|
|
471
|
+
return camel(`${method} ${segments.join(" ")}`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function parameterGroups(context: Context, item: PathItemObject, operation: OperationObject): { params: string[]; query: string[]; queryRequired: boolean }
|
|
475
|
+
{
|
|
476
|
+
const merged = new Map<string, ParameterObject>();
|
|
477
|
+
for (const list of [item.parameters ?? [], operation.parameters ?? []])
|
|
478
|
+
{
|
|
479
|
+
for (const raw of list)
|
|
480
|
+
{
|
|
481
|
+
const parameter = deref(context, raw, "parameters");
|
|
482
|
+
if (parameter.name === undefined || parameter.in === undefined)
|
|
483
|
+
{
|
|
484
|
+
fail(`a parameter needs a name and a location`);
|
|
485
|
+
}
|
|
486
|
+
merged.set(`${parameter.in}:${parameter.name}`, parameter);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const params: string[] = [];
|
|
490
|
+
const query: string[] = [];
|
|
491
|
+
let queryRequired = false;
|
|
492
|
+
for (const parameter of merged.values())
|
|
493
|
+
{
|
|
494
|
+
const name = parameter.name ?? "";
|
|
495
|
+
const schema = parameter.schema ?? (parameter.content === undefined ? undefined : Object.values(parameter.content)[0]?.schema);
|
|
496
|
+
const type = typeOf(schema, " ");
|
|
497
|
+
const doc = comment(parameter.description, " ");
|
|
498
|
+
if (parameter.in === "path")
|
|
499
|
+
{
|
|
500
|
+
params.push(`${doc} ${key(name)}: ${type};`);
|
|
501
|
+
}
|
|
502
|
+
else if (parameter.in === "query")
|
|
503
|
+
{
|
|
504
|
+
const required = parameter.required === true;
|
|
505
|
+
queryRequired ||= required;
|
|
506
|
+
query.push(`${doc} ${key(name)}${required ? "" : "?"}: ${type}${required ? "" : " | undefined"};`);
|
|
507
|
+
}
|
|
508
|
+
else if (parameter.in === "querystring")
|
|
509
|
+
{
|
|
510
|
+
queryRequired ||= parameter.required === true;
|
|
511
|
+
query.push(`${doc} [name: string]: unknown;`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return { params, query, queryRequired };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function bodyType(context: Context, operation: OperationObject): { type: string; required: boolean } | undefined
|
|
518
|
+
{
|
|
519
|
+
if (operation.requestBody === undefined)
|
|
520
|
+
{
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
const body = deref(context, operation.requestBody, "requestBodies");
|
|
524
|
+
const content = jsonSchemaOf(body.content);
|
|
525
|
+
const required = body.required === true;
|
|
526
|
+
switch (content.kind)
|
|
527
|
+
{
|
|
528
|
+
case "json":
|
|
529
|
+
return { type: typeOf(content.schema, " "), required };
|
|
530
|
+
case "form":
|
|
531
|
+
return { type: builtIn("FormData"), required };
|
|
532
|
+
case "urlencoded":
|
|
533
|
+
return { type: builtIn("URLSearchParams"), required };
|
|
534
|
+
case "text":
|
|
535
|
+
return { type: "string", required };
|
|
536
|
+
case "none":
|
|
537
|
+
return undefined;
|
|
538
|
+
default:
|
|
539
|
+
return { type: builtIn("BodyInit"), required };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function outputType(context: Context, operation: OperationObject): string
|
|
544
|
+
{
|
|
545
|
+
const responses = Object.entries(operation.responses ?? {});
|
|
546
|
+
const successful = responses.filter(([status]) => /^2(\d\d|XX)$/.test(status)).toSorted(([a], [b]) => a.localeCompare(b));
|
|
547
|
+
const chosen = successful.length > 0 ? successful : responses.filter(([status]) => status === "default");
|
|
548
|
+
if (chosen.length === 0)
|
|
549
|
+
{
|
|
550
|
+
return "unknown";
|
|
551
|
+
}
|
|
552
|
+
const types = new Set<string>();
|
|
553
|
+
for (const [, raw] of chosen)
|
|
554
|
+
{
|
|
555
|
+
const response = deref(context, raw, "responses");
|
|
556
|
+
const content = jsonSchemaOf(response.content);
|
|
557
|
+
switch (content.kind)
|
|
558
|
+
{
|
|
559
|
+
case "json":
|
|
560
|
+
types.add(typeOf(content.schema, " "));
|
|
561
|
+
break;
|
|
562
|
+
case "text":
|
|
563
|
+
types.add("string");
|
|
564
|
+
break;
|
|
565
|
+
case "none":
|
|
566
|
+
types.add("undefined");
|
|
567
|
+
break;
|
|
568
|
+
default:
|
|
569
|
+
types.add(builtIn("Blob"));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return [...types].join(" | ");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function entryOf(context: Context, path: string, item: PathItemObject, method: string, operation: OperationObject): Entry
|
|
576
|
+
{
|
|
577
|
+
const { params, query, queryRequired } = parameterGroups(context, item, operation);
|
|
578
|
+
const lines = [` method: ${JSON.stringify(method)};`, ` path: ${JSON.stringify(path)};`];
|
|
579
|
+
if (params.length > 0)
|
|
580
|
+
{
|
|
581
|
+
lines.push(` params: {\n${params.join("\n")}\n };`);
|
|
582
|
+
}
|
|
583
|
+
if (query.length > 0)
|
|
584
|
+
{
|
|
585
|
+
lines.push(` query${queryRequired ? "" : "?"}: {\n${query.join("\n")}\n };`);
|
|
586
|
+
}
|
|
587
|
+
const body = bodyType(context, operation);
|
|
588
|
+
if (body !== undefined)
|
|
589
|
+
{
|
|
590
|
+
lines.push(` body${body.required ? "" : "?"}: ${body.type}${body.required ? "" : " | undefined"};`);
|
|
591
|
+
}
|
|
592
|
+
lines.push(` output: ${outputType(context, operation)};`);
|
|
593
|
+
const doc = [operation.summary, operation.description, operation.deprecated === true ? "@deprecated" : undefined].filter((part) => part !== undefined && part !== "").join("\n\n");
|
|
594
|
+
return { name: operationName(operation, method, path), method, path, lines, doc: doc === "" ? undefined : doc };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** The built-in types a generated module may write, with what each is. */
|
|
598
|
+
const BUILT_INS: ReadonlyMap<string, string> = new Map([
|
|
599
|
+
["Record", "TypeScript's Record type"],
|
|
600
|
+
["Blob", "the global Blob type"],
|
|
601
|
+
["FormData", "the global FormData type"],
|
|
602
|
+
["URLSearchParams", "the global URLSearchParams type"],
|
|
603
|
+
["BodyInit", "the global BodyInit type"]
|
|
604
|
+
]);
|
|
605
|
+
|
|
606
|
+
/** The type names no schema can take in a module, with what each is: the interface of the operations, the `RouteTable` the module imports, and the built-in types in `used`, which the module writes. A built-in the module does not write is left to a schema of that name. */
|
|
607
|
+
function reservedTypeNames(apiName: string, used: ReadonlySet<string>): Map<string, string>
|
|
608
|
+
{
|
|
609
|
+
const reserved = new Map([
|
|
610
|
+
["RouteTable", "the RouteTable type the module imports"],
|
|
611
|
+
[apiName, "the interface of the operations, which apiName renames"]
|
|
612
|
+
]);
|
|
613
|
+
for (const [name, what] of BUILT_INS)
|
|
614
|
+
{
|
|
615
|
+
if (used.has(name))
|
|
616
|
+
{
|
|
617
|
+
reserved.set(name, `${what}, which this module uses`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return reserved;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The type name every schema is written under, by schema name, with the
|
|
625
|
+
* reason for a name that is not the schema's own. A schema keeps its own
|
|
626
|
+
* name, as `note-item` gives `NoteItem`, unless `reserved` holds it or a
|
|
627
|
+
* schema earlier in sorted order takes it; then it gets the first free
|
|
628
|
+
* name with a number added, as `Record2`.
|
|
629
|
+
*/
|
|
630
|
+
function schemaTypeNames(names: readonly string[], reserved: ReadonlyMap<string, string>): Map<string, { type: string; reason?: string }>
|
|
631
|
+
{
|
|
632
|
+
const holders = new Map(reserved);
|
|
633
|
+
const assigned = new Map<string, { type: string; reason?: string }>();
|
|
634
|
+
for (const name of names)
|
|
635
|
+
{
|
|
636
|
+
const type = typeName(name);
|
|
637
|
+
if (!holders.has(type))
|
|
638
|
+
{
|
|
639
|
+
holders.set(type, `the type of the schema ${JSON.stringify(name)}`);
|
|
640
|
+
assigned.set(name, { type });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
for (const name of names)
|
|
644
|
+
{
|
|
645
|
+
if (assigned.has(name))
|
|
646
|
+
{
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const base = typeName(name);
|
|
650
|
+
let suffix = 2;
|
|
651
|
+
while (holders.has(`${base}${suffix}`))
|
|
652
|
+
{
|
|
653
|
+
suffix++;
|
|
654
|
+
}
|
|
655
|
+
const type = `${base}${suffix}`;
|
|
656
|
+
assigned.set(name, { type, reason: `named ${type} because ${base} is already ${holders.get(base) ?? "taken"}` });
|
|
657
|
+
holders.set(type, `the type of the schema ${JSON.stringify(name)}`);
|
|
658
|
+
}
|
|
659
|
+
return assigned;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Generates the contract module for an OpenAPI 3.x document given as a
|
|
664
|
+
* parsed object (JSON; convert YAML first). Schemas under
|
|
665
|
+
* `components.schemas` become exported types, every operation becomes an
|
|
666
|
+
* entry of the `Api` interface named by its `operationId` (or by its
|
|
667
|
+
* method and path), and `routes` carries the method and path of each.
|
|
668
|
+
* Header and cookie parameters, security, and non-local references are
|
|
669
|
+
* left out; the client sends headers through its options. A schema whose
|
|
670
|
+
* type name would clash is written under the first free name with a
|
|
671
|
+
* number added, as `Record2`, with a comment that says why, and every
|
|
672
|
+
* `$ref` to it uses that name: one that another schema takes first, as
|
|
673
|
+
* `note-item` and `note_item` both give `NoteItem`, one named like the
|
|
674
|
+
* interface of the operations or the `RouteTable` the module imports, and
|
|
675
|
+
* one named like a built-in type the module writes, such as `Record` for
|
|
676
|
+
* an object of free keys or `Blob` for a binary response. A schema named
|
|
677
|
+
* like a built-in the module does not write keeps its name.
|
|
678
|
+
*/
|
|
679
|
+
export function generateClient(document: OpenApiDocument, options: GenerateOptions = {}): string
|
|
680
|
+
{
|
|
681
|
+
if (typeof document.openapi !== "string" || !document.openapi.startsWith("3."))
|
|
682
|
+
{
|
|
683
|
+
fail(`expected an OpenAPI 3.x document, found openapi: ${JSON.stringify(document.openapi)}`);
|
|
684
|
+
}
|
|
685
|
+
const context: Context = { document, apiName: options.apiName ?? "Api" };
|
|
686
|
+
const specifier = options.clientSpecifier ?? "@zerolith/client";
|
|
687
|
+
const names = Object.keys(document.components?.schemas ?? {}).toSorted((a, b) => a.localeCompare(b));
|
|
688
|
+
const first = writeModule(context, specifier, schemaTypeNames(names, reservedTypeNames(context.apiName, new Set())));
|
|
689
|
+
if (![...first.types.values()].some(({ type }) => first.used.has(type)))
|
|
690
|
+
{
|
|
691
|
+
return first.code;
|
|
692
|
+
}
|
|
693
|
+
return writeModule(context, specifier, schemaTypeNames(names, reservedTypeNames(context.apiName, first.used))).code;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** The module text for the given schema type names, with the built-in types it writes, which a second pass reserves when a schema took one of their names. */
|
|
697
|
+
function writeModule(context: Context, specifier: string, types: Map<string, { type: string; reason?: string }>): { code: string; used: ReadonlySet<string>; types: Map<string, { type: string; reason?: string }> }
|
|
698
|
+
{
|
|
699
|
+
const { document } = context;
|
|
700
|
+
builtInsUsed = new Set();
|
|
701
|
+
const out: string[] = [];
|
|
702
|
+
const title = document.info?.title === undefined ? "an OpenAPI document" : `${document.info.title}${document.info.version === undefined ? "" : ` ${document.info.version}`}`;
|
|
703
|
+
out.push(`// Generated by @zerolith/client from ${commentText(title)}. Do not edit; regenerate instead.`);
|
|
704
|
+
out.push(`import type { RouteTable } from ${JSON.stringify(specifier)};`);
|
|
705
|
+
out.push("");
|
|
706
|
+
|
|
707
|
+
const schemas = Object.entries(document.components?.schemas ?? {}).toSorted(([a], [b]) => a.localeCompare(b));
|
|
708
|
+
schemaTypes = new Map(Array.from(types, ([name, { type }]) => [name, type]));
|
|
709
|
+
for (const [name, schema] of schemas)
|
|
710
|
+
{
|
|
711
|
+
const { type, reason } = types.get(name) ?? { type: typeName(name) };
|
|
712
|
+
const note = reason === undefined ? undefined : `The schema ${JSON.stringify(name)}, ${reason}.`;
|
|
713
|
+
const text = [schema.description ?? schema.title, note].filter((part) => part !== undefined && part.trim() !== "").join("\n\n");
|
|
714
|
+
out.push(`${comment(text, "")}export type ${type} = ${typeOf(schema, "")};`);
|
|
715
|
+
out.push("");
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const entries: Entry[] = [];
|
|
719
|
+
const seen = new Set<string>();
|
|
720
|
+
for (const [path, rawItem] of Object.entries(document.paths ?? {}).toSorted(([a], [b]) => a.localeCompare(b)))
|
|
721
|
+
{
|
|
722
|
+
const item = deref(context, rawItem, "pathItems");
|
|
723
|
+
const operations: [string, OperationObject][] = [];
|
|
724
|
+
for (const method of METHODS)
|
|
725
|
+
{
|
|
726
|
+
const operation = item[method];
|
|
727
|
+
if (operation !== undefined)
|
|
728
|
+
{
|
|
729
|
+
operations.push([method, operation]);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
for (const [method, operation] of Object.entries(item.additionalOperations ?? {}))
|
|
733
|
+
{
|
|
734
|
+
if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/i.test(method))
|
|
735
|
+
{
|
|
736
|
+
fail(`${JSON.stringify(path)} has the additional operation ${JSON.stringify(method)}, which is not an HTTP method name`);
|
|
737
|
+
}
|
|
738
|
+
if (FORBIDDEN_METHODS.has(method.toLowerCase()))
|
|
739
|
+
{
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
operations.push([method.toLowerCase(), operation]);
|
|
743
|
+
}
|
|
744
|
+
for (const [method, operation] of operations)
|
|
745
|
+
{
|
|
746
|
+
const entry = entryOf(context, path, item, method, operation);
|
|
747
|
+
if (seen.has(entry.name))
|
|
748
|
+
{
|
|
749
|
+
fail(`two operations resolve to the name "${entry.name}"; give them distinct operationId values`);
|
|
750
|
+
}
|
|
751
|
+
seen.add(entry.name);
|
|
752
|
+
entries.push(entry);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
out.push(`export interface ${context.apiName}\n{`);
|
|
757
|
+
out.push(entries.map((entry) => `${comment(entry.doc, " ")} ${key(entry.name)}: {\n${entry.lines.join("\n")}\n };`).join("\n"));
|
|
758
|
+
out.push("}");
|
|
759
|
+
out.push("");
|
|
760
|
+
out.push("export const routes = {");
|
|
761
|
+
out.push(entries.map((entry) => ` ${key(entry.name)}: { method: ${JSON.stringify(entry.method)}, path: ${JSON.stringify(entry.path)} }`).join(",\n"));
|
|
762
|
+
out.push(`} as const satisfies RouteTable<${context.apiName}>;`);
|
|
763
|
+
out.push("");
|
|
764
|
+
return { code: out.join("\n"), used: builtInsUsed, types };
|
|
765
|
+
}
|