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 +21 -0
- package/dist/_template.ts +65 -0
- package/dist/cli.js +532 -0
- package/package.json +55 -0
- package/readme.md +89 -0
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/
|