typedapi-client-helpers 0.1.14 → 0.1.16

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,959 +1,1103 @@
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
-
43
- const swaggerInput = swaggerFile
44
- ? path.resolve(cwd, swaggerFile)
45
- : (swaggerUrl ?? path.resolve(cwd, "swagger/swagger.json"));
46
-
47
- const apiRoot = path.resolve(
48
- cwd,
49
- process.env.API_OUTPUT ??
50
- process.env.npm_config_api_output ??
51
- config.apiOutput ??
52
- "src/api",
53
- );
54
-
55
- const generatedDir = path.join(apiRoot, "generated");
56
- const methodsDir = path.join(apiRoot, "methods");
57
-
58
- const useTypeOnlyImports =
59
- process.env.TYPED_API_USE_TYPE_ONLY_IMPORTS === "true" ||
60
- process.env.npm_config_typed_api_use_type_only_imports === "true" ||
61
- config.typedApiUseTypeOnlyImports === true;
62
-
63
- const useFilterFormValues =
64
- process.env.TYPED_API_USE_FILTER_FORM_VALUES === "true" ||
65
- process.env.npm_config_typed_api_use_filter_form_values === "true" ||
66
- config.typedApiUseFilterFormValues === true;
67
-
68
- function getStringSetting(...values) {
69
- for (const value of values) {
70
- if (typeof value === "string" && value.trim() !== "") {
71
- return value.trim();
72
- }
73
- }
74
-
75
- return undefined;
76
- }
77
-
78
- const fallbackDefaultFunctionsPath = "defaultApiFunctions";
79
-
80
- const defaultFunctionsPathSetting = getStringSetting(
81
- process.env.TYPED_API_DEFAULT_FUNCTIONS_PATH,
82
- process.env.npm_config_typed_api_default_functions_path,
83
- config.typedApiDefaultFunctionsPath,
84
- fallbackDefaultFunctionsPath,
85
- );
86
-
87
- const defaultFunctionsFilePath = path.resolve(cwd, defaultFunctionsPathSetting);
88
-
89
- const defaultSuccessHandlerName = getStringSetting(
90
- process.env.TYPED_API_DEFAULT_SUCCESS_HANDLER,
91
- process.env.npm_config_typed_api_default_success_handler,
92
- config.typedApiDefaultSuccessHandler,
93
- "handleGoodResult",
94
- );
95
-
96
- const defaultErrorHandlerName = getStringSetting(
97
- process.env.TYPED_API_DEFAULT_ERROR_HANDLER,
98
- process.env.npm_config_typed_api_default_error_handler,
99
- config.typedApiDefaultErrorHandler,
100
- "handleErrors",
101
- );
102
-
103
- function pascalCase(value) {
104
- return value
105
- .split(/[-_]/)
106
- .filter(Boolean)
107
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
108
- .join("");
109
- }
110
-
111
- function toQueryName(methodName) {
112
- return `${pascalCase(methodName)}Query`;
113
- }
114
-
115
- function cleanDirectory(directory) {
116
- if (fs.existsSync(directory)) {
117
- fs.rmSync(directory, { recursive: true, force: true });
118
- }
119
-
120
- fs.mkdirSync(directory, { recursive: true });
121
- }
122
-
123
- async function writeFormattedFile(filePath, content) {
124
- fs.writeFileSync(filePath, content);
125
- }
126
-
127
- function removeGeneratedRouteFiles() {
128
- const files = fs.readdirSync(generatedDir);
129
-
130
- for (const file of files) {
131
- if (/route\.ts$/i.test(file)) {
132
- fs.rmSync(path.join(generatedDir, file), { force: true });
133
- }
134
- }
135
- }
136
-
137
- async function generateOpenApiClient() {
138
- cleanDirectory(generatedDir);
139
- cleanDirectory(methodsDir);
140
- fs.mkdirSync(methodsDir, { recursive: true });
141
-
142
- const generateOptions = {
143
- name: "Api.ts",
144
- output: generatedDir,
145
- httpClientType: "fetch",
146
- modular: true,
147
- cleanOutput: true,
148
- generateClient: true,
149
- generateRouteTypes: true,
150
- extractRequestParams: true,
151
- extractRequestBody: true,
152
- moduleNameFirstTag: true,
153
- };
154
-
155
- if (swaggerFile) {
156
- generateOptions.input = swaggerInput;
157
- } else {
158
- generateOptions.url = swaggerInput;
159
- }
160
-
161
- await generateApi(generateOptions);
162
-
163
- removeGeneratedRouteFiles();
164
- }
165
-
166
- function createImportStatement({ names, from, typeOnly = false }) {
167
- if (!names || names.length === 0) {
168
- return "";
169
- }
170
-
171
- const importKeyword = typeOnly ? "import type" : "import";
172
-
173
- return `${importKeyword} {
174
- ${names.map((name) => ` ${name},`).join("\n")}
175
- } from "${from}";`;
176
- }
177
-
178
- function createAliasedImportStatement({ imports, from }) {
179
- if (!imports || imports.length === 0) {
180
- return "";
181
- }
182
-
183
- return `import {
184
- ${imports
185
- .map(({ name, alias }) =>
186
- alias && alias !== name ? ` ${name} as ${alias},` : ` ${name},`,
187
- )
188
- .join("\n")}
189
- } from "${from}";`;
190
- }
191
-
192
- function resolveImportFilePath(fromDirectory, importPath) {
193
- if (!importPath.startsWith(".")) {
194
- return undefined;
195
- }
196
-
197
- const basePath = path.resolve(fromDirectory, importPath);
198
- const candidates = [
199
- basePath,
200
- `${basePath}.ts`,
201
- `${basePath}.tsx`,
202
- `${basePath}.js`,
203
- `${basePath}.jsx`,
204
- path.join(basePath, "index.ts"),
205
- path.join(basePath, "index.tsx"),
206
- path.join(basePath, "index.js"),
207
- path.join(basePath, "index.jsx"),
208
- ];
209
-
210
- return candidates.find((candidate) => fs.existsSync(candidate));
211
- }
212
-
213
- function createDefaultFunctionsFileIfMissing(filePath) {
214
- const extension = path.extname(filePath);
215
- const resolvedFilePath = extension ? filePath : `${filePath}.ts`;
216
-
217
- if (fs.existsSync(resolvedFilePath)) {
218
- return resolvedFilePath;
219
- }
220
-
221
- fs.mkdirSync(path.dirname(resolvedFilePath), { recursive: true });
222
-
223
- fs.writeFileSync(
224
- resolvedFilePath,
225
- `
226
- ${createImportStatement({
227
- names: ["ApiErrorResult", "ApiSuccessResult"],
228
- from: "typedapi-client-helpers",
229
- typeOnly: true,
230
- })}
231
-
232
- export function ${defaultSuccessHandlerName}<T>(
233
- _response: ApiSuccessResult<T>,
234
- ): void | Promise<void> {
235
- // Add your default success handling here.
236
- }
237
-
238
- export function ${defaultErrorHandlerName}<T>(
239
- _error: ApiErrorResult<T>,
240
- ): void | Promise<void> {
241
- // Add your default error handling here.
242
- }
243
- `,
244
- );
245
-
246
- console.log(
247
- `Created default API handler file: ${path.relative(process.cwd(), resolvedFilePath)}`,
248
- );
249
-
250
- return resolvedFilePath;
251
- }
252
-
253
- function toRelativeImportPath(fromDirectory, filePath) {
254
- let relativePath = path.relative(fromDirectory, filePath);
255
-
256
- if (!relativePath.startsWith(".")) {
257
- relativePath = `./${relativePath}`;
258
- }
259
-
260
- relativePath = relativePath.replaceAll(path.sep, "/");
261
-
262
- return relativePath.replace(/\.(ts|tsx|js|jsx)$/, "");
263
- }
264
-
265
- function escapeRegExp(value) {
266
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
267
- }
268
-
269
- function fileExportsName(source, exportName) {
270
- const escapedName = escapeRegExp(exportName);
271
-
272
- const directExportPattern = new RegExp(
273
- `export\\s+(?:async\\s+)?(?:function|const|let|var|class)\\s+${escapedName}\\b`,
274
- "m",
275
- );
276
-
277
- if (directExportPattern.test(source)) {
278
- return true;
279
- }
280
-
281
- const namedExportBlocks = source.matchAll(/export\s*\{([\s\S]*?)\}/g);
282
-
283
- for (const match of namedExportBlocks) {
284
- const exportedNames = match[1]
285
- .split(",")
286
- .map((part) => part.trim())
287
- .filter(Boolean);
288
-
289
- if (
290
- exportedNames.some((part) => {
291
- const [localName, aliasName] = part
292
- .split(/\s+as\s+/)
293
- .map((name) => name.trim());
294
-
295
- return (
296
- aliasName === exportName || (!aliasName && localName === exportName)
297
- );
298
- })
299
- ) {
300
- return true;
301
- }
302
- }
303
-
304
- return false;
305
- }
306
-
307
- function resolveDefaultHandlers(importFromDirectory) {
308
- const defaultFunctionsFile =
309
- resolveImportFilePath(importFromDirectory, defaultFunctionsFilePath) ??
310
- createDefaultFunctionsFileIfMissing(defaultFunctionsFilePath);
311
-
312
- const source = fs.readFileSync(defaultFunctionsFile, "utf8");
313
- const hasSuccessHandler = fileExportsName(source, defaultSuccessHandlerName);
314
- const hasErrorHandler = fileExportsName(source, defaultErrorHandlerName);
315
-
316
- if (!hasSuccessHandler || !hasErrorHandler) {
317
- const missingNames = [
318
- ...(!hasSuccessHandler ? [defaultSuccessHandlerName] : []),
319
- ...(!hasErrorHandler ? [defaultErrorHandlerName] : []),
320
- ];
321
-
322
- console.warn(
323
- `Default API handlers were not imported because ${path.relative(
324
- process.cwd(),
325
- defaultFunctionsFile,
326
- )} does not export: ${missingNames.join(", ")}.`,
327
- );
328
-
329
- return undefined;
330
- }
331
-
332
- return {
333
- defaultFunctionsPath: toRelativeImportPath(
334
- importFromDirectory,
335
- defaultFunctionsFile,
336
- ),
337
- defaultSuccessHandlerName,
338
- defaultErrorHandlerName,
339
- };
340
- }
341
-
342
- function callbackOptionsExpression(defaultHandlers) {
343
- if (defaultHandlers) {
344
- return `{
345
- onSuccess: onSuccess ?? typedApiDefaultSuccessHandler,
346
- onError: onError ?? typedApiDefaultErrorHandler
347
- }`;
348
- }
349
-
350
- return `{
351
- onSuccess,
352
- onError
353
- }`;
354
- }
355
-
356
- function getAllTsFiles(dir) {
357
- let results = [];
358
-
359
- if (!fs.existsSync(dir)) {
360
- return results;
361
- }
362
-
363
- const list = fs.readdirSync(dir);
364
-
365
- for (const file of list) {
366
- const filePath = path.join(dir, file);
367
- const stat = fs.statSync(filePath);
368
-
369
- if (stat.isDirectory()) {
370
- results = results.concat(getAllTsFiles(filePath));
371
- } else if (filePath.endsWith(".ts")) {
372
- results.push(filePath);
373
- }
374
- }
375
-
376
- return results;
377
- }
378
-
379
- function getGeneratedMethods(source) {
380
- const methodRegex = /^\s*(\w+)\s*=\s*\(/gm;
381
- const matches = [...source.matchAll(methodRegex)];
382
-
383
- return matches.map((match, index) => {
384
- const methodName = match[1];
385
- const startIndex = match.index ?? 0;
386
- const endIndex =
387
- index + 1 < matches.length ? matches[index + 1].index : source.length;
388
-
389
- const methodSource = source.slice(startIndex, endIndex);
390
-
391
- return {
392
- methodName,
393
- methodSource,
394
- isFormData: methodSource.includes("type: ContentType.FormData"),
395
- };
396
- });
397
- }
398
-
399
- function splitTopLevelArguments(value) {
400
- const args = [];
401
- let current = "";
402
- let depth = 0;
403
- let quote = "";
404
-
405
- for (let index = 0; index < value.length; index++) {
406
- const char = value[index];
407
- const previousChar = value[index - 1];
408
-
409
- if (quote) {
410
- current += char;
411
-
412
- if (char === quote && previousChar !== "\\") {
413
- quote = "";
414
- }
415
-
416
- continue;
417
- }
418
-
419
- if (char === '"' || char === "'" || char === "`") {
420
- quote = char;
421
- current += char;
422
- continue;
423
- }
424
-
425
- if (char === "(" || char === "{" || char === "[" || char === "<") {
426
- depth++;
427
- current += char;
428
- continue;
429
- }
430
-
431
- if (char === ")" || char === "}" || char === "]" || char === ">") {
432
- depth--;
433
- current += char;
434
- continue;
435
- }
436
-
437
- if (char === "," && depth === 0) {
438
- if (current.trim()) {
439
- args.push(current.trim());
440
- }
441
-
442
- current = "";
443
- continue;
444
- }
445
-
446
- current += char;
447
- }
448
-
449
- if (current.trim()) {
450
- args.push(current.trim());
451
- }
452
-
453
- return args;
454
- }
455
-
456
- function getGeneratedMethodArgumentCount(signatureParams) {
457
- const args = splitTopLevelArguments(signatureParams);
458
-
459
- if (args.length === 0) {
460
- return 0;
461
- }
462
-
463
- const lastArg = args[args.length - 1];
464
-
465
- if (/\bparams\s*:\s*RequestParams\b/.test(lastArg)) {
466
- return args.length - 1;
467
- }
468
-
469
- return args.length;
470
- }
471
-
472
- async function createMethodFiles() {
473
- const defaultHandlers = resolveDefaultHandlers(methodsDir);
474
-
475
- const generatedFiles = fs
476
- .readdirSync(generatedDir)
477
- .filter(
478
- (file) =>
479
- file.endsWith(".ts") &&
480
- file !== "http-client.ts" &&
481
- file !== "data-contracts.ts" &&
482
- !/route\.ts$/i.test(file) &&
483
- !file.includes(".api"),
484
- );
485
-
486
- const methodExports = [];
487
-
488
- for (const file of generatedFiles) {
489
- const filePath = path.join(generatedDir, file);
490
- const source = fs.readFileSync(filePath, "utf8");
491
-
492
- const baseName = file.replace(".ts", "");
493
- const className = baseName.split("-").map(pascalCase).join("");
494
-
495
- const instanceName = `${className.charAt(0).toLowerCase()}${className.slice(1)}Api`;
496
-
497
- const paginatedMethods = [];
498
- const simpleQueryMethods = [];
499
- const nonQueryMethods = [];
500
-
501
- const generatedMethods = getGeneratedMethods(source);
502
-
503
- for (const generatedMethod of generatedMethods) {
504
- const { methodName, methodSource, isFormData } = generatedMethod;
505
-
506
- const signatureRegex = new RegExp(
507
- `${methodName}\\s*=\\s*\\(([^)]*)\\)\\s*=>`,
508
- "m",
509
- );
510
-
511
- const signatureMatch = methodSource.match(signatureRegex);
512
-
513
- if (!signatureMatch) {
514
- continue;
515
- }
516
-
517
- const params = signatureMatch[1].trim();
518
- const methodArgumentCount = getGeneratedMethodArgumentCount(params);
519
-
520
- const returnTypeMatch = methodSource.match(/this\.request<([^,>]+)/m);
521
- const returnType = returnTypeMatch?.[1] ?? "";
522
-
523
- const isPaginated =
524
- returnType.includes("ApiPaginationResponse") ||
525
- returnType.trim().endsWith("[]");
526
-
527
- if (params.startsWith("query")) {
528
- if (isPaginated) {
529
- paginatedMethods.push(methodName);
530
- } else {
531
- simpleQueryMethods.push(methodName);
532
- }
533
-
534
- continue;
535
- }
536
-
537
- nonQueryMethods.push({
538
- methodName,
539
- isFormData,
540
- methodArgumentCount,
541
- });
542
- }
543
-
544
- const queryTypes = [...paginatedMethods, ...simpleQueryMethods]
545
- .map((methodName) =>
546
- `
547
- export type ${toQueryName(methodName)} =
548
- NonNullable<
549
- Parameters<${className}["${methodName}"]>[0]
550
- >;
551
- `.trim(),
552
- )
553
- .join("\n\n");
554
-
555
- const hasPaginatedMethods = paginatedMethods.length > 0;
556
-
557
- const hasFormDataMethods = nonQueryMethods.some(
558
- ({ isFormData }) => isFormData,
559
- );
560
-
561
- const hasRegularNonQueryMethods = nonQueryMethods.some(
562
- ({ isFormData }) => !isFormData,
563
- );
564
-
565
- const paginatedMethodWrappers = paginatedMethods
566
- .map((methodName) => {
567
- if (useFilterFormValues) {
568
- return `
569
- export async function ${methodName}(
570
- filters: FilterFormValues<${toQueryName(methodName)}>[] = [],
571
- page = 1,
572
- pageSize = 100,
573
- sortBy: SortableKeys<
574
- ExtractResponse<ReturnType<${className}["${methodName}"]>>
575
- > | null = null,
576
- sortDirection?: SortDirection,
577
- options: ApiMethodOptions<
578
- ExtractResponse<ReturnType<${className}["${methodName}"]>>,
579
- ExtractError<ReturnType<${className}["${methodName}"]>>,
580
- RequestParams
581
- > = {}
582
- ): Promise<ApiResult<ExtractResponse<ReturnType<${className}["${methodName}"]>>>> {
583
- const { onSuccess, onError, params } = options;
584
-
585
- return handleApiResponse(
586
- () =>
587
- ${instanceName}.${methodName}(
588
- buildQuery<
589
- ${toQueryName(methodName)},
590
- UnwrapArray<
591
- ExtractDataIfPaginated<
592
- ExtractResponse<ReturnType<${className}["${methodName}"]>>
593
- >
594
- >
595
- >(filters, page, pageSize, sortBy, sortDirection),
596
- params ?? {}
597
- ),
598
- ${callbackOptionsExpression(defaultHandlers)}
599
- );
600
- }
601
- `.trim();
602
- }
603
-
604
- return `
605
- export async function ${methodName}(
606
- query?: ${toQueryName(methodName)},
607
- options: ApiMethodOptions<
608
- ExtractResponse<ReturnType<${className}["${methodName}"]>>,
609
- ExtractError<ReturnType<${className}["${methodName}"]>>,
610
- RequestParams
611
- > = {}
612
- ): Promise<ApiResult<ExtractResponse<ReturnType<${className}["${methodName}"]>>>> {
613
- const { onSuccess, onError, params } = options;
614
-
615
- return handleApiResponse(
616
- () =>
617
- ${instanceName}.${methodName}(
618
- (query ?? {}) as ${toQueryName(methodName)},
619
- params ?? {}
620
- ),
621
- ${callbackOptionsExpression(defaultHandlers)}
622
- );
623
- }
624
- `.trim();
625
- })
626
- .join("\n\n");
627
-
628
- const simpleQueryMethodWrappers = simpleQueryMethods
629
- .map((methodName) =>
630
- `
631
- export async function ${methodName}(
632
- query?: ${toQueryName(methodName)},
633
- options: ApiMethodOptions<
634
- ExtractResponse<ReturnType<${className}["${methodName}"]>>,
635
- ExtractError<ReturnType<${className}["${methodName}"]>>,
636
- RequestParams
637
- > = {}
638
- ): Promise<
639
- ApiResult<
640
- ExtractResponse<
641
- ReturnType<
642
- ${className}["${methodName}"]
643
- >
644
- >
645
- >
646
- > {
647
- const { onSuccess, onError, params } = options;
648
-
649
- return handleApiResponse(
650
- () =>
651
- ${instanceName}.${methodName}(
652
- (query ?? {}) as ${toQueryName(methodName)},
653
- params ?? {}
654
- ),
655
- ${callbackOptionsExpression(defaultHandlers)}
656
- );
657
- }
658
- `.trim(),
659
- )
660
- .join("\n\n");
661
-
662
- const nonQueryMethodWrappers = nonQueryMethods
663
- .map(({ methodName, isFormData, methodArgumentCount }) => {
664
- if (isFormData) {
665
- return `
666
- export async function ${methodName}(
667
- data: Parameters<
668
- ${className}["${methodName}"]
669
- >[0],
670
- options: ApiMethodOptions<
671
- ExtractResponse<ReturnType<${className}["${methodName}"]>>,
672
- ExtractError<ReturnType<${className}["${methodName}"]>>,
673
- RequestParams
674
- > = {}
675
- ): Promise<
676
- ApiResult<
677
- ExtractResponse<
678
- ReturnType<
679
- ${className}["${methodName}"]
680
- >
681
- >
682
- >
683
- > {
684
- const { onSuccess, onError, params } = options;
685
- const formData = toFormData(data);
686
-
687
- return handleApiResponse(
688
- () =>
689
- ${instanceName}.${methodName}(
690
- formData as unknown as Parameters<
691
- ${className}["${methodName}"]
692
- >[0],
693
- params ?? {}
694
- ),
695
- ${callbackOptionsExpression(defaultHandlers)}
696
- );
697
- }
698
- `.trim();
699
- }
700
-
701
- return `
702
- export async function ${methodName}(
703
- ...argsWithOptions: [
704
- ...ApiMethodArguments<
705
- ${className}["${methodName}"],
706
- RequestParams
707
- >,
708
- ApiMethodOptions<
709
- ExtractResponse<ReturnType<${className}["${methodName}"]>>,
710
- ExtractError<ReturnType<${className}["${methodName}"]>>,
711
- RequestParams
712
- >?
713
- ]
714
- ): Promise<
715
- ApiResult<
716
- ExtractResponse<
717
- ReturnType<
718
- ${className}["${methodName}"]
719
- >
720
- >
721
- >
722
- > {
723
- const args = [...argsWithOptions] as unknown[];
724
-
725
- const options =
726
- args.length > ${methodArgumentCount}
727
- ? (args.pop() as ApiMethodOptions<
728
- ExtractResponse<ReturnType<${className}["${methodName}"]>>,
729
- ExtractError<ReturnType<${className}["${methodName}"]>>,
730
- RequestParams
731
- >)
732
- : {};
733
-
734
- const { onSuccess, onError, params } = options ?? {};
735
-
736
- const requestArgs = [
737
- ...args,
738
- params ?? {}
739
- ] as unknown as Parameters<
740
- ${className}["${methodName}"]
741
- >;
742
-
743
- return handleApiResponse(
744
- () =>
745
- ${instanceName}.${methodName}(
746
- ...requestArgs
747
- ),
748
- ${callbackOptionsExpression(defaultHandlers)}
749
- );
750
- }
751
- `.trim();
752
- })
753
- .join("\n\n");
754
-
755
- const runtimeValueImportNames = [
756
- ...(hasPaginatedMethods && useFilterFormValues ? ["buildQuery"] : []),
757
- ...(hasFormDataMethods ? ["toFormData"] : []),
758
- "handleApiResponse",
759
- ];
760
-
761
- const runtimeTypeImportNames = [
762
- "ApiResult",
763
- "ApiMethodOptions",
764
- ...(hasRegularNonQueryMethods ? ["ApiMethodArguments"] : []),
765
- "ExtractResponse",
766
- "ExtractError",
767
-
768
- ...(hasPaginatedMethods && useFilterFormValues
769
- ? [
770
- "ExtractDataIfPaginated",
771
- "FilterFormValues",
772
- "SortableKeys",
773
- "SortDirection",
774
- "UnwrapArray",
775
- ]
776
- : []),
777
- ];
778
-
779
- const runtimeValueImports = createImportStatement({
780
- names: runtimeValueImportNames,
781
- from: runtimePackageName,
782
- });
783
-
784
- const defaultFunctionImports = defaultHandlers
785
- ? createAliasedImportStatement({
786
- imports: [
787
- {
788
- name: defaultHandlers.defaultSuccessHandlerName,
789
- alias: "typedApiDefaultSuccessHandler",
790
- },
791
- {
792
- name: defaultHandlers.defaultErrorHandlerName,
793
- alias: "typedApiDefaultErrorHandler",
794
- },
795
- ],
796
- from: defaultHandlers.defaultFunctionsPath,
797
- })
798
- : "";
799
-
800
- const runtimeTypeImports = createImportStatement({
801
- names: runtimeTypeImportNames,
802
- from: runtimePackageName,
803
- typeOnly: useTypeOnlyImports,
804
- });
805
-
806
- const generatedValueImports = createImportStatement({
807
- names: [className],
808
- from: `../generated/${baseName}`,
809
- });
810
-
811
- const generatedTypeImports =
812
- generatedMethods.length > 0
813
- ? createImportStatement({
814
- names: ["RequestParams"],
815
- from: "../generated/http-client",
816
- typeOnly: useTypeOnlyImports,
817
- })
818
- : "";
819
-
820
- const content = `
821
- ${runtimeValueImports}
822
-
823
- ${defaultFunctionImports}
824
-
825
- ${runtimeTypeImports}
826
-
827
- ${generatedValueImports}
828
-
829
- ${generatedTypeImports}
830
-
831
- /* =======================
832
- Query Types
833
- ======================= */
834
- ${queryTypes}
835
-
836
- /* =======================
837
- API Instance
838
- ======================= */
839
- const ${instanceName} = new ${className}();
840
-
841
- /* =======================
842
- Paginated Query Methods
843
- ======================= */
844
- ${paginatedMethodWrappers}
845
-
846
- /* =======================
847
- Simple Query Methods
848
- ======================= */
849
- ${simpleQueryMethodWrappers}
850
-
851
- /* =======================
852
- Non-Query Methods
853
- ======================= */
854
- ${nonQueryMethodWrappers}
855
- `.trim();
856
-
857
- const outputFile = path.join(methodsDir, `${className}.api.ts`);
858
-
859
- await writeFormattedFile(outputFile, content);
860
-
861
- methodExports.push(`export * from "./methods/${className}.api";`);
862
- }
863
-
864
- const apiIndexContent = `
865
- export * from "./generated/data-contracts";
866
- export * from "./generated/http-client";
867
- ${methodExports.join("\n")}
868
- `.trim();
869
-
870
- await writeFormattedFile(path.join(apiRoot, "index.ts"), apiIndexContent);
871
- }
872
-
873
- async function fixGeneratedMixedImports(apiDir) {
874
- const tsFiles = getAllTsFiles(apiDir);
875
-
876
- for (const filePath of tsFiles) {
877
- if (
878
- filePath.endsWith("http-client.ts") ||
879
- filePath.endsWith("data-contracts.ts")
880
- ) {
881
- continue;
882
- }
883
-
884
- const originalContent = fs.readFileSync(filePath, "utf8");
885
- let content = originalContent;
886
-
887
- content = content.replace(
888
- /import\s*\{([\s\S]*?)\}\s*from\s*["']\.\/http-client["'];/g,
889
- (_match, importsText) => {
890
- const importedNames = importsText
891
- .split(",")
892
- .map((name) => name.trim())
893
- .filter(Boolean);
894
-
895
- const typeNames = importedNames.filter(
896
- (name) => name === "RequestParams",
897
- );
898
-
899
- const valueNames = importedNames.filter(
900
- (name) => name !== "RequestParams",
901
- );
902
-
903
- const statements = [];
904
-
905
- if (valueNames.length > 0) {
906
- statements.push(
907
- `import { ${valueNames.join(", ")} } from "./http-client";`,
908
- );
909
- }
910
-
911
- if (typeNames.length > 0 && useTypeOnlyImports) {
912
- statements.push(
913
- `import type { ${typeNames.join(", ")} } from "./http-client";`,
914
- );
915
- } else if (typeNames.length > 0) {
916
- statements.push(
917
- `import { ${typeNames.join(", ")} } from "./http-client";`,
918
- );
919
- }
920
-
921
- return statements.join("\n");
922
- },
923
- );
924
-
925
- if (useTypeOnlyImports) {
926
- content = content.replace(
927
- /import\s*\{([\s\S]*?)\}\s*from\s*["']\.\/data-contracts["'];/g,
928
- (_match, importsText) =>
929
- `import type {${importsText}} from "./data-contracts";`,
930
- );
931
- }
932
-
933
- if (content !== originalContent) {
934
- await writeFormattedFile(filePath, content);
935
-
936
- console.log(
937
- `Fixed mixed imports in ${path.relative(process.cwd(), filePath)}`,
938
- );
939
- }
940
- }
941
- }
942
-
943
- async function main() {
944
- console.log(`Generating API from: ${swaggerInput}`);
945
- console.log(`Output folder: ${apiRoot}`);
946
-
947
- await generateOpenApiClient();
948
-
949
- await fixGeneratedMixedImports(generatedDir);
950
-
951
- await createMethodFiles();
952
-
953
- console.log("API generation completed.");
954
- }
955
-
956
- main().catch((error) => {
957
- console.error(error);
958
- process.exit(1);
959
- });
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 packageRoot = path.resolve(__dirname, "..");
10
+ const runtimePackageJsonPath = path.join(packageRoot, "package.json");
11
+ const runtimePackageJson = JSON.parse(
12
+ fs.readFileSync(runtimePackageJsonPath, "utf8"),
13
+ );
14
+
15
+ const consumerPackageJsonPath = path.resolve(process.cwd(), "package.json");
16
+ const consumerPackageJson = fs.existsSync(consumerPackageJsonPath)
17
+ ? JSON.parse(fs.readFileSync(consumerPackageJsonPath, "utf8"))
18
+ : {};
19
+
20
+ const isRunningInsidePackage =
21
+ path.resolve(process.cwd()) === path.resolve(__dirname, "..");
22
+
23
+ const runtimePackageName = isRunningInsidePackage
24
+ ? "../.."
25
+ : runtimePackageJson.name;
26
+
27
+ const cwd = process.cwd();
28
+
29
+ const config = {
30
+ ...(runtimePackageJson.config ?? {}),
31
+ ...(consumerPackageJson.config ?? {}),
32
+ };
33
+
34
+ const swaggerFile =
35
+ process.env.SWAGGER_FILE ??
36
+ process.env.npm_config_swagger_file ??
37
+ config.swaggerFile;
38
+
39
+ const swaggerUrl =
40
+ process.env.SWAGGER_URL ??
41
+ process.env.npm_config_swagger_url ??
42
+ config.swaggerUrl;
43
+
44
+ const defaultSwaggerUrl = "https://localhost:7000/swagger/v1/swagger.json";
45
+
46
+ const swaggerBackupFileSetting = getStringSetting(
47
+ process.env.TYPED_API_SWAGGER_BACKUP_FILE,
48
+ process.env.npm_config_typed_api_swagger_backup_file,
49
+ config.typedApiSwaggerBackupFile,
50
+ "swagger/swagger.backup.json",
51
+ );
52
+
53
+ const swaggerBackupPath = path.resolve(packageRoot, swaggerBackupFileSetting);
54
+
55
+ const apiRoot = path.resolve(
56
+ cwd,
57
+ process.env.API_OUTPUT ??
58
+ process.env.npm_config_api_output ??
59
+ config.apiOutput ??
60
+ "src/api",
61
+ );
62
+
63
+ const generatedDir = path.join(apiRoot, "generated");
64
+ const methodsDir = path.join(apiRoot, "methods");
65
+
66
+ const useTypeOnlyImports =
67
+ process.env.TYPED_API_USE_TYPE_ONLY_IMPORTS === "true" ||
68
+ process.env.npm_config_typed_api_use_type_only_imports === "true" ||
69
+ config.typedApiUseTypeOnlyImports === true;
70
+
71
+ const useFilterFormValues =
72
+ process.env.TYPED_API_USE_FILTER_FORM_VALUES === "true" ||
73
+ process.env.npm_config_typed_api_use_filter_form_values === "true" ||
74
+ config.typedApiUseFilterFormValues === true;
75
+
76
+ function getStringSetting(...values) {
77
+ for (const value of values) {
78
+ if (typeof value === "string" && value.trim() !== "") {
79
+ return value.trim();
80
+ }
81
+ }
82
+
83
+ return undefined;
84
+ }
85
+
86
+ const fallbackDefaultFunctionsPath = "defaultApiFunctions";
87
+
88
+ const defaultFunctionsPathSetting = getStringSetting(
89
+ process.env.TYPED_API_DEFAULT_FUNCTIONS_PATH,
90
+ process.env.npm_config_typed_api_default_functions_path,
91
+ config.typedApiDefaultFunctionsPath,
92
+ fallbackDefaultFunctionsPath,
93
+ );
94
+
95
+ const defaultFunctionsFilePath = path.resolve(cwd, defaultFunctionsPathSetting);
96
+
97
+ const defaultSuccessHandlerName = getStringSetting(
98
+ process.env.TYPED_API_DEFAULT_SUCCESS_HANDLER,
99
+ process.env.npm_config_typed_api_default_success_handler,
100
+ config.typedApiDefaultSuccessHandler,
101
+ "handleGoodResult",
102
+ );
103
+
104
+ const defaultErrorHandlerName = getStringSetting(
105
+ process.env.TYPED_API_DEFAULT_ERROR_HANDLER,
106
+ process.env.npm_config_typed_api_default_error_handler,
107
+ config.typedApiDefaultErrorHandler,
108
+ "handleErrors",
109
+ );
110
+
111
+ function pascalCase(value) {
112
+ return value
113
+ .split(/[-_]/)
114
+ .filter(Boolean)
115
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
116
+ .join("");
117
+ }
118
+
119
+ function toQueryName(methodName) {
120
+ return `${pascalCase(methodName)}Query`;
121
+ }
122
+
123
+ function cleanDirectory(directory) {
124
+ if (fs.existsSync(directory)) {
125
+ fs.rmSync(directory, { recursive: true, force: true });
126
+ }
127
+
128
+ fs.mkdirSync(directory, { recursive: true });
129
+ }
130
+
131
+
132
+ function fileExists(filePath) {
133
+ try {
134
+ return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ function writeSwaggerBackup(content, sourceLabel) {
141
+ fs.mkdirSync(path.dirname(swaggerBackupPath), { recursive: true });
142
+ fs.writeFileSync(swaggerBackupPath, content);
143
+
144
+ console.log(
145
+ `Updated Swagger backup from ${sourceLabel}: ${path.relative(packageRoot, swaggerBackupPath)}`,
146
+ );
147
+ }
148
+
149
+ function copySwaggerBackupFromFile(filePath) {
150
+ const content = fs.readFileSync(filePath, "utf8");
151
+
152
+ // Validate that the backup is at least JSON. This prevents overwriting a good
153
+ // backup with an invalid or empty file by accident.
154
+ JSON.parse(content);
155
+
156
+ writeSwaggerBackup(content, path.relative(cwd, filePath));
157
+ }
158
+
159
+ async function downloadSwaggerBackupFromUrl(url) {
160
+ const response = await fetch(url);
161
+
162
+ if (!response.ok) {
163
+ throw new Error(
164
+ `Swagger URL returned ${response.status} ${response.statusText}: ${url}`,
165
+ );
166
+ }
167
+
168
+ const content = await response.text();
169
+
170
+ // Validate before saving, so a server error page is never stored as backup.
171
+ JSON.parse(content);
172
+
173
+ writeSwaggerBackup(content, url);
174
+ }
175
+
176
+ function useBackupSwaggerInput(reason) {
177
+ if (!fileExists(swaggerBackupPath)) {
178
+ return undefined;
179
+ }
180
+
181
+ console.warn(
182
+ `${reason} Using Swagger backup: ${path.relative(packageRoot, swaggerBackupPath)}`,
183
+ );
184
+
185
+ return {
186
+ input: swaggerBackupPath,
187
+ label: swaggerBackupPath,
188
+ };
189
+ }
190
+
191
+ async function resolveSwaggerInput() {
192
+ if (swaggerFile) {
193
+ const swaggerFilePath = path.resolve(cwd, swaggerFile);
194
+
195
+ if (fileExists(swaggerFilePath)) {
196
+ copySwaggerBackupFromFile(swaggerFilePath);
197
+
198
+ return {
199
+ input: swaggerFilePath,
200
+ label: swaggerFilePath,
201
+ };
202
+ }
203
+
204
+ const backupInput = useBackupSwaggerInput(
205
+ `Swagger file was not found: ${swaggerFilePath}.`,
206
+ );
207
+
208
+ if (backupInput) {
209
+ return backupInput;
210
+ }
211
+
212
+ throw new Error(
213
+ `Swagger file was not found and no backup exists: ${swaggerFilePath}`,
214
+ );
215
+ }
216
+
217
+ if (swaggerUrl) {
218
+ try {
219
+ await downloadSwaggerBackupFromUrl(swaggerUrl);
220
+
221
+ return {
222
+ input: swaggerBackupPath,
223
+ label: swaggerUrl,
224
+ };
225
+ } catch (error) {
226
+ const backupInput = useBackupSwaggerInput(
227
+ `Swagger URL is not available: ${swaggerUrl}.`,
228
+ );
229
+
230
+ if (backupInput) {
231
+ return backupInput;
232
+ }
233
+
234
+ throw error;
235
+ }
236
+ }
237
+
238
+ const defaultSwaggerFile = path.resolve(cwd, "swagger/swagger.json");
239
+
240
+ if (fileExists(defaultSwaggerFile)) {
241
+ copySwaggerBackupFromFile(defaultSwaggerFile);
242
+
243
+ return {
244
+ input: defaultSwaggerFile,
245
+ label: defaultSwaggerFile,
246
+ };
247
+ }
248
+
249
+ try {
250
+ await downloadSwaggerBackupFromUrl(defaultSwaggerUrl);
251
+
252
+ return {
253
+ input: swaggerBackupPath,
254
+ label: defaultSwaggerUrl,
255
+ };
256
+ } catch (error) {
257
+ const backupInput = useBackupSwaggerInput(
258
+ `Default Swagger file was not found and default Swagger URL is not available.`,
259
+ );
260
+
261
+ if (backupInput) {
262
+ return backupInput;
263
+ }
264
+
265
+ throw error;
266
+ }
267
+ }
268
+
269
+ async function writeFormattedFile(filePath, content) {
270
+ fs.writeFileSync(filePath, content);
271
+ }
272
+
273
+ function removeGeneratedRouteFiles() {
274
+ const files = fs.readdirSync(generatedDir);
275
+
276
+ for (const file of files) {
277
+ if (/route\.ts$/i.test(file)) {
278
+ fs.rmSync(path.join(generatedDir, file), { force: true });
279
+ }
280
+ }
281
+ }
282
+
283
+ async function generateOpenApiClient(swaggerInput) {
284
+ cleanDirectory(generatedDir);
285
+ cleanDirectory(methodsDir);
286
+ fs.mkdirSync(methodsDir, { recursive: true });
287
+
288
+ const generateOptions = {
289
+ name: "Api.ts",
290
+ output: generatedDir,
291
+ httpClientType: "fetch",
292
+ modular: true,
293
+ cleanOutput: true,
294
+ generateClient: true,
295
+ generateRouteTypes: true,
296
+ extractRequestParams: true,
297
+ extractRequestBody: true,
298
+ moduleNameFirstTag: true,
299
+ };
300
+
301
+ generateOptions.input = swaggerInput;
302
+
303
+ await generateApi(generateOptions);
304
+
305
+ removeGeneratedRouteFiles();
306
+ }
307
+
308
+ function createImportStatement({ names, from, typeOnly = false }) {
309
+ if (!names || names.length === 0) {
310
+ return "";
311
+ }
312
+
313
+ const importKeyword = typeOnly ? "import type" : "import";
314
+
315
+ return `${importKeyword} {
316
+ ${names.map((name) => ` ${name},`).join("\n")}
317
+ } from "${from}";`;
318
+ }
319
+
320
+ function createAliasedImportStatement({ imports, from }) {
321
+ if (!imports || imports.length === 0) {
322
+ return "";
323
+ }
324
+
325
+ return `import {
326
+ ${imports
327
+ .map(({ name, alias }) =>
328
+ alias && alias !== name ? ` ${name} as ${alias},` : ` ${name},`,
329
+ )
330
+ .join("\n")}
331
+ } from "${from}";`;
332
+ }
333
+
334
+ function resolveImportFilePath(fromDirectory, importPath) {
335
+ if (!importPath.startsWith(".")) {
336
+ return undefined;
337
+ }
338
+
339
+ const basePath = path.resolve(fromDirectory, importPath);
340
+ const candidates = [
341
+ basePath,
342
+ `${basePath}.ts`,
343
+ `${basePath}.tsx`,
344
+ `${basePath}.js`,
345
+ `${basePath}.jsx`,
346
+ path.join(basePath, "index.ts"),
347
+ path.join(basePath, "index.tsx"),
348
+ path.join(basePath, "index.js"),
349
+ path.join(basePath, "index.jsx"),
350
+ ];
351
+
352
+ return candidates.find((candidate) => fs.existsSync(candidate));
353
+ }
354
+
355
+ function createDefaultFunctionsFileIfMissing(filePath) {
356
+ const extension = path.extname(filePath);
357
+ const resolvedFilePath = extension ? filePath : `${filePath}.ts`;
358
+
359
+ if (fs.existsSync(resolvedFilePath)) {
360
+ return resolvedFilePath;
361
+ }
362
+
363
+ fs.mkdirSync(path.dirname(resolvedFilePath), { recursive: true });
364
+
365
+ fs.writeFileSync(
366
+ resolvedFilePath,
367
+ `
368
+ ${createImportStatement({
369
+ names: ["ApiErrorResult", "ApiSuccessResult"],
370
+ from: "typedapi-client-helpers",
371
+ typeOnly: true,
372
+ })}
373
+
374
+ export function ${defaultSuccessHandlerName}<T>(
375
+ _response: ApiSuccessResult<T>,
376
+ ): void | Promise<void> {
377
+ // Add your default success handling here.
378
+ }
379
+
380
+ export function ${defaultErrorHandlerName}<T>(
381
+ _error: ApiErrorResult<T>,
382
+ ): void | Promise<void> {
383
+ // Add your default error handling here.
384
+ }
385
+ `,
386
+ );
387
+
388
+ console.log(
389
+ `Created default API handler file: ${path.relative(process.cwd(), resolvedFilePath)}`,
390
+ );
391
+
392
+ return resolvedFilePath;
393
+ }
394
+
395
+ function toRelativeImportPath(fromDirectory, filePath) {
396
+ let relativePath = path.relative(fromDirectory, filePath);
397
+
398
+ if (!relativePath.startsWith(".")) {
399
+ relativePath = `./${relativePath}`;
400
+ }
401
+
402
+ relativePath = relativePath.replaceAll(path.sep, "/");
403
+
404
+ return relativePath.replace(/\.(ts|tsx|js|jsx)$/, "");
405
+ }
406
+
407
+ function escapeRegExp(value) {
408
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
409
+ }
410
+
411
+ function fileExportsName(source, exportName) {
412
+ const escapedName = escapeRegExp(exportName);
413
+
414
+ const directExportPattern = new RegExp(
415
+ `export\\s+(?:async\\s+)?(?:function|const|let|var|class)\\s+${escapedName}\\b`,
416
+ "m",
417
+ );
418
+
419
+ if (directExportPattern.test(source)) {
420
+ return true;
421
+ }
422
+
423
+ const namedExportBlocks = source.matchAll(/export\s*\{([\s\S]*?)\}/g);
424
+
425
+ for (const match of namedExportBlocks) {
426
+ const exportedNames = match[1]
427
+ .split(",")
428
+ .map((part) => part.trim())
429
+ .filter(Boolean);
430
+
431
+ if (
432
+ exportedNames.some((part) => {
433
+ const [localName, aliasName] = part
434
+ .split(/\s+as\s+/)
435
+ .map((name) => name.trim());
436
+
437
+ return (
438
+ aliasName === exportName || (!aliasName && localName === exportName)
439
+ );
440
+ })
441
+ ) {
442
+ return true;
443
+ }
444
+ }
445
+
446
+ return false;
447
+ }
448
+
449
+ function resolveDefaultHandlers(importFromDirectory) {
450
+ const defaultFunctionsFile =
451
+ resolveImportFilePath(importFromDirectory, defaultFunctionsFilePath) ??
452
+ createDefaultFunctionsFileIfMissing(defaultFunctionsFilePath);
453
+
454
+ const source = fs.readFileSync(defaultFunctionsFile, "utf8");
455
+ const hasSuccessHandler = fileExportsName(source, defaultSuccessHandlerName);
456
+ const hasErrorHandler = fileExportsName(source, defaultErrorHandlerName);
457
+
458
+ if (!hasSuccessHandler || !hasErrorHandler) {
459
+ const missingNames = [
460
+ ...(!hasSuccessHandler ? [defaultSuccessHandlerName] : []),
461
+ ...(!hasErrorHandler ? [defaultErrorHandlerName] : []),
462
+ ];
463
+
464
+ console.warn(
465
+ `Default API handlers were not imported because ${path.relative(
466
+ process.cwd(),
467
+ defaultFunctionsFile,
468
+ )} does not export: ${missingNames.join(", ")}.`,
469
+ );
470
+
471
+ return undefined;
472
+ }
473
+
474
+ return {
475
+ defaultFunctionsPath: toRelativeImportPath(
476
+ importFromDirectory,
477
+ defaultFunctionsFile,
478
+ ),
479
+ defaultSuccessHandlerName,
480
+ defaultErrorHandlerName,
481
+ };
482
+ }
483
+
484
+ function callbackOptionsExpression(defaultHandlers) {
485
+ if (defaultHandlers) {
486
+ return `{
487
+ onSuccess: onSuccess ?? typedApiDefaultSuccessHandler,
488
+ onError: onError ?? typedApiDefaultErrorHandler
489
+ }`;
490
+ }
491
+
492
+ return `{
493
+ onSuccess,
494
+ onError
495
+ }`;
496
+ }
497
+
498
+ function getAllTsFiles(dir) {
499
+ let results = [];
500
+
501
+ if (!fs.existsSync(dir)) {
502
+ return results;
503
+ }
504
+
505
+ const list = fs.readdirSync(dir);
506
+
507
+ for (const file of list) {
508
+ const filePath = path.join(dir, file);
509
+ const stat = fs.statSync(filePath);
510
+
511
+ if (stat.isDirectory()) {
512
+ results = results.concat(getAllTsFiles(filePath));
513
+ } else if (filePath.endsWith(".ts")) {
514
+ results.push(filePath);
515
+ }
516
+ }
517
+
518
+ return results;
519
+ }
520
+
521
+ function getGeneratedMethods(source) {
522
+ const methodRegex = /^\s*(\w+)\s*=\s*\(/gm;
523
+ const matches = [...source.matchAll(methodRegex)];
524
+
525
+ return matches.map((match, index) => {
526
+ const methodName = match[1];
527
+ const startIndex = match.index ?? 0;
528
+ const endIndex =
529
+ index + 1 < matches.length ? matches[index + 1].index : source.length;
530
+
531
+ const methodSource = source.slice(startIndex, endIndex);
532
+
533
+ return {
534
+ methodName,
535
+ methodSource,
536
+ isFormData: methodSource.includes("type: ContentType.FormData"),
537
+ };
538
+ });
539
+ }
540
+
541
+ function splitTopLevelArguments(value) {
542
+ const args = [];
543
+ let current = "";
544
+ let depth = 0;
545
+ let quote = "";
546
+
547
+ for (let index = 0; index < value.length; index++) {
548
+ const char = value[index];
549
+ const previousChar = value[index - 1];
550
+
551
+ if (quote) {
552
+ current += char;
553
+
554
+ if (char === quote && previousChar !== "\\") {
555
+ quote = "";
556
+ }
557
+
558
+ continue;
559
+ }
560
+
561
+ if (char === '"' || char === "'" || char === "`") {
562
+ quote = char;
563
+ current += char;
564
+ continue;
565
+ }
566
+
567
+ if (char === "(" || char === "{" || char === "[" || char === "<") {
568
+ depth++;
569
+ current += char;
570
+ continue;
571
+ }
572
+
573
+ if (char === ")" || char === "}" || char === "]" || char === ">") {
574
+ depth--;
575
+ current += char;
576
+ continue;
577
+ }
578
+
579
+ if (char === "," && depth === 0) {
580
+ if (current.trim()) {
581
+ args.push(current.trim());
582
+ }
583
+
584
+ current = "";
585
+ continue;
586
+ }
587
+
588
+ current += char;
589
+ }
590
+
591
+ if (current.trim()) {
592
+ args.push(current.trim());
593
+ }
594
+
595
+ return args;
596
+ }
597
+
598
+ function getGeneratedMethodArgumentCount(signatureParams) {
599
+ const args = splitTopLevelArguments(signatureParams);
600
+
601
+ if (args.length === 0) {
602
+ return 0;
603
+ }
604
+
605
+ const lastArg = args[args.length - 1];
606
+
607
+ if (/\bparams\s*:\s*RequestParams\b/.test(lastArg)) {
608
+ return args.length - 1;
609
+ }
610
+
611
+ return args.length;
612
+ }
613
+
614
+ async function createMethodFiles() {
615
+ const defaultHandlers = resolveDefaultHandlers(methodsDir);
616
+
617
+ const generatedFiles = fs
618
+ .readdirSync(generatedDir)
619
+ .filter(
620
+ (file) =>
621
+ file.endsWith(".ts") &&
622
+ file !== "http-client.ts" &&
623
+ file !== "data-contracts.ts" &&
624
+ !/route\.ts$/i.test(file) &&
625
+ !file.includes(".api"),
626
+ );
627
+
628
+ const methodExports = [];
629
+
630
+ for (const file of generatedFiles) {
631
+ const filePath = path.join(generatedDir, file);
632
+ const source = fs.readFileSync(filePath, "utf8");
633
+
634
+ const baseName = file.replace(".ts", "");
635
+ const className = baseName.split("-").map(pascalCase).join("");
636
+
637
+ const instanceName = `${className.charAt(0).toLowerCase()}${className.slice(1)}Api`;
638
+
639
+ const paginatedMethods = [];
640
+ const simpleQueryMethods = [];
641
+ const nonQueryMethods = [];
642
+
643
+ const generatedMethods = getGeneratedMethods(source);
644
+
645
+ for (const generatedMethod of generatedMethods) {
646
+ const { methodName, methodSource, isFormData } = generatedMethod;
647
+
648
+ const signatureRegex = new RegExp(
649
+ `${methodName}\\s*=\\s*\\(([^)]*)\\)\\s*=>`,
650
+ "m",
651
+ );
652
+
653
+ const signatureMatch = methodSource.match(signatureRegex);
654
+
655
+ if (!signatureMatch) {
656
+ continue;
657
+ }
658
+
659
+ const params = signatureMatch[1].trim();
660
+ const methodArgumentCount = getGeneratedMethodArgumentCount(params);
661
+
662
+ const returnTypeMatch = methodSource.match(/this\.request<([^,>]+)/m);
663
+ const returnType = returnTypeMatch?.[1] ?? "";
664
+
665
+ const isPaginated =
666
+ returnType.includes("ApiPaginationResponse") ||
667
+ returnType.trim().endsWith("[]");
668
+
669
+ if (params.startsWith("query")) {
670
+ if (isPaginated) {
671
+ paginatedMethods.push(methodName);
672
+ } else {
673
+ simpleQueryMethods.push(methodName);
674
+ }
675
+
676
+ continue;
677
+ }
678
+
679
+ nonQueryMethods.push({
680
+ methodName,
681
+ isFormData,
682
+ methodArgumentCount,
683
+ });
684
+ }
685
+
686
+ const queryTypes = [...paginatedMethods, ...simpleQueryMethods]
687
+ .map((methodName) =>
688
+ `
689
+ export type ${toQueryName(methodName)} =
690
+ NonNullable<
691
+ Parameters<${className}["${methodName}"]>[0]
692
+ >;
693
+ `.trim(),
694
+ )
695
+ .join("\n\n");
696
+
697
+ const hasPaginatedMethods = paginatedMethods.length > 0;
698
+
699
+ const hasFormDataMethods = nonQueryMethods.some(
700
+ ({ isFormData }) => isFormData,
701
+ );
702
+
703
+ const hasRegularNonQueryMethods = nonQueryMethods.some(
704
+ ({ isFormData }) => !isFormData,
705
+ );
706
+
707
+ const paginatedMethodWrappers = paginatedMethods
708
+ .map((methodName) => {
709
+ if (useFilterFormValues) {
710
+ return `
711
+ export async function ${methodName}(
712
+ filters: FilterFormValues<${toQueryName(methodName)}>[] = [],
713
+ page = 1,
714
+ pageSize = 100,
715
+ sortBy: SortableKeys<
716
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>
717
+ > | null = null,
718
+ sortDirection?: SortDirection,
719
+ options: ApiMethodOptions<
720
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>,
721
+ ExtractError<ReturnType<${className}["${methodName}"]>>,
722
+ RequestParams
723
+ > = {}
724
+ ): Promise<ApiResult<ExtractResponse<ReturnType<${className}["${methodName}"]>>>> {
725
+ const { onSuccess, onError, params } = options;
726
+
727
+ return handleApiResponse(
728
+ () =>
729
+ ${instanceName}.${methodName}(
730
+ buildQuery<
731
+ ${toQueryName(methodName)},
732
+ UnwrapArray<
733
+ ExtractDataIfPaginated<
734
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>
735
+ >
736
+ >
737
+ >(filters, page, pageSize, sortBy, sortDirection),
738
+ params ?? {}
739
+ ),
740
+ ${callbackOptionsExpression(defaultHandlers)}
741
+ );
742
+ }
743
+ `.trim();
744
+ }
745
+
746
+ return `
747
+ export async function ${methodName}(
748
+ query?: ${toQueryName(methodName)},
749
+ options: ApiMethodOptions<
750
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>,
751
+ ExtractError<ReturnType<${className}["${methodName}"]>>,
752
+ RequestParams
753
+ > = {}
754
+ ): Promise<ApiResult<ExtractResponse<ReturnType<${className}["${methodName}"]>>>> {
755
+ const { onSuccess, onError, params } = options;
756
+
757
+ return handleApiResponse(
758
+ () =>
759
+ ${instanceName}.${methodName}(
760
+ (query ?? {}) as ${toQueryName(methodName)},
761
+ params ?? {}
762
+ ),
763
+ ${callbackOptionsExpression(defaultHandlers)}
764
+ );
765
+ }
766
+ `.trim();
767
+ })
768
+ .join("\n\n");
769
+
770
+ const simpleQueryMethodWrappers = simpleQueryMethods
771
+ .map((methodName) =>
772
+ `
773
+ export async function ${methodName}(
774
+ query?: ${toQueryName(methodName)},
775
+ options: ApiMethodOptions<
776
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>,
777
+ ExtractError<ReturnType<${className}["${methodName}"]>>,
778
+ RequestParams
779
+ > = {}
780
+ ): Promise<
781
+ ApiResult<
782
+ ExtractResponse<
783
+ ReturnType<
784
+ ${className}["${methodName}"]
785
+ >
786
+ >
787
+ >
788
+ > {
789
+ const { onSuccess, onError, params } = options;
790
+
791
+ return handleApiResponse(
792
+ () =>
793
+ ${instanceName}.${methodName}(
794
+ (query ?? {}) as ${toQueryName(methodName)},
795
+ params ?? {}
796
+ ),
797
+ ${callbackOptionsExpression(defaultHandlers)}
798
+ );
799
+ }
800
+ `.trim(),
801
+ )
802
+ .join("\n\n");
803
+
804
+ const nonQueryMethodWrappers = nonQueryMethods
805
+ .map(({ methodName, isFormData, methodArgumentCount }) => {
806
+ if (isFormData) {
807
+ return `
808
+ export async function ${methodName}(
809
+ data: Parameters<
810
+ ${className}["${methodName}"]
811
+ >[0],
812
+ options: ApiMethodOptions<
813
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>,
814
+ ExtractError<ReturnType<${className}["${methodName}"]>>,
815
+ RequestParams
816
+ > = {}
817
+ ): Promise<
818
+ ApiResult<
819
+ ExtractResponse<
820
+ ReturnType<
821
+ ${className}["${methodName}"]
822
+ >
823
+ >
824
+ >
825
+ > {
826
+ const { onSuccess, onError, params } = options;
827
+ const formData = toFormData(data);
828
+
829
+ return handleApiResponse(
830
+ () =>
831
+ ${instanceName}.${methodName}(
832
+ formData as unknown as Parameters<
833
+ ${className}["${methodName}"]
834
+ >[0],
835
+ params ?? {}
836
+ ),
837
+ ${callbackOptionsExpression(defaultHandlers)}
838
+ );
839
+ }
840
+ `.trim();
841
+ }
842
+
843
+ return `
844
+ export async function ${methodName}(
845
+ ...argsWithOptions: [
846
+ ...ApiMethodArguments<
847
+ ${className}["${methodName}"],
848
+ RequestParams
849
+ >,
850
+ ApiMethodOptions<
851
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>,
852
+ ExtractError<ReturnType<${className}["${methodName}"]>>,
853
+ RequestParams
854
+ >?
855
+ ]
856
+ ): Promise<
857
+ ApiResult<
858
+ ExtractResponse<
859
+ ReturnType<
860
+ ${className}["${methodName}"]
861
+ >
862
+ >
863
+ >
864
+ > {
865
+ const args = [...argsWithOptions] as unknown[];
866
+
867
+ const options =
868
+ args.length > ${methodArgumentCount}
869
+ ? (args.pop() as ApiMethodOptions<
870
+ ExtractResponse<ReturnType<${className}["${methodName}"]>>,
871
+ ExtractError<ReturnType<${className}["${methodName}"]>>,
872
+ RequestParams
873
+ >)
874
+ : {};
875
+
876
+ const { onSuccess, onError, params } = options ?? {};
877
+
878
+ const requestArgs = [
879
+ ...args,
880
+ params ?? {}
881
+ ] as unknown as Parameters<
882
+ ${className}["${methodName}"]
883
+ >;
884
+
885
+ return handleApiResponse(
886
+ () =>
887
+ ${instanceName}.${methodName}(
888
+ ...requestArgs
889
+ ),
890
+ ${callbackOptionsExpression(defaultHandlers)}
891
+ );
892
+ }
893
+ `.trim();
894
+ })
895
+ .join("\n\n");
896
+
897
+ const runtimeValueImportNames = [
898
+ ...(hasPaginatedMethods && useFilterFormValues ? ["buildQuery"] : []),
899
+ ...(hasFormDataMethods ? ["toFormData"] : []),
900
+ "handleApiResponse",
901
+ ];
902
+
903
+ const runtimeTypeImportNames = [
904
+ "ApiResult",
905
+ "ApiMethodOptions",
906
+ ...(hasRegularNonQueryMethods ? ["ApiMethodArguments"] : []),
907
+ "ExtractResponse",
908
+ "ExtractError",
909
+
910
+ ...(hasPaginatedMethods && useFilterFormValues
911
+ ? [
912
+ "ExtractDataIfPaginated",
913
+ "FilterFormValues",
914
+ "SortableKeys",
915
+ "SortDirection",
916
+ "UnwrapArray",
917
+ ]
918
+ : []),
919
+ ];
920
+
921
+ const runtimeValueImports = createImportStatement({
922
+ names: runtimeValueImportNames,
923
+ from: runtimePackageName,
924
+ });
925
+
926
+ const defaultFunctionImports = defaultHandlers
927
+ ? createAliasedImportStatement({
928
+ imports: [
929
+ {
930
+ name: defaultHandlers.defaultSuccessHandlerName,
931
+ alias: "typedApiDefaultSuccessHandler",
932
+ },
933
+ {
934
+ name: defaultHandlers.defaultErrorHandlerName,
935
+ alias: "typedApiDefaultErrorHandler",
936
+ },
937
+ ],
938
+ from: defaultHandlers.defaultFunctionsPath,
939
+ })
940
+ : "";
941
+
942
+ const runtimeTypeImports = createImportStatement({
943
+ names: runtimeTypeImportNames,
944
+ from: runtimePackageName,
945
+ typeOnly: useTypeOnlyImports,
946
+ });
947
+
948
+ const generatedValueImports = createImportStatement({
949
+ names: [className],
950
+ from: `../generated/${baseName}`,
951
+ });
952
+
953
+ const generatedTypeImports =
954
+ generatedMethods.length > 0
955
+ ? createImportStatement({
956
+ names: ["RequestParams"],
957
+ from: "../generated/http-client",
958
+ typeOnly: useTypeOnlyImports,
959
+ })
960
+ : "";
961
+
962
+ const content = `
963
+ ${runtimeValueImports}
964
+
965
+ ${defaultFunctionImports}
966
+
967
+ ${runtimeTypeImports}
968
+
969
+ ${generatedValueImports}
970
+
971
+ ${generatedTypeImports}
972
+
973
+ /* =======================
974
+ Query Types
975
+ ======================= */
976
+ ${queryTypes}
977
+
978
+ /* =======================
979
+ API Instance
980
+ ======================= */
981
+ const ${instanceName} = new ${className}();
982
+
983
+ /* =======================
984
+ Paginated Query Methods
985
+ ======================= */
986
+ ${paginatedMethodWrappers}
987
+
988
+ /* =======================
989
+ Simple Query Methods
990
+ ======================= */
991
+ ${simpleQueryMethodWrappers}
992
+
993
+ /* =======================
994
+ Non-Query Methods
995
+ ======================= */
996
+ ${nonQueryMethodWrappers}
997
+ `.trim();
998
+
999
+ const outputFile = path.join(methodsDir, `${className}.api.ts`);
1000
+
1001
+ await writeFormattedFile(outputFile, content);
1002
+
1003
+ methodExports.push(`export * from "./methods/${className}.api";`);
1004
+ }
1005
+
1006
+ const apiIndexContent = `
1007
+ export * from "./generated/data-contracts";
1008
+ export * from "./generated/http-client";
1009
+ ${methodExports.join("\n")}
1010
+ `.trim();
1011
+
1012
+ await writeFormattedFile(path.join(apiRoot, "index.ts"), apiIndexContent);
1013
+ }
1014
+
1015
+ async function fixGeneratedMixedImports(apiDir) {
1016
+ const tsFiles = getAllTsFiles(apiDir);
1017
+
1018
+ for (const filePath of tsFiles) {
1019
+ if (
1020
+ filePath.endsWith("http-client.ts") ||
1021
+ filePath.endsWith("data-contracts.ts")
1022
+ ) {
1023
+ continue;
1024
+ }
1025
+
1026
+ const originalContent = fs.readFileSync(filePath, "utf8");
1027
+ let content = originalContent;
1028
+
1029
+ content = content.replace(
1030
+ /import\s*\{([\s\S]*?)\}\s*from\s*["']\.\/http-client["'];/g,
1031
+ (_match, importsText) => {
1032
+ const importedNames = importsText
1033
+ .split(",")
1034
+ .map((name) => name.trim())
1035
+ .filter(Boolean);
1036
+
1037
+ const typeNames = importedNames.filter(
1038
+ (name) => name === "RequestParams",
1039
+ );
1040
+
1041
+ const valueNames = importedNames.filter(
1042
+ (name) => name !== "RequestParams",
1043
+ );
1044
+
1045
+ const statements = [];
1046
+
1047
+ if (valueNames.length > 0) {
1048
+ statements.push(
1049
+ `import { ${valueNames.join(", ")} } from "./http-client";`,
1050
+ );
1051
+ }
1052
+
1053
+ if (typeNames.length > 0 && useTypeOnlyImports) {
1054
+ statements.push(
1055
+ `import type { ${typeNames.join(", ")} } from "./http-client";`,
1056
+ );
1057
+ } else if (typeNames.length > 0) {
1058
+ statements.push(
1059
+ `import { ${typeNames.join(", ")} } from "./http-client";`,
1060
+ );
1061
+ }
1062
+
1063
+ return statements.join("\n");
1064
+ },
1065
+ );
1066
+
1067
+ if (useTypeOnlyImports) {
1068
+ content = content.replace(
1069
+ /import\s*\{([\s\S]*?)\}\s*from\s*["']\.\/data-contracts["'];/g,
1070
+ (_match, importsText) =>
1071
+ `import type {${importsText}} from "./data-contracts";`,
1072
+ );
1073
+ }
1074
+
1075
+ if (content !== originalContent) {
1076
+ await writeFormattedFile(filePath, content);
1077
+
1078
+ console.log(
1079
+ `Fixed mixed imports in ${path.relative(process.cwd(), filePath)}`,
1080
+ );
1081
+ }
1082
+ }
1083
+ }
1084
+
1085
+ async function main() {
1086
+ const swaggerInput = await resolveSwaggerInput();
1087
+
1088
+ console.log(`Generating API from: ${swaggerInput.label}`);
1089
+ console.log(`Output folder: ${apiRoot}`);
1090
+
1091
+ await generateOpenApiClient(swaggerInput.input);
1092
+
1093
+ await fixGeneratedMixedImports(generatedDir);
1094
+
1095
+ await createMethodFiles();
1096
+
1097
+ console.log("API generation completed.");
1098
+ }
1099
+
1100
+ main().catch((error) => {
1101
+ console.error(error);
1102
+ process.exit(1);
1103
+ });