apigen-ts 0.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 vladkens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,65 @@
1
+ // Use uppercase for names in ApiClient to avoid conflict with the generated code
2
+
3
+ namespace apigen {
4
+ export type Config = { baseUrl: string; headers: Record<string, string> }
5
+ export type Req = Omit<RequestInit, "body"> & {
6
+ search?: Record<string, unknown>
7
+ body?: unknown
8
+ }
9
+ }
10
+
11
+ export class ApiClient {
12
+ ISO_FORMAT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d*)?(?:[-+]\d{2}:?\d{2}|Z)?$/
13
+ Config: apigen.Config
14
+
15
+ constructor(config?: Partial<apigen.Config>) {
16
+ this.Config = { baseUrl: "/", headers: {}, ...config }
17
+ }
18
+
19
+ PopulateDates<T>(d: T): T {
20
+ if (d === null || d === undefined || typeof d !== "object") return d
21
+
22
+ const t = d as unknown as Record<string, unknown>
23
+ for (const [k, v] of Object.entries(t)) {
24
+ if (typeof v === "string" && this.ISO_FORMAT.test(v)) t[k] = new Date(v)
25
+ else if (typeof v === "object") this.PopulateDates(v)
26
+ }
27
+
28
+ return d
29
+ }
30
+
31
+ async Fetch<T>(method: string, path: string, config: apigen.Req = {}): Promise<T> {
32
+ const fallback = globalThis.location?.origin ?? undefined
33
+ const url = new URL(`${this.Config.baseUrl}/${path}`.replace(/\/+/g, "/"), fallback)
34
+ for (const [k, v] of Object.entries(config?.search ?? {})) {
35
+ url.searchParams.append(k, Array.isArray(v) ? v.join(",") : (v as string))
36
+ }
37
+
38
+ const headers = new Headers({ ...this.Config.headers, ...config.headers })
39
+ const ct = headers.get("content-type") ?? "application/json"
40
+
41
+ let body: FormData | string | undefined = undefined
42
+ if (ct === "multipart/form-data") {
43
+ headers.delete("content-type") // https://stackoverflow.com/a/61053359/3664464
44
+ body = new FormData()
45
+ for (const [k, v] of Object.entries(config.body as Record<string, string>)) {
46
+ body.append(k, v)
47
+ }
48
+ }
49
+
50
+ if (ct === "application/json" && typeof config.body !== "string") {
51
+ headers.set("content-type", "application/json")
52
+ body = JSON.stringify(config.body)
53
+ }
54
+
55
+ const rep = await fetch(url.toString(), { method, ...config, headers, body })
56
+ if (!rep.ok) throw rep
57
+
58
+ const rs = await rep.text()
59
+ try {
60
+ return this.PopulateDates(JSON.parse(rs))
61
+ } catch (e) {
62
+ return rs as unknown as T
63
+ }
64
+ }
65
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env node
2
+ import { cli } from 'cleye';
3
+ import redocly from '@redocly/openapi-core';
4
+ import { filterNullable, filterEmpty } from 'array-utils-ts';
5
+ import fs from 'fs/promises';
6
+ import { isObject, sortBy, get, lowerFirst, upperFirst, uniqBy, isArray, uniq } from 'lodash-es';
7
+ import { dirname, join } from 'path';
8
+ import * as prettier from 'prettier';
9
+ import { convertObj } from 'swagger2openapi';
10
+ import ts from 'typescript';
11
+ import { fileURLToPath } from 'url';
12
+
13
+ const f = ts.factory;
14
+ const HttpMethods = ["get", "post", "put", "patch", "delete", "head", "options", "trace"];
15
+ const Keywords = /* @__PURE__ */ new Set([
16
+ "break",
17
+ "case",
18
+ "catch",
19
+ "class",
20
+ "const",
21
+ "continue",
22
+ "debugger",
23
+ "default",
24
+ "delete",
25
+ "do",
26
+ "else",
27
+ "enum",
28
+ "export",
29
+ "extends",
30
+ "false",
31
+ "finally",
32
+ "for",
33
+ "function",
34
+ "if",
35
+ "import",
36
+ "in",
37
+ "instanceof",
38
+ "new",
39
+ "null",
40
+ "return",
41
+ "super",
42
+ "switch",
43
+ "this",
44
+ "throw",
45
+ "true",
46
+ "try",
47
+ "typeof",
48
+ "var",
49
+ "void",
50
+ "while",
51
+ "with",
52
+ "implements",
53
+ "interface",
54
+ "let",
55
+ "package",
56
+ "private",
57
+ "protected",
58
+ "public",
59
+ "static",
60
+ "yield",
61
+ "any",
62
+ "boolean",
63
+ "number",
64
+ "string",
65
+ "symbol",
66
+ // "abstract", "as", "async", "await", "constructor", "declare", "from", "get", "is", "module",
67
+ // "namespace", "of", "require", "set", "type",
68
+ "Record",
69
+ "Partial",
70
+ "Pick",
71
+ "Omit",
72
+ "Exclude",
73
+ "Extract",
74
+ // ts keywords
75
+ "Date",
76
+ "object"
77
+ // ts type names
78
+ ]);
79
+ const initCtx = (doc, cfg) => {
80
+ return { name: "ApiClient", ...cfg, doc, tag: "", opNames: /* @__PURE__ */ new Set() };
81
+ };
82
+ const normalizeIdentifier = (val, asVar = false) => {
83
+ let name = val.replace("#/components/schemas/", "").replaceAll("'", "").replace(/[^a-zA-Z0-9]/g, "_");
84
+ if (name.match(/^\d/))
85
+ name = `$${name}`;
86
+ if (asVar && Keywords.has(name))
87
+ name = `$${name}`;
88
+ return name;
89
+ };
90
+ const normalizeOpName = (val) => {
91
+ const articles = /* @__PURE__ */ new Set(["a", "an", "the"]);
92
+ const tmp = val.replace(/'/, "").replace(/[^a-zA-Z0-9]/g, "_").split("_").filter((x) => x !== "" && !articles.has(x)).map((x) => upperFirst(x));
93
+ tmp[0] = tmp[0].toUpperCase() === tmp[0] ? tmp[0].toLowerCase() : lowerFirst(tmp[0]);
94
+ return tmp.join("");
95
+ };
96
+ const getOpName = (ctx, cfg) => {
97
+ let ns = normalizeOpName(filterEmpty(cfg.tags ?? [])[0] ?? "general");
98
+ let op = cfg.operationId ?? null;
99
+ if (!op) {
100
+ op = cfg.path.replace(/^(\/api)?(\/v?\d\.?\d?)?\/(.+)$/, "$3");
101
+ op = `${cfg.method}/${op}`.replace(/\/+/, "/");
102
+ }
103
+ op = normalizeOpName(op);
104
+ let nsr = ns.split("").map((x) => `[${x.toUpperCase()}${x.toLowerCase()}]`).join("");
105
+ if (nsr.endsWith("[Ss]"))
106
+ nsr += "?";
107
+ op = op.replace(new RegExp(`^${nsr}([Cc]ontroller|[Ss]ervice)?([A-Z].*)$`), "$2");
108
+ op = lowerFirst(op);
109
+ return [ns, op];
110
+ };
111
+ const makeInlineEnum = (s) => {
112
+ if (!s.enum)
113
+ return void 0;
114
+ const values = filterEmpty(s.enum);
115
+ if (!values.length)
116
+ return void 0;
117
+ if (!s.type) {
118
+ if (values.every((x) => typeof x === "string"))
119
+ s.type = "string";
120
+ if (values.every((x) => typeof x === "number"))
121
+ s.type = "number";
122
+ if (values.every((x) => typeof x === "boolean"))
123
+ s.type = "boolean";
124
+ }
125
+ if (s.type === "string") {
126
+ const tokens = uniq(values).map((x) => f.createStringLiteral(x.toString()));
127
+ return f.createUnionTypeNode(tokens.map((x) => f.createLiteralTypeNode(x)));
128
+ }
129
+ if (s.type === "number") {
130
+ const tokens = uniq(values).map((x) => f.createNumericLiteral(x));
131
+ return f.createUnionTypeNode(tokens.map((x) => f.createLiteralTypeNode(x)));
132
+ }
133
+ if (s.type === "boolean") {
134
+ const tokens = [];
135
+ if (values.includes(true))
136
+ tokens.push(f.createToken(ts.SyntaxKind.TrueKeyword));
137
+ if (values.includes(false))
138
+ tokens.push(f.createToken(ts.SyntaxKind.FalseKeyword));
139
+ return f.createUnionTypeNode(tokens.map((x) => f.createLiteralTypeNode(x)));
140
+ }
141
+ console.warn(`enum with unknown type ${s.type}`, s);
142
+ return void 0;
143
+ };
144
+ const makeType = (ctx, s) => {
145
+ const mk = makeType.bind(null, ctx);
146
+ if (s === void 0)
147
+ return f.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword);
148
+ if (s === null)
149
+ return f.createLiteralTypeNode(f.createNull());
150
+ if ("$ref" in s && s.$ref) {
151
+ const parts = s.$ref.replace("#/", "").split("/");
152
+ if (parts.length === 3 && parts[0] === "components" && parts[1] === "schemas") {
153
+ return f.createTypeReferenceNode(normalizeIdentifier(parts[2], true));
154
+ }
155
+ const t = unref(ctx, s);
156
+ if (!t)
157
+ throw new Error(`makeTypeRef: ref not found ${JSON.stringify(s)}`);
158
+ return makeType(ctx, t);
159
+ }
160
+ if ("oneOf" in s && s.oneOf)
161
+ return f.createUnionTypeNode(s.oneOf.map(mk));
162
+ if ("anyOf" in s && s.anyOf)
163
+ return f.createUnionTypeNode(s.anyOf.map(mk));
164
+ if ("allOf" in s && s.allOf)
165
+ return f.createIntersectionTypeNode(s.allOf.map(mk));
166
+ if ("type" in s && s.type === "integer")
167
+ s.type = "number";
168
+ if ("enum" in s && s.enum && !Array.isArray(s.type)) {
169
+ const isArray2 = s.type === "array";
170
+ const t = makeInlineEnum(isArray2 ? { ...s, type: s.items?.type } : s);
171
+ if (t)
172
+ return isArray2 ? f.createArrayTypeNode(t) : t;
173
+ }
174
+ if ("properties" in s && s.properties) {
175
+ return f.createTypeLiteralNode(
176
+ Object.entries(s.properties).map(([k, v]) => {
177
+ const r = s.required ?? [];
178
+ const q = r.includes(k) ? void 0 : f.createToken(ts.SyntaxKind.QuestionToken);
179
+ return f.createPropertySignature(void 0, f.createStringLiteral(k), q, mk(v));
180
+ })
181
+ );
182
+ }
183
+ if ("type" in s) {
184
+ if (Array.isArray(s.type)) {
185
+ const types = [];
186
+ for (const type of s.type) {
187
+ if (type === "null")
188
+ types.push({ type: "null" });
189
+ else
190
+ types.push({ ...s, type });
191
+ }
192
+ return mk({ oneOf: types });
193
+ }
194
+ let t;
195
+ if (s.type === "object")
196
+ t = f.createKeywordTypeNode(ts.SyntaxKind.ObjectKeyword);
197
+ else if (s.type === "boolean")
198
+ t = f.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);
199
+ else if (s.type === "number")
200
+ t = f.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
201
+ else if (s.type === "string")
202
+ t = f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
203
+ else if (s.type === "array")
204
+ t = f.createArrayTypeNode(mk(s.items));
205
+ else if (s.type === "null")
206
+ t = f.createLiteralTypeNode(f.createNull());
207
+ else if (isArray(s.type))
208
+ t = f.createUnionTypeNode(s.type.map((x) => mk({ type: x })));
209
+ else
210
+ throw new Error(`makeType: unknown type ${s.type}`);
211
+ if (s.type === "string") {
212
+ if (s.format === "binary")
213
+ t = f.createTypeReferenceNode("File");
214
+ if (s.format === "date-time")
215
+ t = f.createTypeReferenceNode("Date");
216
+ }
217
+ return s.nullable ? f.createUnionTypeNode([t, f.createLiteralTypeNode(f.createNull())]) : t;
218
+ }
219
+ return f.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword);
220
+ };
221
+ const isStringEnum = (s) => {
222
+ if ("enum" in s && s.enum) {
223
+ return s.enum.every((x) => typeof x === "string");
224
+ }
225
+ return false;
226
+ };
227
+ const makeTypeAlias = (ctx, name, s) => {
228
+ if (isStringEnum(s)) {
229
+ const tokens1 = s.enum;
230
+ const tokens2 = filterEmpty(s.enum);
231
+ if (tokens1.length !== tokens2.length) {
232
+ console.warn(`enum ${name} has empty values`, s);
233
+ }
234
+ return f.createEnumDeclaration(
235
+ [f.createToken(ts.SyntaxKind.ExportKeyword)],
236
+ normalizeIdentifier(name, true),
237
+ tokens2.map(
238
+ (x) => f.createEnumMember(upperFirst(normalizeIdentifier(x)), f.createStringLiteral(x))
239
+ )
240
+ );
241
+ }
242
+ return f.createTypeAliasDeclaration(
243
+ [f.createToken(ts.SyntaxKind.ExportKeyword)],
244
+ f.createIdentifier(normalizeIdentifier(name, true)),
245
+ void 0,
246
+ makeType(ctx, s)
247
+ );
248
+ };
249
+ const unref = (ctx, s) => {
250
+ if (!s)
251
+ return void 0;
252
+ if ("$ref" in s && s.$ref) {
253
+ const parts = s.$ref.replace("#/", "").split("/");
254
+ const obj = parts.reduce(
255
+ // openapi encodes "/" in key as "~1"
256
+ (acc, x) => get(acc, x, get(acc, decodeURIComponent(x).replaceAll("~1", "/"))),
257
+ ctx.doc
258
+ );
259
+ if (obj)
260
+ return obj;
261
+ console.warn(`${ctx.tag} ref ${s.$ref} not found`);
262
+ return void 0;
263
+ }
264
+ return s;
265
+ };
266
+ const getReqSchema = (ctx, config) => {
267
+ const req = unref(ctx, config.requestBody);
268
+ if (!req)
269
+ return void 0;
270
+ const cts = Object.entries(req.content ?? {}).map((x) => [x[0].split(";")[0], x[1].schema]).filter((x) => x[1]);
271
+ if (cts.length === 0)
272
+ return void 0;
273
+ const pretenders = [
274
+ "application/json",
275
+ "text/",
276
+ "multipart/form-data",
277
+ "application/x-www-form-urlencoded"
278
+ ];
279
+ for (const p of pretenders) {
280
+ const ct = cts.find((x) => x[0].startsWith(p));
281
+ if (ct)
282
+ return ct;
283
+ }
284
+ cts.map((x) => x[0]);
285
+ return void 0;
286
+ };
287
+ const getRepSchema = (ctx, config) => {
288
+ const successCodes = Object.keys(config.responses ?? {}).filter((x) => x.startsWith("2")).filter((x) => get(config, ["responses", x, "content"]));
289
+ const cts = Object.entries(get(config, ["responses", successCodes[0], "content"], {})).filter((x) => x[1].schema);
290
+ if (cts.length === 0)
291
+ return void 0;
292
+ const ctJson = cts.find((x) => x[0].startsWith("application/json"));
293
+ if (ctJson)
294
+ return ctJson[1].schema;
295
+ const ctText = cts.find((x) => x[0].startsWith("text/"));
296
+ if (ctText)
297
+ return { type: "string" };
298
+ cts.map((x) => x[0]).join(", ");
299
+ return void 0;
300
+ };
301
+ const prepareUrl = (url, rename) => {
302
+ for (const [k, v] of Object.entries(rename))
303
+ url = url.replaceAll(`{${k}}`, "${" + v + "}");
304
+ const parts = url.split("${");
305
+ if (parts.length === 1)
306
+ return f.createStringLiteral(url);
307
+ return f.createTemplateExpression(
308
+ f.createTemplateHead(parts[0]),
309
+ parts.slice(1).map((x, i) => {
310
+ const [name, ...rest] = x.split("}");
311
+ const right = rest.join("}");
312
+ return f.createTemplateSpan(
313
+ f.createIdentifier(name),
314
+ // no normalization required
315
+ i === parts.length - 2 ? f.createTemplateTail(right) : f.createTemplateMiddle(right)
316
+ );
317
+ })
318
+ );
319
+ };
320
+ const prepareOp = (ctx, cfg, opName) => {
321
+ cfg.parameters = cfg.parameters ?? [];
322
+ const reqSchema = getReqSchema(ctx, cfg);
323
+ const repSchema = getRepSchema(ctx, cfg);
324
+ const allParams = filterNullable(cfg.parameters.map((x) => unref(ctx, x)));
325
+ const params = uniqBy(allParams.filter((x) => x.in === "path"), "name");
326
+ if (reqSchema)
327
+ params.push({ name: "body", schema: reqSchema[1] });
328
+ const search = allParams.filter((x) => x.in === "query");
329
+ allParams.filter((x) => x.in === "header");
330
+ for (const [name, v] of Object.entries({ search })) {
331
+ if (!v.length)
332
+ continue;
333
+ const properties = v.reduce((acc, x) => ({ ...acc, [x.name]: x.schema }), {});
334
+ params.push({ name, schema: { type: "object", properties } });
335
+ }
336
+ const urlReplacements = {};
337
+ const fnArgs = params.map((x) => {
338
+ const name = normalizeIdentifier(x.name, true);
339
+ const type = makeType(ctx, x.schema);
340
+ urlReplacements[x.name] = name;
341
+ return f.createParameterDeclaration(void 0, void 0, name, void 0, type);
342
+ });
343
+ const cbArgs = filterNullable([
344
+ search.length ? f.createShorthandPropertyAssignment("search") : void 0,
345
+ reqSchema && f.createShorthandPropertyAssignment("body"),
346
+ reqSchema && reqSchema[0] !== "application/json" ? f.createPropertyAssignment(
347
+ "headers",
348
+ f.createObjectLiteralExpression([
349
+ f.createPropertyAssignment(
350
+ f.createStringLiteral("content-type"),
351
+ f.createStringLiteral(reqSchema[0])
352
+ )
353
+ ])
354
+ ) : void 0
355
+ ]);
356
+ return f.createPropertyAssignment(
357
+ f.createIdentifier(normalizeIdentifier(opName)),
358
+ f.createArrowFunction(
359
+ void 0,
360
+ void 0,
361
+ fnArgs,
362
+ void 0,
363
+ void 0,
364
+ f.createBlock([
365
+ f.createReturnStatement(
366
+ f.createCallExpression(
367
+ f.createIdentifier("this.Fetch"),
368
+ [makeType(ctx, repSchema)],
369
+ [
370
+ f.createStringLiteral(cfg.method),
371
+ // method
372
+ prepareUrl(cfg.path, urlReplacements),
373
+ // path
374
+ f.createObjectLiteralExpression(cbArgs)
375
+ // { query, body, headers }
376
+ ]
377
+ )
378
+ )
379
+ ])
380
+ )
381
+ );
382
+ };
383
+ const prepareNs = (ctx, name, handlers) => {
384
+ return f.createPropertyDeclaration(
385
+ void 0,
386
+ normalizeIdentifier(name),
387
+ void 0,
388
+ void 0,
389
+ f.createObjectLiteralExpression(handlers)
390
+ );
391
+ };
392
+ const prepareRoutes = async (ctx) => {
393
+ const routes = {};
394
+ for (const [path, pathConfig] of Object.entries(ctx.doc.paths ?? {})) {
395
+ ctx.tag = `${"[ALL]".toUpperCase().padEnd(6, " ")} ${path}`;
396
+ if (!isObject(pathConfig))
397
+ continue;
398
+ if ("$ref" in pathConfig) {
399
+ console.warn(`${ctx.tag} $ref should be resolved before (skipping)`);
400
+ continue;
401
+ }
402
+ for (const method of HttpMethods) {
403
+ ctx.tag = `${method.toUpperCase().padEnd(6, " ")} ${path}`;
404
+ const config = pathConfig[method];
405
+ if (!config)
406
+ continue;
407
+ if (pathConfig.parameters) {
408
+ config.parameters = [...config.parameters ?? [], ...pathConfig.parameters];
409
+ }
410
+ const [ns, op] = getOpName(ctx, { ...config, method, path });
411
+ if (!routes[ns])
412
+ routes[ns] = [];
413
+ const joined = [ns, op].join(".");
414
+ if (ctx.opNames.has(joined)) {
415
+ continue;
416
+ } else {
417
+ ctx.opNames.add(joined);
418
+ }
419
+ try {
420
+ routes[ns].push(prepareOp(ctx, { ...config, method, path }, op));
421
+ } catch (e) {
422
+ console.error(`${ctx.tag} - ${e}`, config);
423
+ throw e;
424
+ }
425
+ }
426
+ }
427
+ return routes;
428
+ };
429
+ const prepareTypes = async (ctx) => {
430
+ const types = [];
431
+ const typesConfig = sortBy(Object.entries(ctx.doc.components?.schemas ?? {}), ([k]) => k);
432
+ for (const [name, config] of typesConfig) {
433
+ try {
434
+ types.push(makeTypeAlias(ctx, name, config));
435
+ } catch (e) {
436
+ console.error(`${ctx.tag} - ${e}`, name, config);
437
+ throw e;
438
+ }
439
+ }
440
+ return types;
441
+ };
442
+ const patchTemplate = async (ctx, modules) => {
443
+ const filepath = join(dirname(fileURLToPath(import.meta.url)), "_template.ts");
444
+ const file = await fs.readFile(filepath, "utf-8");
445
+ const root = ts.createSourceFile("tmpl.ts", file, ts.ScriptTarget.Latest);
446
+ return Array.from(root.statements).map((x) => {
447
+ if (x.kind === ts.SyntaxKind.ClassDeclaration) {
448
+ const name = get(x, "name.text");
449
+ if (name === "ApiClient") {
450
+ const t = x;
451
+ return f.updateClassDeclaration(
452
+ t,
453
+ t.modifiers,
454
+ f.createIdentifier(ctx.name),
455
+ t.typeParameters,
456
+ t.heritageClauses,
457
+ addNewLines([...t.members, ...modules])
458
+ );
459
+ }
460
+ }
461
+ return x;
462
+ });
463
+ };
464
+ const prepareAst = async (ctx) => {
465
+ const types = await prepareTypes(ctx);
466
+ const routes = await prepareRoutes(ctx);
467
+ const modules = [];
468
+ for (const [k, v] of Object.entries(routes)) {
469
+ modules.push(prepareNs(ctx, k, v));
470
+ }
471
+ return filterNullable([...await patchTemplate(ctx, modules), ...types]);
472
+ };
473
+ const addNewLines = (nodes) => {
474
+ const result = [];
475
+ for (const node of nodes) {
476
+ result.push(node);
477
+ result.push(f.createIdentifier("\n"));
478
+ }
479
+ return result;
480
+ };
481
+ const loadSchema = async (url) => {
482
+ const { bundle } = await redocly.bundle({
483
+ ref: url,
484
+ config: await redocly.createConfig({}),
485
+ removeUnusedComponents: false
486
+ });
487
+ if (bundle.parsed.swagger) {
488
+ const { openapi } = await convertObj(bundle.parsed, { patch: true });
489
+ return openapi;
490
+ }
491
+ return bundle.parsed;
492
+ };
493
+ const printCode = async (nodes) => {
494
+ const code = ts.createPrinter().printFile(
495
+ f.createSourceFile(
496
+ addNewLines(nodes),
497
+ f.createToken(ts.SyntaxKind.EndOfFileToken),
498
+ ts.NodeFlags.None
499
+ )
500
+ ).replaceAll("}, ", "},\n\n");
501
+ const options = await prettier.resolveConfig(process.cwd());
502
+ return prettier.format(code, { ...options, parser: "typescript" });
503
+ };
504
+ const apigen = async (source, output, cfg) => {
505
+ const doc = await loadSchema(source);
506
+ const ast = await prepareAst(initCtx(doc, cfg));
507
+ const txt = [
508
+ `// Auto-generated by https://github.com/vladkens/apigen-ts`,
509
+ `// Source: ${source}
510
+ `,
511
+ await printCode(ast)
512
+ ].join("\n");
513
+ await fs.mkdir(dirname(output), { recursive: true });
514
+ await fs.writeFile(output, txt);
515
+ };
516
+
517
+ const main = async () => {
518
+ const argv = cli({
519
+ name: "apigen",
520
+ // version: "0.0.1",
521
+ parameters: ["<source>", "<output>"],
522
+ flags: {
523
+ name: {
524
+ type: String,
525
+ description: "api class name to export",
526
+ default: "ApiClient"
527
+ }
528
+ }
529
+ });
530
+ await apigen(argv._.source, argv._.output, { name: argv.flags.name });
531
+ };
532
+ main();
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "type": "module",
3
+ "name": "apigen-ts",
4
+ "version": "0.0.1",
5
+ "license": "MIT",
6
+ "author": "Vlad Pronsky <v.pronsky@gmail.com>",
7
+ "repository": "vladkens/apigen-ts",
8
+ "description": "OpenAPI TypeScript client generator",
9
+ "keywords": [
10
+ "openapi",
11
+ "swagger",
12
+ "typescript",
13
+ "api",
14
+ "generator",
15
+ "codegen"
16
+ ],
17
+ "scripts": {
18
+ "build": "rm -rf dist && pkgroll && cp ./src/_template.ts ./dist && ls -lah dist",
19
+ "test": "uvu -r tsm test '\\.test\\.ts$'",
20
+ "test-cov": "c8 --include=src yarn test",
21
+ "test-watch": "watchexec -c -e ts 'clear && yarn test'",
22
+ "format": "prettier --write .",
23
+ "ci": "yarn test-cov && yarn build"
24
+ },
25
+ "dependencies": {
26
+ "@redocly/openapi-core": "^1.4.1",
27
+ "@types/lodash-es": "^4.17.12",
28
+ "@types/swagger2openapi": "^7.0.4",
29
+ "array-utils-ts": "^0.1.2",
30
+ "cleye": "^1.3.2",
31
+ "lodash-es": "^4.17.21",
32
+ "swagger2openapi": "^7.0.8"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^18.18.0",
36
+ "c8": "^8.0.1",
37
+ "fetch-mock": "^9.11.0",
38
+ "pkgroll": "^2.0.1",
39
+ "prettier": "^3.1.0",
40
+ "prettier-plugin-organize-imports": "^3.2.4",
41
+ "tsm": "^2.3.0",
42
+ "typescript": "^5.3.2",
43
+ "uvu": "^0.5.6"
44
+ },
45
+ "peerDependencies": {
46
+ "prettier": "^3.0.0",
47
+ "typescript": "^5.0.0"
48
+ },
49
+ "bin": {
50
+ "apigen-ts": "./dist/cli.js"
51
+ },
52
+ "files": [
53
+ "dist"
54
+ ]
55
+ }
package/readme.md ADDED
@@ -0,0 +1,89 @@
1
+ # OpenAPI TypeScript client generator
2
+
3
+ <div align="center">
4
+ <a href="https://npmjs.org/package/apigen-ts">
5
+ <img src="https://badgen.net/npm/v/apigen-ts" alt="version" />
6
+ </a>
7
+ <a href="https://github.com/vladkens/apigen-ts/actions">
8
+ <img src="https://github.com/vladkens/apigen-ts/workflows/test/badge.svg" alt="test status" />
9
+ </a>
10
+ <a href="https://packagephobia.now.sh/result?p=apigen-ts">
11
+ <img src="https://badgen.net/packagephobia/publish/apigen-ts" alt="size" />
12
+ </a>
13
+ <a href="https://npmjs.org/package/apigen-ts">
14
+ <img src="https://badgen.net/npm/dm/apigen-ts" alt="downloads" />
15
+ </a>
16
+ <a href="https://github.com/vladkens/apigen-ts/blob/main/LICENSE">
17
+ <img src="https://badgen.net/github/license/vladkens/apigen-ts" alt="license" />
18
+ </a>
19
+ </div>
20
+
21
+ ## Features
22
+
23
+ - Generates ready to use ApiClient with types (using `fetch`)
24
+ - Single output file, minimal third-party code
25
+ - Load schema from JSON / YAML, locally and remote
26
+ - Ability to customize `fetch` with your custom function
27
+ - Uses `type` instead of `interface`, so no problem with declaration merging
28
+ - Automatic formating with Prettier
29
+ - Parses dates automatically
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ yarn install -D apigen-ts
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### Generate
40
+
41
+ ```sh
42
+ # From url
43
+ yarn apigen-ts https://petstore3.swagger.io/api/v3/openapi.json ./api-generated.ts
44
+
45
+ # From file
46
+ yarn apigen-ts ./openapi.json ./api-generated.ts
47
+ ```
48
+
49
+ ### Import
50
+
51
+ ```typescript
52
+ import { ApiClient } from "./api-generated"
53
+
54
+ const api = new ApiClient({
55
+ baseUrl: "https://example.com/api",
56
+ headers: { Authorization: "secret-token" },
57
+ })
58
+ ```
59
+
60
+ ### Use
61
+
62
+ ```typescript
63
+ // GET /pet/{petId}
64
+ await api.pet.getPetById(1) // -> Pet
65
+
66
+ // GET /pet/findByStatus?status=sold
67
+ await api.pet.findPetsByStatus({ status: "sold" }) // -> Pets[]
68
+
69
+ // PUT /user/{username}
70
+ await api.user.updateUser("username", { firstName: "John" }) // second arg is body with type User
71
+ ```
72
+
73
+ ## Advanced
74
+
75
+ ### Login flow
76
+
77
+ ```typescript
78
+ const { token } = await api.auth.login({ usename, password })
79
+ api.Config.headers = { Authorization: token }
80
+
81
+ await api.protectedRoute.get() // here authenticated
82
+ ```
83
+
84
+ ## Useful for development
85
+
86
+ - https://ts-ast-viewer.com
87
+ - https://jsonschemalint.com
88
+ - https://redocly.github.io/redoc/
89
+ - https://swagger.io/docs/specification/basic-structure/