typedapi-client-helpers 0.1.3 → 0.1.4

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.
@@ -1,735 +1,740 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { generateApi } from "swagger-typescript-api";
5
-
6
- const __filename = fileURLToPath(import.meta.url);
7
- const __dirname = path.dirname(__filename);
8
-
9
- const runtimePackageJsonPath = path.resolve(__dirname, "../package.json");
10
- const runtimePackageJson = JSON.parse(
11
- fs.readFileSync(runtimePackageJsonPath, "utf8"),
12
- );
13
-
14
- const consumerPackageJsonPath = path.resolve(process.cwd(), "package.json");
15
- const consumerPackageJson = fs.existsSync(consumerPackageJsonPath)
16
- ? JSON.parse(fs.readFileSync(consumerPackageJsonPath, "utf8"))
17
- : {};
18
-
19
- const isRunningInsidePackage =
20
- path.resolve(process.cwd()) === path.resolve(__dirname, "..");
21
-
22
- const runtimePackageName = isRunningInsidePackage
23
- ? "../.."
24
- : runtimePackageJson.name;
25
-
26
- const cwd = process.cwd();
27
-
28
- const config = {
29
- ...(runtimePackageJson.config ?? {}),
30
- ...(consumerPackageJson.config ?? {}),
31
- };
32
-
33
- const swaggerFile =
34
- process.env.SWAGGER_FILE ??
35
- process.env.npm_config_swagger_file ??
36
- config.swaggerFile;
37
-
38
- const swaggerUrl =
39
- process.env.SWAGGER_URL ??
40
- process.env.npm_config_swagger_url ??
41
- config.swaggerUrl ??
42
- "https://localhost:7000/swagger/v1/swagger.json";
43
-
44
- const swaggerInput = swaggerFile ? path.resolve(cwd, swaggerFile) : swaggerUrl;
45
-
46
- const apiRoot = path.resolve(
47
- cwd,
48
- process.env.API_OUTPUT ??
49
- process.env.npm_config_api_output ??
50
- config.apiOutput ??
51
- "src/api",
52
- );
53
- const generatedDir = path.join(apiRoot, "generated");
54
- const methodsDir = path.join(apiRoot, "methods");
55
-
56
- const useTypeOnlyImports =
57
- process.env.TYPED_API_USE_TYPE_ONLY_IMPORTS === "true" ||
58
- process.env.npm_config_typed_api_use_type_only_imports === "true" ||
59
- config.typedApiUseTypeOnlyImports === true;
60
-
61
- const useFilterFormValues =
62
- process.env.TYPED_API_USE_FILTER_FORM_VALUES === "true" ||
63
- process.env.npm_config_typed_api_use_filter_form_values === "true" ||
64
- config.typedApiUseFilterFormValues === true;
65
-
66
- function pascalCase(value) {
67
- return value
68
- .split(/[-_]/)
69
- .filter(Boolean)
70
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
71
- .join("");
72
- }
73
-
74
- function toQueryName(methodName) {
75
- return `${pascalCase(methodName)}Query`;
76
- }
77
-
78
- function cleanDirectory(directory) {
79
- if (fs.existsSync(directory)) {
80
- fs.rmSync(directory, { recursive: true, force: true });
81
- }
82
-
83
- fs.mkdirSync(directory, { recursive: true });
84
- }
85
-
86
- async function writeFormattedFile(filePath, content) {
87
- fs.writeFileSync(filePath, content);
88
- }
89
-
90
- async function generateOpenApiClient() {
91
- cleanDirectory(generatedDir);
92
- cleanDirectory(methodsDir);
93
- fs.mkdirSync(methodsDir, { recursive: true });
94
-
95
- const generateOptions = {
96
- name: "Api.ts",
97
- output: generatedDir,
98
- httpClientType: "fetch",
99
- modular: true,
100
- cleanOutput: true,
101
- generateClient: true,
102
- generateRouteTypes: true,
103
- extractRequestParams: true,
104
- extractRequestBody: true,
105
- moduleNameFirstTag: true,
106
- };
107
-
108
- if (swaggerFile) {
109
- generateOptions.input = swaggerInput;
110
- } else {
111
- generateOptions.url = swaggerInput;
112
- }
113
-
114
- await generateApi(generateOptions);
115
- }
116
-
117
- function createImportStatement({ names, from, typeOnly = false }) {
118
- if (!names || names.length === 0) {
119
- return "";
120
- }
121
-
122
- const importKeyword = typeOnly ? "import type" : "import";
123
-
124
- return `${importKeyword} {
125
- ${names.map((name) => ` ${name},`).join("\n")}
126
- } from "${from}";`;
127
- }
128
-
129
- async function createTypesFile() {
130
- const httpClientImport = useTypeOnlyImports
131
- ? `import type { HttpResponse, RequestParams } from "../generated/http-client";`
132
- : `import { HttpResponse, RequestParams } from "../generated/http-client";`;
133
-
134
- const content = `
135
- ${httpClientImport}
136
-
137
- export type ExtractResponse<T> =
138
- T extends Promise<HttpResponse<infer R, any>> ? R : never;
139
-
140
- export type UnwrapArray<T> = T extends (infer U)[] ? U : T;
141
-
142
- export type ExtractDataIfPaginated<T> =
143
- T extends { data?: (infer U)[] | null } ? U : T;
144
-
145
- export type SortableKeys<T> = keyof UnwrapArray<ExtractDataIfPaginated<T>>;
146
-
147
- export type WithoutRequestParams<T extends any[]> =
148
- T extends [infer First, ...infer Rest]
149
- ? First extends RequestParams
150
- ? Rest
151
- : [First, ...WithoutRequestParams<Rest>]
152
- : [];
153
- `.trim();
154
-
155
- await writeFormattedFile(path.join(methodsDir, "Types.ts"), content);
156
- }
157
-
158
- function getAllTsFiles(dir) {
159
- let results = [];
160
-
161
- if (!fs.existsSync(dir)) {
162
- return results;
163
- }
164
-
165
- const list = fs.readdirSync(dir);
166
-
167
- for (const file of list) {
168
- const filePath = path.join(dir, file);
169
- const stat = fs.statSync(filePath);
170
-
171
- if (stat.isDirectory()) {
172
- results = results.concat(getAllTsFiles(filePath));
173
- } else if (filePath.endsWith(".ts")) {
174
- results.push(filePath);
175
- }
176
- }
177
-
178
- return results;
179
- }
180
-
181
- async function convertGeneratedUploadMethods(apiDir) {
182
- const tsFiles = getAllTsFiles(apiDir);
183
- let changedMethods = 0;
184
-
185
- console.log(
186
- `Checking ${tsFiles.length} generated TypeScript files for multipart methods...`,
187
- );
188
-
189
- for (const filePath of tsFiles) {
190
- if (
191
- filePath.endsWith("data-contracts.ts") ||
192
- filePath.endsWith("http-client.ts")
193
- ) {
194
- continue;
195
- }
196
-
197
- const originalContent = fs.readFileSync(filePath, "utf8");
198
-
199
- const methodRegex =
200
- /(\b\w+\s*=\s*\(\s*data\s*:\s*)([^,\n]+)(,\s*params\s*:\s*RequestParams\s*=\s*\{\}\s*,?\s*\)\s*=>\s*this\.request<[\s\S]*?type:\s*ContentType\.FormData,[\s\S]*?\}\);)/g;
201
-
202
- let fileChanges = 0;
203
-
204
- const updatedContent = originalContent.replace(
205
- methodRegex,
206
- (_match, start, currentType, rest) => {
207
- console.log(
208
- ` Replacing multipart payload type "${currentType.trim()}" with FormData`,
209
- );
210
-
211
- fileChanges++;
212
- changedMethods++;
213
-
214
- return `${start}FormData${rest}`;
215
- },
216
- );
217
-
218
- if (fileChanges > 0) {
219
- await writeFormattedFile(filePath, updatedContent);
220
-
221
- console.log(
222
- `Updated ${fileChanges} multipart method(s) in ${path.relative(
223
- process.cwd(),
224
- filePath,
225
- )}`,
226
- );
227
- }
228
- }
229
-
230
- if (changedMethods === 0) {
231
- throw new Error(
232
- "No multipart methods were converted. Check the generated method format and output directory.",
233
- );
234
- }
235
-
236
- console.log(`Converted ${changedMethods} multipart method(s) to FormData.`);
237
- }
238
-
239
- function isMultipartMethod(source, methodName) {
240
- const startPattern = new RegExp(`\\b${methodName}\\s*=\\s*\\(`);
241
-
242
- const startMatch = startPattern.exec(source);
243
-
244
- if (!startMatch) {
245
- return false;
246
- }
247
-
248
- const nextMethodPattern = /^\s*\w+\s*=\s*\(/gm;
249
- nextMethodPattern.lastIndex = startMatch.index + startMatch[0].length;
250
-
251
- const nextMatch = nextMethodPattern.exec(source);
252
- const endIndex = nextMatch?.index ?? source.length;
253
-
254
- const methodSource = source.slice(startMatch.index, endIndex);
255
-
256
- return methodSource.includes("type: ContentType.FormData");
257
- }
258
-
259
- function getGeneratedMethods(source) {
260
- const methodRegex = /^\s*(\w+)\s*=\s*\(/gm;
261
- const matches = [...source.matchAll(methodRegex)];
262
-
263
- return matches.map((match, index) => {
264
- const methodName = match[1];
265
- const startIndex = match.index ?? 0;
266
- const endIndex =
267
- index + 1 < matches.length ? matches[index + 1].index : source.length;
268
-
269
- const methodSource = source.slice(startIndex, endIndex);
270
-
271
- return {
272
- methodName,
273
- methodSource,
274
- isFormData: methodSource.includes("type: ContentType.FormData"),
275
- };
276
- });
277
- }
278
-
279
- async function createMethodFiles() {
280
- const generatedFiles = fs
281
- .readdirSync(generatedDir)
282
- .filter(
283
- (file) =>
284
- file.endsWith(".ts") &&
285
- file !== "http-client.ts" &&
286
- file !== "data-contracts.ts" &&
287
- !file.endsWith("Route.ts") &&
288
- !file.includes(".api"),
289
- );
290
-
291
- const methodExports = [];
292
-
293
- for (const file of generatedFiles) {
294
- const filePath = path.join(generatedDir, file);
295
- const source = fs.readFileSync(filePath, "utf8");
296
-
297
- const baseName = file.replace(".ts", "");
298
- const className = baseName.split("-").map(pascalCase).join("");
299
-
300
- const instanceName = `${className.charAt(0).toLowerCase()}${className.slice(1)}Api`;
301
-
302
- const paginatedMethods = [];
303
- const simpleQueryMethods = [];
304
- const nonQueryMethods = [];
305
-
306
- const generatedMethods = getGeneratedMethods(source);
307
-
308
- for (const generatedMethod of generatedMethods) {
309
- const { methodName, methodSource, isFormData } = generatedMethod;
310
-
311
- const signatureRegex = new RegExp(
312
- `${methodName}\\s*=\\s*\\(([^)]*)\\)\\s*=>`,
313
- "m",
314
- );
315
-
316
- const signatureMatch = methodSource.match(signatureRegex);
317
-
318
- if (!signatureMatch) {
319
- continue;
320
- }
321
-
322
- const params = signatureMatch[1].trim();
323
-
324
- const returnTypeMatch = methodSource.match(/this\.request<([^,>]+)/m);
325
-
326
- const returnType = returnTypeMatch?.[1] ?? "";
327
-
328
- const isPaginated =
329
- returnType.includes("ApiPaginationResponse") ||
330
- returnType.trim().endsWith("[]");
331
-
332
- if (params.startsWith("query")) {
333
- if (isPaginated) {
334
- paginatedMethods.push(methodName);
335
- } else {
336
- simpleQueryMethods.push(methodName);
337
- }
338
-
339
- continue;
340
- }
341
-
342
- nonQueryMethods.push({
343
- methodName,
344
- isFormData,
345
- });
346
- }
347
-
348
- const queryTypes = [...paginatedMethods, ...simpleQueryMethods]
349
- .map((methodName) =>
350
- `
351
- export type ${toQueryName(methodName)} =
352
- NonNullable<
353
- Parameters<${className}["${methodName}"]>[0]
354
- >;
355
- `.trim(),
356
- )
357
- .join("\n\n");
358
-
359
- const hasPaginatedMethods = paginatedMethods.length > 0;
360
-
361
- const hasNonQueryMethods = nonQueryMethods.length > 0;
362
-
363
- const hasFormDataMethods = nonQueryMethods.some(
364
- ({ isFormData }) => isFormData,
365
- );
366
-
367
- const hasRegularNonQueryMethods = nonQueryMethods.some(
368
- ({ isFormData }) => !isFormData,
369
- );
370
-
371
- const paginatedMethodWrappers = paginatedMethods
372
- .map((methodName) => {
373
- if (useFilterFormValues) {
374
- return `
375
- export async function ${methodName}(
376
- filters: FilterFormValues<${toQueryName(methodName)}>[] = [],
377
- page = 1,
378
- pageSize = 100,
379
- sortBy: SortableKeys<
380
- ExtractResponse<ReturnType<${className}["${methodName}"]>>
381
- > | null = null,
382
- sortDirection?: SortDirection,
383
- toastOptions?: ToastOptions
384
- ): Promise<ApiResult<ExtractResponse<ReturnType<${className}["${methodName}"]>>>> {
385
- return handleApiResponse(
386
- () =>
387
- ${instanceName}.${methodName}(
388
- buildQuery<
389
- ${toQueryName(methodName)},
390
- UnwrapArray<
391
- ExtractDataIfPaginated<
392
- ExtractResponse<ReturnType<${className}["${methodName}"]>>
393
- >
394
- >
395
- >(filters, page, pageSize, sortBy, sortDirection)
396
- ),
397
- toastOptions
398
- );
399
- }
400
- `.trim();
401
- }
402
-
403
- return `
404
- export async function ${methodName}(
405
- query?: ${toQueryName(methodName)},
406
- toastOptions?: ToastOptions
407
- ): Promise<ApiResult<ExtractResponse<ReturnType<${className}["${methodName}"]>>>> {
408
- return handleApiResponse(
409
- () => ${instanceName}.${methodName}(query),
410
- toastOptions
411
- );
412
- }
413
- `.trim();
414
- })
415
- .join("\n\n");
416
-
417
- const simpleQueryMethodWrappers = simpleQueryMethods
418
- .map((methodName) =>
419
- `
420
- export async function ${methodName}(
421
- query?: ${toQueryName(methodName)},
422
- toastOptions?: ToastOptions
423
- ): Promise<
424
- ApiResult<
425
- ExtractResponse<
426
- ReturnType<
427
- ${className}["${methodName}"]
428
- >
429
- >
430
- >
431
- > {
432
- return handleApiResponse(
433
- () =>
434
- ${instanceName}.${methodName}(query),
435
- toastOptions
436
- );
437
- }
438
- `.trim(),
439
- )
440
- .join("\n\n");
441
-
442
- const nonQueryMethodWrappers = nonQueryMethods
443
- .map(({ methodName, isFormData }) => {
444
- if (isFormData) {
445
- return `
446
- export async function ${methodName}(
447
- data: Parameters<
448
- ${className}["${methodName}"]
449
- >[0],
450
- toastOptions?: ToastOptions,
451
- params?: RequestParams
452
- ): Promise<
453
- ApiResult<
454
- ExtractResponse<
455
- ReturnType<
456
- ${className}["${methodName}"]
457
- >
458
- >
459
- >
460
- > {
461
- const formData = toFormData(data);
462
-
463
- return handleApiResponse(
464
- () =>
465
- ${instanceName}.${methodName}(
466
- formData as unknown as Parameters<
467
- ${className}["${methodName}"]
468
- >[0],
469
- params ?? {}
470
- ),
471
- toastOptions
472
- );
473
- }
474
- `.trim();
475
- }
476
-
477
- return `
478
- export async function ${methodName}(
479
- ...argsWithToast: [
480
- ...WithoutRequestParams<
481
- Parameters<
482
- ${className}["${methodName}"]
483
- >
484
- >,
485
- ToastOptions?,
486
- RequestParams?
487
- ]
488
- ): Promise<
489
- ApiResult<
490
- ExtractResponse<
491
- ReturnType<
492
- ${className}["${methodName}"]
493
- >
494
- >
495
- >
496
- > {
497
- const {
498
- args,
499
- toastOptions,
500
- params
501
- } = extractArgsToastsAndParams<
502
- WithoutRequestParams<
503
- Parameters<
504
- ${className}["${methodName}"]
505
- >
506
- >
507
- >(argsWithToast);
508
-
509
- const requestArgs = [
510
- ...args,
511
- params ?? {}
512
- ] as unknown as Parameters<
513
- ${className}["${methodName}"]
514
- >;
515
-
516
- return handleApiResponse(
517
- () =>
518
- ${instanceName}.${methodName}(
519
- ...requestArgs
520
- ),
521
- toastOptions
522
- );
523
- }
524
- `.trim();
525
- })
526
- .join("\n\n");
527
-
528
- const runtimeValueImportNames = [
529
- ...(hasPaginatedMethods && useFilterFormValues ? ["buildQuery"] : []),
530
- ...(hasRegularNonQueryMethods ? ["extractArgsToastsAndParams"] : []),
531
- ...(hasFormDataMethods ? ["toFormData"] : []),
532
- "handleApiResponse",
533
- ];
534
-
535
- const runtimeTypeImportNames = [
536
- "ApiResult",
537
- ...(hasPaginatedMethods && useFilterFormValues
538
- ? ["FilterFormValues", "SortDirection"]
539
- : []),
540
- "ToastOptions",
541
- ];
542
-
543
- const localTypeImportNames = [
544
- ...(hasPaginatedMethods && useFilterFormValues
545
- ? ["ExtractDataIfPaginated", "SortableKeys", "UnwrapArray"]
546
- : []),
547
- "ExtractResponse",
548
- ...(hasRegularNonQueryMethods ? ["WithoutRequestParams"] : []),
549
- ];
550
-
551
- const runtimeValueImports = createImportStatement({
552
- names: runtimeValueImportNames,
553
- from: runtimePackageName,
554
- });
555
-
556
- const runtimeTypeImports = createImportStatement({
557
- names: runtimeTypeImportNames,
558
- from: runtimePackageName,
559
- typeOnly: useTypeOnlyImports,
560
- });
561
-
562
- const generatedValueImports = createImportStatement({
563
- names: [className],
564
- from: `../generated/${baseName}`,
565
- });
566
-
567
- const generatedTypeImports = hasNonQueryMethods
568
- ? createImportStatement({
569
- names: ["RequestParams"],
570
- from: "../generated/http-client",
571
- typeOnly: useTypeOnlyImports,
572
- })
573
- : "";
574
-
575
- const localTypeImports = createImportStatement({
576
- names: localTypeImportNames,
577
- from: "./Types",
578
- typeOnly: useTypeOnlyImports,
579
- });
580
-
581
- const content = `
582
- ${runtimeValueImports}
583
-
584
- ${runtimeTypeImports}
585
-
586
- ${generatedValueImports}
587
-
588
- ${generatedTypeImports}
589
-
590
- ${localTypeImports}
591
-
592
- /* =======================
593
- Query Types
594
- ======================= */
595
- ${queryTypes}
596
-
597
- /* =======================
598
- API Instance
599
- ======================= */
600
- const ${instanceName} = new ${className}();
601
-
602
- /* =======================
603
- Paginated Query Methods
604
- ======================= */
605
- ${paginatedMethodWrappers}
606
-
607
- /* =======================
608
- Simple Query Methods
609
- ======================= */
610
- ${simpleQueryMethodWrappers}
611
-
612
- /* =======================
613
- Non-Query Methods
614
- ======================= */
615
- ${nonQueryMethodWrappers}
616
- `.trim();
617
-
618
- const outputFile = path.join(methodsDir, `${className}.api.ts`);
619
-
620
- await writeFormattedFile(outputFile, content);
621
-
622
- methodExports.push(`export * from "./methods/${className}.api";`);
623
- }
624
-
625
- const apiIndexContent = `
626
- export * from "./generated/data-contracts";
627
- export * from "./generated/http-client";
628
- export * from "./methods/Types";
629
-
630
- ${methodExports.join("\n")}
631
- `.trim();
632
-
633
- await writeFormattedFile(path.join(apiRoot, "index.ts"), apiIndexContent);
634
- }
635
-
636
- async function fixGeneratedMixedImports(apiDir) {
637
- const tsFiles = getAllTsFiles(apiDir);
638
-
639
- for (const filePath of tsFiles) {
640
- if (
641
- filePath.endsWith("http-client.ts") ||
642
- filePath.endsWith("data-contracts.ts")
643
- ) {
644
- continue;
645
- }
646
-
647
- const originalContent = fs.readFileSync(filePath, "utf8");
648
- let content = originalContent;
649
-
650
- /*
651
- * Converts:
652
- *
653
- * import { ContentType, HttpClient, RequestParams }
654
- * from "./http-client";
655
- *
656
- * into separate runtime and type imports.
657
- */
658
- content = content.replace(
659
- /import\s*\{([\s\S]*?)\}\s*from\s*["']\.\/http-client["'];/g,
660
- (_match, importsText) => {
661
- const importedNames = importsText
662
- .split(",")
663
- .map((name) => name.trim())
664
- .filter(Boolean);
665
-
666
- const typeNames = importedNames.filter(
667
- (name) => name === "RequestParams",
668
- );
669
-
670
- const valueNames = importedNames.filter(
671
- (name) => name !== "RequestParams",
672
- );
673
-
674
- const statements = [];
675
-
676
- if (valueNames.length > 0) {
677
- statements.push(
678
- `import { ${valueNames.join(", ")} } from "./http-client";`,
679
- );
680
- }
681
-
682
- if (typeNames.length > 0 && useTypeOnlyImports) {
683
- statements.push(
684
- `import type { ${typeNames.join(", ")} } from "./http-client";`,
685
- );
686
- } else if (typeNames.length > 0) {
687
- statements.push(
688
- `import { ${typeNames.join(", ")} } from "./http-client";`,
689
- );
690
- }
691
-
692
- return statements.join("\n");
693
- },
694
- );
695
-
696
- /*
697
- * Everything imported from data-contracts by generated API classes
698
- * is normally used as a TypeScript type.
699
- */
700
- if (useTypeOnlyImports) {
701
- content = content.replace(
702
- /import\s*\{([\s\S]*?)\}\s*from\s*["']\.\/data-contracts["'];/g,
703
- (_match, importsText) =>
704
- `import type {${importsText}} from "./data-contracts";`,
705
- );
706
- }
707
-
708
- if (content !== originalContent) {
709
- await writeFormattedFile(filePath, content);
710
-
711
- console.log(
712
- `Fixed mixed imports in ${path.relative(process.cwd(), filePath)}`,
713
- );
714
- }
715
- }
716
- }
717
-
718
- async function main() {
719
- console.log(`Generating API from: ${swaggerInput}`);
720
- console.log(`Output folder: ${apiRoot}`);
721
-
722
- await generateOpenApiClient();
723
-
724
- await fixGeneratedMixedImports(generatedDir);
725
-
726
- await createTypesFile();
727
- await createMethodFiles();
728
-
729
- console.log("API generation completed.");
730
- }
731
-
732
- main().catch((error) => {
733
- console.error(error);
734
- process.exit(1);
735
- });
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { generateApi } from "swagger-typescript-api";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ const runtimePackageJsonPath = path.resolve(__dirname, "../package.json");
10
+ const runtimePackageJson = JSON.parse(
11
+ fs.readFileSync(runtimePackageJsonPath, "utf8"),
12
+ );
13
+
14
+ const consumerPackageJsonPath = path.resolve(process.cwd(), "package.json");
15
+ const consumerPackageJson = fs.existsSync(consumerPackageJsonPath)
16
+ ? JSON.parse(fs.readFileSync(consumerPackageJsonPath, "utf8"))
17
+ : {};
18
+
19
+ const isRunningInsidePackage =
20
+ path.resolve(process.cwd()) === path.resolve(__dirname, "..");
21
+
22
+ const runtimePackageName = isRunningInsidePackage
23
+ ? "../.."
24
+ : runtimePackageJson.name;
25
+
26
+ const cwd = process.cwd();
27
+
28
+ const config = {
29
+ ...(runtimePackageJson.config ?? {}),
30
+ ...(consumerPackageJson.config ?? {}),
31
+ };
32
+
33
+ const swaggerFile =
34
+ process.env.SWAGGER_FILE ??
35
+ process.env.npm_config_swagger_file ??
36
+ config.swaggerFile;
37
+
38
+ const swaggerUrl =
39
+ process.env.SWAGGER_URL ??
40
+ process.env.npm_config_swagger_url ??
41
+ config.swaggerUrl ??
42
+ "https://localhost:7000/swagger/v1/swagger.json";
43
+
44
+ const swaggerInput = swaggerFile ? path.resolve(cwd, swaggerFile) : swaggerUrl;
45
+
46
+ const apiRoot = path.resolve(
47
+ cwd,
48
+ process.env.API_OUTPUT ??
49
+ process.env.npm_config_api_output ??
50
+ config.apiOutput ??
51
+ "src/api",
52
+ );
53
+ const generatedDir = path.join(apiRoot, "generated");
54
+ const methodsDir = path.join(apiRoot, "methods");
55
+
56
+ const useTypeOnlyImports =
57
+ process.env.TYPED_API_USE_TYPE_ONLY_IMPORTS === "true" ||
58
+ process.env.npm_config_typed_api_use_type_only_imports === "true" ||
59
+ config.typedApiUseTypeOnlyImports === true;
60
+
61
+ const useFilterFormValues =
62
+ process.env.TYPED_API_USE_FILTER_FORM_VALUES === "true" ||
63
+ process.env.npm_config_typed_api_use_filter_form_values === "true" ||
64
+ config.typedApiUseFilterFormValues === true;
65
+
66
+ function pascalCase(value) {
67
+ return value
68
+ .split(/[-_]/)
69
+ .filter(Boolean)
70
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
71
+ .join("");
72
+ }
73
+
74
+ function toQueryName(methodName) {
75
+ return `${pascalCase(methodName)}Query`;
76
+ }
77
+
78
+ function cleanDirectory(directory) {
79
+ if (fs.existsSync(directory)) {
80
+ fs.rmSync(directory, { recursive: true, force: true });
81
+ }
82
+
83
+ fs.mkdirSync(directory, { recursive: true });
84
+ }
85
+
86
+ async function writeFormattedFile(filePath, content) {
87
+ fs.writeFileSync(filePath, content);
88
+ }
89
+
90
+ async function generateOpenApiClient() {
91
+ cleanDirectory(generatedDir);
92
+ cleanDirectory(methodsDir);
93
+ fs.mkdirSync(methodsDir, { recursive: true });
94
+
95
+ const generateOptions = {
96
+ name: "Api.ts",
97
+ output: generatedDir,
98
+ httpClientType: "fetch",
99
+ modular: true,
100
+ cleanOutput: true,
101
+ generateClient: true,
102
+ generateRouteTypes: true,
103
+ extractRequestParams: true,
104
+ extractRequestBody: true,
105
+ moduleNameFirstTag: true,
106
+ };
107
+
108
+ if (swaggerFile) {
109
+ generateOptions.input = swaggerInput;
110
+ } else {
111
+ generateOptions.url = swaggerInput;
112
+ }
113
+
114
+ await generateApi(generateOptions);
115
+ }
116
+
117
+ function createImportStatement({ names, from, typeOnly = false }) {
118
+ if (!names || names.length === 0) {
119
+ return "";
120
+ }
121
+
122
+ const importKeyword = typeOnly ? "import type" : "import";
123
+
124
+ return `${importKeyword} {
125
+ ${names.map((name) => ` ${name},`).join("\n")}
126
+ } from "${from}";`;
127
+ }
128
+
129
+ function getAllTsFiles(dir) {
130
+ let results = [];
131
+
132
+ if (!fs.existsSync(dir)) {
133
+ return results;
134
+ }
135
+
136
+ const list = fs.readdirSync(dir);
137
+
138
+ for (const file of list) {
139
+ const filePath = path.join(dir, file);
140
+ const stat = fs.statSync(filePath);
141
+
142
+ if (stat.isDirectory()) {
143
+ results = results.concat(getAllTsFiles(filePath));
144
+ } else if (filePath.endsWith(".ts")) {
145
+ results.push(filePath);
146
+ }
147
+ }
148
+
149
+ return results;
150
+ }
151
+
152
+ async function convertGeneratedUploadMethods(apiDir) {
153
+ const tsFiles = getAllTsFiles(apiDir);
154
+ let changedMethods = 0;
155
+
156
+ console.log(
157
+ `Checking ${tsFiles.length} generated TypeScript files for multipart methods...`,
158
+ );
159
+
160
+ for (const filePath of tsFiles) {
161
+ if (
162
+ filePath.endsWith("data-contracts.ts") ||
163
+ filePath.endsWith("http-client.ts")
164
+ ) {
165
+ continue;
166
+ }
167
+
168
+ const originalContent = fs.readFileSync(filePath, "utf8");
169
+
170
+ const methodRegex =
171
+ /(\b\w+\s*=\s*\(\s*data\s*:\s*)([^,\n]+)(,\s*params\s*:\s*RequestParams\s*=\s*\{\}\s*,?\s*\)\s*=>\s*this\.request<[\s\S]*?type:\s*ContentType\.FormData,[\s\S]*?\}\);)/g;
172
+
173
+ let fileChanges = 0;
174
+
175
+ const updatedContent = originalContent.replace(
176
+ methodRegex,
177
+ (_match, start, currentType, rest) => {
178
+ console.log(
179
+ ` Replacing multipart payload type "${currentType.trim()}" with FormData`,
180
+ );
181
+
182
+ fileChanges++;
183
+ changedMethods++;
184
+
185
+ return `${start}FormData${rest}`;
186
+ },
187
+ );
188
+
189
+ if (fileChanges > 0) {
190
+ await writeFormattedFile(filePath, updatedContent);
191
+
192
+ console.log(
193
+ `Updated ${fileChanges} multipart method(s) in ${path.relative(
194
+ process.cwd(),
195
+ filePath,
196
+ )}`,
197
+ );
198
+ }
199
+ }
200
+
201
+ if (changedMethods === 0) {
202
+ throw new Error(
203
+ "No multipart methods were converted. Check the generated method format and output directory.",
204
+ );
205
+ }
206
+
207
+ console.log(`Converted ${changedMethods} multipart method(s) to FormData.`);
208
+ }
209
+
210
+ function isMultipartMethod(source, methodName) {
211
+ const startPattern = new RegExp(`\\b${methodName}\\s*=\\s*\\(`);
212
+
213
+ const startMatch = startPattern.exec(source);
214
+
215
+ if (!startMatch) {
216
+ return false;
217
+ }
218
+
219
+ const nextMethodPattern = /^\s*\w+\s*=\s*\(/gm;
220
+ nextMethodPattern.lastIndex = startMatch.index + startMatch[0].length;
221
+
222
+ const nextMatch = nextMethodPattern.exec(source);
223
+ const endIndex = nextMatch?.index ?? source.length;
224
+
225
+ const methodSource = source.slice(startMatch.index, endIndex);
226
+
227
+ return methodSource.includes("type: ContentType.FormData");
228
+ }
229
+
230
+ function getGeneratedMethods(source) {
231
+ const methodRegex = /^\s*(\w+)\s*=\s*\(/gm;
232
+ const matches = [...source.matchAll(methodRegex)];
233
+
234
+ return matches.map((match, index) => {
235
+ const methodName = match[1];
236
+ const startIndex = match.index ?? 0;
237
+ const endIndex =
238
+ index + 1 < matches.length ? matches[index + 1].index : source.length;
239
+
240
+ const methodSource = source.slice(startIndex, endIndex);
241
+
242
+ return {
243
+ methodName,
244
+ methodSource,
245
+ isFormData: methodSource.includes("type: ContentType.FormData"),
246
+ };
247
+ });
248
+ }
249
+
250
+ async function createMethodFiles() {
251
+ const generatedFiles = fs
252
+ .readdirSync(generatedDir)
253
+ .filter(
254
+ (file) =>
255
+ file.endsWith(".ts") &&
256
+ file !== "http-client.ts" &&
257
+ file !== "data-contracts.ts" &&
258
+ !file.endsWith("Route.ts") &&
259
+ !file.includes(".api"),
260
+ );
261
+
262
+ const methodExports = [];
263
+
264
+ for (const file of generatedFiles) {
265
+ const filePath = path.join(generatedDir, file);
266
+ const source = fs.readFileSync(filePath, "utf8");
267
+
268
+ const baseName = file.replace(".ts", "");
269
+ const className = baseName.split("-").map(pascalCase).join("");
270
+
271
+ const instanceName = `${className.charAt(0).toLowerCase()}${className.slice(1)}Api`;
272
+
273
+ const paginatedMethods = [];
274
+ const simpleQueryMethods = [];
275
+ const nonQueryMethods = [];
276
+
277
+ const generatedMethods = getGeneratedMethods(source);
278
+
279
+ for (const generatedMethod of generatedMethods) {
280
+ const { methodName, methodSource, isFormData } = generatedMethod;
281
+
282
+ const signatureRegex = new RegExp(
283
+ `${methodName}\\s*=\\s*\\(([^)]*)\\)\\s*=>`,
284
+ "m",
285
+ );
286
+
287
+ const signatureMatch = methodSource.match(signatureRegex);
288
+
289
+ if (!signatureMatch) {
290
+ continue;
291
+ }
292
+
293
+ const params = signatureMatch[1].trim();
294
+
295
+ const returnTypeMatch = methodSource.match(/this\.request<([^,>]+)/m);
296
+
297
+ const returnType = returnTypeMatch?.[1] ?? "";
298
+
299
+ const isPaginated =
300
+ returnType.includes("ApiPaginationResponse") ||
301
+ returnType.trim().endsWith("[]");
302
+
303
+ if (params.startsWith("query")) {
304
+ if (isPaginated) {
305
+ paginatedMethods.push(methodName);
306
+ } else {
307
+ simpleQueryMethods.push(methodName);
308
+ }
309
+
310
+ continue;
311
+ }
312
+
313
+ nonQueryMethods.push({
314
+ methodName,
315
+ isFormData,
316
+ });
317
+ }
318
+
319
+ const queryTypes = [...paginatedMethods, ...simpleQueryMethods]
320
+ .map((methodName) =>
321
+ `
322
+ export type ${toQueryName(methodName)} =
323
+ NonNullable<
324
+ Parameters<${className}["${methodName}"]>[0]
325
+ >;
326
+ `.trim(),
327
+ )
328
+ .join("\n\n");
329
+
330
+ const hasPaginatedMethods = paginatedMethods.length > 0;
331
+
332
+ const hasNonQueryMethods = nonQueryMethods.length > 0;
333
+
334
+ const hasFormDataMethods = nonQueryMethods.some(
335
+ ({ isFormData }) => isFormData,
336
+ );
337
+
338
+ const hasRegularNonQueryMethods = nonQueryMethods.some(
339
+ ({ isFormData }) => !isFormData,
340
+ );
341
+
342
+ const paginatedMethodWrappers = paginatedMethods
343
+ .map((methodName) => {
344
+ if (useFilterFormValues) {
345
+ return `
346
+ export async function ${methodName}(
347
+ filters: FilterFormValues<${toQueryName(methodName)}>[] = [],
348
+ page = 1,
349
+ pageSize = 100,
350
+ sortBy: SortableKeys<
351
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>
352
+ > | null = null,
353
+ sortDirection?: SortDirection,
354
+ onSuccess?: ApiSuccessHandler<
355
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>
356
+ >,
357
+ onError?: ApiErrorHandler,
358
+ params?: RequestParams
359
+ ): Promise<ApiResult<ExtractResponse<ReturnType<${className}["${methodName}"]>>>> {
360
+ return handleApiResponse(
361
+ () =>
362
+ ${instanceName}.${methodName}(
363
+ buildQuery<
364
+ ${toQueryName(methodName)},
365
+ UnwrapArray<
366
+ ExtractDataIfPaginated<
367
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>
368
+ >
369
+ >
370
+ >(filters, page, pageSize, sortBy, sortDirection),
371
+ params ?? {}
372
+ ),
373
+ { onSuccess, onError }
374
+ );
375
+ }
376
+ `.trim();
377
+ }
378
+
379
+ return `
380
+ export async function ${methodName}(
381
+ query?: ${toQueryName(methodName)},
382
+ onSuccess?: ApiSuccessHandler<
383
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>
384
+ >,
385
+ onError?: ApiErrorHandler,
386
+ params?: RequestParams
387
+ ): Promise<ApiResult<ExtractResponse<ReturnType<${className}["${methodName}"]>>>> {
388
+ return handleApiResponse(
389
+ () => ${instanceName}.${methodName}(query, params ?? {}),
390
+ { onSuccess, onError }
391
+ );
392
+ }
393
+ `.trim();
394
+ })
395
+ .join("\n\n");
396
+
397
+ const simpleQueryMethodWrappers = simpleQueryMethods
398
+ .map((methodName) =>
399
+ `
400
+ export async function ${methodName}(
401
+ query?: ${toQueryName(methodName)},
402
+ onSuccess?: ApiSuccessHandler<
403
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>
404
+ >,
405
+ onError?: ApiErrorHandler,
406
+ params?: RequestParams
407
+ ): Promise<
408
+ ApiResult<
409
+ ExtractResponse<
410
+ ReturnType<
411
+ ${className}["${methodName}"]
412
+ >
413
+ >
414
+ >
415
+ > {
416
+ return handleApiResponse(
417
+ () =>
418
+ ${instanceName}.${methodName}(query, params ?? {}),
419
+ { onSuccess, onError }
420
+ );
421
+ }
422
+ `.trim(),
423
+ )
424
+ .join("\n\n");
425
+
426
+ const nonQueryMethodWrappers = nonQueryMethods
427
+ .map(({ methodName, isFormData }) => {
428
+ if (isFormData) {
429
+ return `
430
+ export async function ${methodName}(
431
+ data: Parameters<
432
+ ${className}["${methodName}"]
433
+ >[0],
434
+ onSuccess?: ApiSuccessHandler<
435
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>
436
+ >,
437
+ onError?: ApiErrorHandler,
438
+ params?: RequestParams
439
+ ): Promise<
440
+ ApiResult<
441
+ ExtractResponse<
442
+ ReturnType<
443
+ ${className}["${methodName}"]
444
+ >
445
+ >
446
+ >
447
+ > {
448
+ const formData = toFormData(data);
449
+
450
+ return handleApiResponse(
451
+ () =>
452
+ ${instanceName}.${methodName}(
453
+ formData as unknown as Parameters<
454
+ ${className}["${methodName}"]
455
+ >[0],
456
+ params ?? {}
457
+ ),
458
+ { onSuccess, onError }
459
+ );
460
+ }
461
+ `.trim();
462
+ }
463
+
464
+ return `
465
+ export async function ${methodName}(
466
+ ...argsWithCallbacks: [
467
+ ...ApiMethodArguments<
468
+ ${className}["${methodName}"]
469
+ >,
470
+ ApiSuccessHandler<
471
+ ExtractResponse<
472
+ ReturnType<
473
+ ${className}["${methodName}"]
474
+ >
475
+ >
476
+ >?,
477
+ ApiErrorHandler?,
478
+ RequestParams?
479
+ ]
480
+ ): Promise<
481
+ ApiResult<
482
+ ExtractResponse<
483
+ ReturnType<
484
+ ${className}["${methodName}"]
485
+ >
486
+ >
487
+ >
488
+ > {
489
+ const {
490
+ args,
491
+ onSuccess,
492
+ onError,
493
+ params
494
+ } = extractArgsCallbacksAndParams<
495
+ ApiMethodArguments<
496
+ ${className}["${methodName}"]
497
+ >,
498
+ ExtractResponse<
499
+ ReturnType<
500
+ ${className}["${methodName}"]
501
+ >
502
+ >
503
+ >(argsWithCallbacks);
504
+
505
+ const requestArgs = [
506
+ ...args,
507
+ params ?? {}
508
+ ] as unknown as Parameters<
509
+ ${className}["${methodName}"]
510
+ >;
511
+
512
+ return handleApiResponse(
513
+ () =>
514
+ ${instanceName}.${methodName}(
515
+ ...requestArgs
516
+ ),
517
+ {
518
+ onSuccess,
519
+ onError
520
+ }
521
+ );
522
+ }
523
+ `.trim();
524
+ })
525
+ .join("\n\n");
526
+
527
+ const runtimeValueImportNames = [
528
+ ...(hasPaginatedMethods && useFilterFormValues ? ["buildQuery"] : []),
529
+ ...(hasRegularNonQueryMethods ? ["extractArgsCallbacksAndParams"] : []),
530
+ ...(hasFormDataMethods ? ["toFormData"] : []),
531
+ "handleApiResponse",
532
+ ];
533
+ const runtimeTypeImportNames = [
534
+ "ApiResult",
535
+ "ApiSuccessHandler",
536
+ "ApiErrorHandler",
537
+ "ExtractResponse",
538
+
539
+ ...(hasPaginatedMethods && useFilterFormValues
540
+ ? [
541
+ "ExtractDataIfPaginated",
542
+ "FilterFormValues",
543
+ "SortableKeys",
544
+ "SortDirection",
545
+ "UnwrapArray",
546
+ ]
547
+ : []),
548
+ ];
549
+
550
+ const runtimeValueImports = createImportStatement({
551
+ names: runtimeValueImportNames,
552
+ from: runtimePackageName,
553
+ });
554
+
555
+ const runtimeTypeImports = createImportStatement({
556
+ names: runtimeTypeImportNames,
557
+ from: runtimePackageName,
558
+ typeOnly: useTypeOnlyImports,
559
+ });
560
+
561
+ const generatedValueImports = createImportStatement({
562
+ names: [className],
563
+ from: `../generated/${baseName}`,
564
+ });
565
+
566
+ const generatedTypeImports =
567
+ generatedMethods.length > 0
568
+ ? createImportStatement({
569
+ names: ["RequestParams"],
570
+ from: "../generated/http-client",
571
+ typeOnly: useTypeOnlyImports,
572
+ })
573
+ : "";
574
+
575
+ const apiMethodArgumentsType = hasRegularNonQueryMethods
576
+ ? `
577
+ type ApiMethodArguments<
578
+ TMethod extends (...args: any[]) => unknown
579
+ > =
580
+ Parameters<TMethod> extends [
581
+ ...infer Arguments,
582
+ unknown?
583
+ ]
584
+ ? Arguments
585
+ : Parameters<TMethod>;
586
+ `.trim()
587
+ : "";
588
+
589
+ const content = `
590
+ ${runtimeValueImports}
591
+
592
+ ${runtimeTypeImports}
593
+
594
+ ${generatedValueImports}
595
+
596
+ ${generatedTypeImports}
597
+
598
+ ${apiMethodArgumentsType}
599
+
600
+ /* =======================
601
+ Query Types
602
+ ======================= */
603
+ ${queryTypes}
604
+
605
+ /* =======================
606
+ API Instance
607
+ ======================= */
608
+ const ${instanceName} = new ${className}();
609
+
610
+ /* =======================
611
+ Paginated Query Methods
612
+ ======================= */
613
+ ${paginatedMethodWrappers}
614
+
615
+ /* =======================
616
+ Simple Query Methods
617
+ ======================= */
618
+ ${simpleQueryMethodWrappers}
619
+
620
+ /* =======================
621
+ Non-Query Methods
622
+ ======================= */
623
+ ${nonQueryMethodWrappers}
624
+ `.trim();
625
+
626
+ const outputFile = path.join(methodsDir, `${className}.api.ts`);
627
+
628
+ await writeFormattedFile(outputFile, content);
629
+
630
+ methodExports.push(`export * from "./methods/${className}.api";`);
631
+ }
632
+
633
+ const apiIndexContent = `
634
+ export * from "./generated/data-contracts";
635
+ export * from "./generated/http-client";
636
+ ${methodExports.join("\n")}
637
+ `.trim();
638
+
639
+ await writeFormattedFile(path.join(apiRoot, "index.ts"), apiIndexContent);
640
+ }
641
+
642
+ async function fixGeneratedMixedImports(apiDir) {
643
+ const tsFiles = getAllTsFiles(apiDir);
644
+
645
+ for (const filePath of tsFiles) {
646
+ if (
647
+ filePath.endsWith("http-client.ts") ||
648
+ filePath.endsWith("data-contracts.ts")
649
+ ) {
650
+ continue;
651
+ }
652
+
653
+ const originalContent = fs.readFileSync(filePath, "utf8");
654
+ let content = originalContent;
655
+
656
+ /*
657
+ * Converts:
658
+ *
659
+ * import { ContentType, HttpClient, RequestParams }
660
+ * from "./http-client";
661
+ *
662
+ * into separate runtime and type imports.
663
+ */
664
+ content = content.replace(
665
+ /import\s*\{([\s\S]*?)\}\s*from\s*["']\.\/http-client["'];/g,
666
+ (_match, importsText) => {
667
+ const importedNames = importsText
668
+ .split(",")
669
+ .map((name) => name.trim())
670
+ .filter(Boolean);
671
+
672
+ const typeNames = importedNames.filter(
673
+ (name) => name === "RequestParams",
674
+ );
675
+
676
+ const valueNames = importedNames.filter(
677
+ (name) => name !== "RequestParams",
678
+ );
679
+
680
+ const statements = [];
681
+
682
+ if (valueNames.length > 0) {
683
+ statements.push(
684
+ `import { ${valueNames.join(", ")} } from "./http-client";`,
685
+ );
686
+ }
687
+
688
+ if (typeNames.length > 0 && useTypeOnlyImports) {
689
+ statements.push(
690
+ `import type { ${typeNames.join(", ")} } from "./http-client";`,
691
+ );
692
+ } else if (typeNames.length > 0) {
693
+ statements.push(
694
+ `import { ${typeNames.join(", ")} } from "./http-client";`,
695
+ );
696
+ }
697
+
698
+ return statements.join("\n");
699
+ },
700
+ );
701
+
702
+ /*
703
+ * Everything imported from data-contracts by generated API classes
704
+ * is normally used as a TypeScript type.
705
+ */
706
+ if (useTypeOnlyImports) {
707
+ content = content.replace(
708
+ /import\s*\{([\s\S]*?)\}\s*from\s*["']\.\/data-contracts["'];/g,
709
+ (_match, importsText) =>
710
+ `import type {${importsText}} from "./data-contracts";`,
711
+ );
712
+ }
713
+
714
+ if (content !== originalContent) {
715
+ await writeFormattedFile(filePath, content);
716
+
717
+ console.log(
718
+ `Fixed mixed imports in ${path.relative(process.cwd(), filePath)}`,
719
+ );
720
+ }
721
+ }
722
+ }
723
+
724
+ async function main() {
725
+ console.log(`Generating API from: ${swaggerInput}`);
726
+ console.log(`Output folder: ${apiRoot}`);
727
+
728
+ await generateOpenApiClient();
729
+
730
+ await fixGeneratedMixedImports(generatedDir);
731
+
732
+ await createMethodFiles();
733
+
734
+ console.log("API generation completed.");
735
+ }
736
+
737
+ main().catch((error) => {
738
+ console.error(error);
739
+ process.exit(1);
740
+ });