openapi-remote-codegen 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.js +45 -0
- package/dist/generators/api-client.d.ts +6 -0
- package/dist/generators/api-client.js +72 -0
- package/dist/generators/remote-functions.d.ts +3 -0
- package/dist/generators/remote-functions.js +439 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +88 -0
- package/dist/parser.d.ts +3 -0
- package/dist/parser.js +236 -0
- package/dist/public.d.ts +6 -0
- package/dist/public.js +5 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.js +1 -0
- package/dist/utils/client-mapping.d.ts +6 -0
- package/dist/utils/client-mapping.js +9 -0
- package/dist/utils/naming.d.ts +26 -0
- package/dist/utils/naming.js +89 -0
- package/package.json +37 -0
- package/src/__tests__/client-mapping.test.ts +38 -0
- package/src/__tests__/config.test.ts +59 -0
- package/src/__tests__/naming.test.ts +68 -0
- package/src/__tests__/parser.test.ts +576 -0
- package/src/__tests__/remote-functions.test.ts +315 -0
- package/src/config.ts +95 -0
- package/src/generators/api-client.ts +82 -0
- package/src/generators/remote-functions.ts +521 -0
- package/src/index.ts +105 -0
- package/src/parser.ts +303 -0
- package/src/public.ts +7 -0
- package/src/types.ts +48 -0
- package/src/utils/client-mapping.ts +9 -0
- package/src/utils/naming.ts +99 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import type { GeneratorConfig } from '../config.js';
|
|
2
|
+
import type { OperationInfo, ParameterInfo, ParsedSpec } from '../types.js';
|
|
3
|
+
import {
|
|
4
|
+
operationIdToFunctionName,
|
|
5
|
+
tagToFileName,
|
|
6
|
+
resolveInvalidation,
|
|
7
|
+
} from '../utils/naming.js';
|
|
8
|
+
import { getClientPropertyName } from '../utils/client-mapping.js';
|
|
9
|
+
|
|
10
|
+
export function generateRemoteFunctions(parsed: ParsedSpec, config: GeneratorConfig): Map<string, string> {
|
|
11
|
+
const fileContents = new Map<string, string>();
|
|
12
|
+
|
|
13
|
+
// Group operations by tag
|
|
14
|
+
const byTag = new Map<string, OperationInfo[]>();
|
|
15
|
+
for (const op of parsed.operations) {
|
|
16
|
+
const existing = byTag.get(op.tag) ?? [];
|
|
17
|
+
existing.push(op);
|
|
18
|
+
byTag.set(op.tag, existing);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Generate file for each tag
|
|
22
|
+
for (const [tag, operations] of byTag) {
|
|
23
|
+
const fileName = `${tagToFileName(tag)}.generated.remote.ts`;
|
|
24
|
+
const content = generateTagFile(tag, operations, config);
|
|
25
|
+
fileContents.set(fileName, content);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Generate barrel export (pass operations to detect name collisions)
|
|
29
|
+
const indexContent = generateIndexFile(byTag);
|
|
30
|
+
fileContents.set('index.ts', indexContent);
|
|
31
|
+
|
|
32
|
+
return fileContents;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function generateTagFile(tag: string, operations: OperationInfo[], config: GeneratorConfig): string {
|
|
36
|
+
const schemaImports = new Set<string>();
|
|
37
|
+
const typeImports = new Set<string>();
|
|
38
|
+
const enumImports = new Set<string>();
|
|
39
|
+
|
|
40
|
+
// Collect imports
|
|
41
|
+
for (const op of operations) {
|
|
42
|
+
if (op.requestBodySchema) {
|
|
43
|
+
schemaImports.add(op.requestBodySchema);
|
|
44
|
+
const typeName = op.requestBodySchema.replace(/Schema$/, '');
|
|
45
|
+
typeImports.add(typeName);
|
|
46
|
+
}
|
|
47
|
+
for (const param of op.parameters) {
|
|
48
|
+
if (param.enumName) {
|
|
49
|
+
enumImports.add(param.enumName);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const schemaImportLine = schemaImports.size > 0
|
|
55
|
+
? `import { ${Array.from(schemaImports).join(', ')} } from '${config.imports.schemas}';\n`
|
|
56
|
+
: '';
|
|
57
|
+
|
|
58
|
+
// Build apiTypes import line with both value imports (enums) and type imports (DTOs)
|
|
59
|
+
let apiImportLine = '';
|
|
60
|
+
const valueImports = Array.from(enumImports);
|
|
61
|
+
const typeOnlyImports = Array.from(typeImports);
|
|
62
|
+
if (valueImports.length > 0 || typeOnlyImports.length > 0) {
|
|
63
|
+
const parts = [
|
|
64
|
+
...valueImports,
|
|
65
|
+
...typeOnlyImports.map(t => `type ${t}`),
|
|
66
|
+
];
|
|
67
|
+
apiImportLine = `import { ${parts.join(', ')} } from '${config.imports.apiTypes}';\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const functions = operations.map(op => generateFunction(op, tag, operations, config)).join('\n\n');
|
|
71
|
+
|
|
72
|
+
// Only import query/command/form if actually used
|
|
73
|
+
const hasQuery = operations.some(op => op.remoteType === 'query');
|
|
74
|
+
const hasCommand = operations.some(op => op.remoteType === 'command');
|
|
75
|
+
const hasForm = operations.some(op => op.remoteType === 'form');
|
|
76
|
+
const serverImports = ['getRequestEvent', ...(hasQuery ? ['query'] : []), ...(hasCommand ? ['command'] : []), ...(hasForm ? ['form'] : [])];
|
|
77
|
+
|
|
78
|
+
// Only import z from zod if any function actually uses Zod schema constructors
|
|
79
|
+
const usesZod = operations.some(op => {
|
|
80
|
+
const hasPathParams = op.parameters.some(p => p.in === 'path');
|
|
81
|
+
const hasQueryParams = op.parameters.some(p => p.in === 'query');
|
|
82
|
+
const hasInlineBody = op.inlineRequestBody !== undefined;
|
|
83
|
+
return hasPathParams || hasQueryParams || op.isArrayBody || hasInlineBody;
|
|
84
|
+
});
|
|
85
|
+
const zodImportLine = usesZod ? `import { z } from '${config.imports.zod}';\n` : '';
|
|
86
|
+
const kitImports = ['error', 'redirect', ...(hasForm ? ['invalid'] : [])];
|
|
87
|
+
|
|
88
|
+
return `// AUTO-GENERATED - DO NOT EDIT
|
|
89
|
+
// Generated by openapi-remote-codegen
|
|
90
|
+
// Source: openapi.json
|
|
91
|
+
|
|
92
|
+
import { ${serverImports.join(', ')} } from '${config.imports.server}';
|
|
93
|
+
import { ${kitImports.join(', ')} } from '${config.imports.kit}';
|
|
94
|
+
${zodImportLine}${schemaImportLine}${apiImportLine}
|
|
95
|
+
${functions}
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function generateFunction(op: OperationInfo, tag: string, allOpsInTag: OperationInfo[], config: GeneratorConfig): string {
|
|
100
|
+
const functionName = operationIdToFunctionName(op.operationId);
|
|
101
|
+
const clientProperty = op.clientPropertyName ?? getClientPropertyName(tag);
|
|
102
|
+
const methodName = getMethodName(op.operationId, tag);
|
|
103
|
+
|
|
104
|
+
if (op.remoteType === 'query') {
|
|
105
|
+
if (op.isBatch) {
|
|
106
|
+
return generateBatchQueryFunction(op, functionName, clientProperty, methodName, config);
|
|
107
|
+
}
|
|
108
|
+
return generateQueryFunction(op, functionName, clientProperty, methodName, config);
|
|
109
|
+
} else if (op.remoteType === 'form') {
|
|
110
|
+
return generateMutationFunction(op, functionName, clientProperty, methodName, tag, allOpsInTag, 'form', config);
|
|
111
|
+
} else {
|
|
112
|
+
return generateMutationFunction(op, functionName, clientProperty, methodName, tag, allOpsInTag, 'command', config);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function generateCatchBlock(config: GeneratorConfig, clientProperty: string, methodName: string, functionName: string): string {
|
|
117
|
+
const humanName = functionName.replace(/([A-Z])/g, ' $1').toLowerCase().trim();
|
|
118
|
+
return ` } catch (err) {
|
|
119
|
+
const status = (err as any)?.status;
|
|
120
|
+
if (status === 401) { ${config.errorHandling.on401}; }
|
|
121
|
+
if (status === 403) ${config.errorHandling.on403};
|
|
122
|
+
console.error('Error in ${clientProperty}.${methodName}:', err);
|
|
123
|
+
${config.errorHandling.on500(humanName)};
|
|
124
|
+
}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function generateClientAccess(config: GeneratorConfig): string {
|
|
128
|
+
return ` const apiClient = ${config.clientAccess};`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function generateQueryFunction(
|
|
132
|
+
op: OperationInfo,
|
|
133
|
+
functionName: string,
|
|
134
|
+
clientProperty: string,
|
|
135
|
+
methodName: string,
|
|
136
|
+
config: GeneratorConfig
|
|
137
|
+
): string {
|
|
138
|
+
const { schemaArg, paramList, apiCallArgs } = buildParameterMapping(op);
|
|
139
|
+
const comment = op.summary ? `/** ${op.summary} */\n` : '';
|
|
140
|
+
const catchBlock = generateCatchBlock(config, clientProperty, methodName, functionName);
|
|
141
|
+
const clientAccess = generateClientAccess(config);
|
|
142
|
+
|
|
143
|
+
if (schemaArg) {
|
|
144
|
+
return `${comment}export const ${functionName} = query(${schemaArg}, async (${paramList}) => {
|
|
145
|
+
${clientAccess}
|
|
146
|
+
try {
|
|
147
|
+
return await apiClient.${clientProperty}.${methodName}(${apiCallArgs});
|
|
148
|
+
${catchBlock}
|
|
149
|
+
});`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return `${comment}export const ${functionName} = query(async () => {
|
|
153
|
+
${clientAccess}
|
|
154
|
+
try {
|
|
155
|
+
return await apiClient.${clientProperty}.${methodName}();
|
|
156
|
+
${catchBlock}
|
|
157
|
+
});`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function generateBatchQueryFunction(
|
|
161
|
+
op: OperationInfo,
|
|
162
|
+
functionName: string,
|
|
163
|
+
clientProperty: string,
|
|
164
|
+
methodName: string,
|
|
165
|
+
config: GeneratorConfig
|
|
166
|
+
): string {
|
|
167
|
+
const { schemaArg, paramList, apiCallArgs } = buildParameterMapping(op);
|
|
168
|
+
const comment = op.summary ? `/** ${op.summary} */\n` : '';
|
|
169
|
+
const catchBlock = generateCatchBlock(config, clientProperty, methodName, functionName);
|
|
170
|
+
const clientAccess = generateClientAccess(config);
|
|
171
|
+
|
|
172
|
+
if (schemaArg) {
|
|
173
|
+
return `${comment}export const ${functionName} = query.batch(${schemaArg}, async (args) => {
|
|
174
|
+
${clientAccess}
|
|
175
|
+
try {
|
|
176
|
+
const results = await Promise.all(args.map((${paramList}) => apiClient.${clientProperty}.${methodName}(${apiCallArgs})));
|
|
177
|
+
return (${paramList}, i) => results[i];
|
|
178
|
+
${catchBlock}
|
|
179
|
+
});`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// No-arg batch doesn't make sense - fall back to regular query
|
|
183
|
+
return `${comment}export const ${functionName} = query(async () => {
|
|
184
|
+
${clientAccess}
|
|
185
|
+
try {
|
|
186
|
+
return await apiClient.${clientProperty}.${methodName}();
|
|
187
|
+
${catchBlock}
|
|
188
|
+
});`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function generateMutationFunction(
|
|
192
|
+
op: OperationInfo,
|
|
193
|
+
functionName: string,
|
|
194
|
+
clientProperty: string,
|
|
195
|
+
methodName: string,
|
|
196
|
+
tag: string,
|
|
197
|
+
allOpsInTag: OperationInfo[],
|
|
198
|
+
wrapperName: 'command' | 'form',
|
|
199
|
+
config: GeneratorConfig
|
|
200
|
+
): string {
|
|
201
|
+
const { schemaArg, paramList, apiCallArgs } = buildParameterMapping(op);
|
|
202
|
+
|
|
203
|
+
// Build invalidation calls
|
|
204
|
+
const invalidations = op.invalidates.map(inv => {
|
|
205
|
+
const resolved = resolveInvalidation(inv, tag);
|
|
206
|
+
return resolved.functionName;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Filter to only include functions that exist in the same file (same tag)
|
|
210
|
+
const localFunctionNames = new Set(allOpsInTag.map(o => operationIdToFunctionName(o.operationId)));
|
|
211
|
+
const localInvalidations = invalidations.filter(fn => localFunctionNames.has(fn));
|
|
212
|
+
|
|
213
|
+
// Build refresh calls
|
|
214
|
+
const commandPathParams = op.parameters.filter(p => p.in === 'path');
|
|
215
|
+
const commandPathParamNames = new Set(commandPathParams.map(p => p.name));
|
|
216
|
+
|
|
217
|
+
const refreshCallsArr = localInvalidations.map(fn => {
|
|
218
|
+
// Check if the target function actually takes a path parameter
|
|
219
|
+
const targetOp = allOpsInTag.find(o => operationIdToFunctionName(o.operationId) === fn);
|
|
220
|
+
const targetPathParams = targetOp?.parameters.filter(p => p.in === 'path') ?? [];
|
|
221
|
+
|
|
222
|
+
if (targetPathParams.length > 0) {
|
|
223
|
+
// Find matching path param names between the command and the target query
|
|
224
|
+
const matchingParams = targetPathParams.filter(p => commandPathParamNames.has(p.name));
|
|
225
|
+
|
|
226
|
+
if (matchingParams.length > 0) {
|
|
227
|
+
if (targetPathParams.length === 1) {
|
|
228
|
+
return `${fn}(${matchingParams[0].name}).refresh()`;
|
|
229
|
+
}
|
|
230
|
+
return `${fn}(${matchingParams[0].name}).refresh()`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Target requires path params the command can't provide - skip
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return `${fn}(undefined).refresh()`;
|
|
238
|
+
}).filter((call): call is string => call !== null);
|
|
239
|
+
|
|
240
|
+
const refreshCalls = refreshCallsArr.length > 0
|
|
241
|
+
? `\n await Promise.all([
|
|
242
|
+
${refreshCallsArr.join(',\n ')}
|
|
243
|
+
]);`
|
|
244
|
+
: '';
|
|
245
|
+
|
|
246
|
+
const comment = op.summary ? `/** ${op.summary} */\n` : '';
|
|
247
|
+
const catchBlock = generateCatchBlock(config, clientProperty, methodName, functionName);
|
|
248
|
+
const clientAccess = generateClientAccess(config);
|
|
249
|
+
|
|
250
|
+
// Handle void responses (204 No Content)
|
|
251
|
+
const apiCall = op.isVoidResponse
|
|
252
|
+
? ` await apiClient.${clientProperty}.${methodName}(${apiCallArgs});${refreshCalls}
|
|
253
|
+
return { success: true };`
|
|
254
|
+
: ` const result = await apiClient.${clientProperty}.${methodName}(${apiCallArgs});${refreshCalls}
|
|
255
|
+
return result;`;
|
|
256
|
+
|
|
257
|
+
if (schemaArg) {
|
|
258
|
+
// form() requires StandardSchemaV1<RemoteFormInput, Record<string, any>> but z.fromJSONSchema()
|
|
259
|
+
// returns ZodType<unknown> - cast to any to bypass the constraint while keeping runtime validation
|
|
260
|
+
const schemaArgStr = wrapperName === 'form' ? `${schemaArg} as any` : schemaArg;
|
|
261
|
+
return `${comment}export const ${functionName} = ${wrapperName}(${schemaArgStr}, async (${paramList}) => {
|
|
262
|
+
${clientAccess}
|
|
263
|
+
try {
|
|
264
|
+
${apiCall}
|
|
265
|
+
${catchBlock}
|
|
266
|
+
});`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return `${comment}export const ${functionName} = ${wrapperName}(async () => {
|
|
270
|
+
${clientAccess}
|
|
271
|
+
try {
|
|
272
|
+
${apiCall}
|
|
273
|
+
${catchBlock}
|
|
274
|
+
});`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function buildParameterMapping(op: OperationInfo): {
|
|
278
|
+
schemaArg: string;
|
|
279
|
+
paramList: string;
|
|
280
|
+
apiCallArgs: string;
|
|
281
|
+
} {
|
|
282
|
+
const pathParams = op.parameters.filter(p => p.in === 'path');
|
|
283
|
+
const queryParams = op.parameters.filter(p => p.in === 'query');
|
|
284
|
+
const hasNamedBody = !!op.requestBodySchema;
|
|
285
|
+
const hasInlineBody = !!op.inlineRequestBody;
|
|
286
|
+
const hasBody = hasNamedBody || hasInlineBody;
|
|
287
|
+
|
|
288
|
+
// Single path param only (e.g., GET /trackers/{id})
|
|
289
|
+
if (pathParams.length === 1 && queryParams.length === 0 && !hasBody) {
|
|
290
|
+
const param = pathParams[0];
|
|
291
|
+
const zodType = param.enumName ? `z.enum(${param.enumName})` : 'z.string()';
|
|
292
|
+
return {
|
|
293
|
+
schemaArg: zodType,
|
|
294
|
+
paramList: param.name,
|
|
295
|
+
apiCallArgs: param.name,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Multiple path params, no request body
|
|
300
|
+
if (pathParams.length > 1 && queryParams.length === 0 && !hasBody) {
|
|
301
|
+
const fields = pathParams.map(p => {
|
|
302
|
+
const zodType = p.enumName ? `z.enum(${p.enumName})` : 'z.string()';
|
|
303
|
+
return `${p.name}: ${zodType}`;
|
|
304
|
+
});
|
|
305
|
+
const paramNames = pathParams.map(p => p.name);
|
|
306
|
+
return {
|
|
307
|
+
schemaArg: `z.object({ ${fields.join(', ')} })`,
|
|
308
|
+
paramList: `{ ${paramNames.join(', ')} }`,
|
|
309
|
+
apiCallArgs: paramNames.join(', '),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Request body only (named schema)
|
|
314
|
+
if (pathParams.length === 0 && queryParams.length === 0 && hasNamedBody) {
|
|
315
|
+
const typeName = op.requestBodySchema!.replace(/Schema$/, '');
|
|
316
|
+
const bodyOptional = !op.requestBodyRequired;
|
|
317
|
+
if (op.isArrayBody) {
|
|
318
|
+
return {
|
|
319
|
+
schemaArg: `z.array(${op.requestBodySchema})`,
|
|
320
|
+
paramList: 'request',
|
|
321
|
+
apiCallArgs: `request as ${typeName}[]`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (bodyOptional) {
|
|
325
|
+
return {
|
|
326
|
+
schemaArg: `${op.requestBodySchema}.optional()`,
|
|
327
|
+
paramList: 'request',
|
|
328
|
+
apiCallArgs: `(request ?? {}) as ${typeName}`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
schemaArg: op.requestBodySchema!,
|
|
333
|
+
paramList: 'request',
|
|
334
|
+
apiCallArgs: `request as ${typeName}`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Inline request body only
|
|
339
|
+
if (pathParams.length === 0 && queryParams.length === 0 && hasInlineBody) {
|
|
340
|
+
const inline = op.inlineRequestBody!;
|
|
341
|
+
const bodyOptional = !op.requestBodyRequired;
|
|
342
|
+
const schemaArg = bodyOptional ? `${inline.zodSchema}.optional()` : inline.zodSchema;
|
|
343
|
+
const requestArg = bodyOptional
|
|
344
|
+
? `(request ?? {}) as ${inline.tsType}`
|
|
345
|
+
: `request as ${inline.tsType}`;
|
|
346
|
+
return {
|
|
347
|
+
schemaArg,
|
|
348
|
+
paramList: 'request',
|
|
349
|
+
apiCallArgs: requestArg,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Path param(s) + named request body
|
|
354
|
+
if (pathParams.length >= 1 && hasNamedBody) {
|
|
355
|
+
const typeName = op.requestBodySchema!.replace(/Schema$/, '');
|
|
356
|
+
const bodyOptional = !op.requestBodyRequired;
|
|
357
|
+
const pathFields = pathParams.map(p => {
|
|
358
|
+
const zodType = p.enumName ? `z.enum(${p.enumName})` : 'z.string()';
|
|
359
|
+
return `${p.name}: ${zodType}`;
|
|
360
|
+
});
|
|
361
|
+
const requestField = bodyOptional
|
|
362
|
+
? `request: ${op.requestBodySchema}.optional()`
|
|
363
|
+
: `request: ${op.requestBodySchema}`;
|
|
364
|
+
const requestArg = bodyOptional
|
|
365
|
+
? `(request ?? {}) as ${typeName}`
|
|
366
|
+
: `request as ${typeName}`;
|
|
367
|
+
const paramNames = pathParams.map(p => p.name);
|
|
368
|
+
return {
|
|
369
|
+
schemaArg: `z.object({ ${pathFields.join(', ')}, ${requestField} })`,
|
|
370
|
+
paramList: `{ ${paramNames.join(', ')}, request }`,
|
|
371
|
+
apiCallArgs: `${paramNames.join(', ')}, ${requestArg}`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Path param(s) + inline request body
|
|
376
|
+
if (pathParams.length >= 1 && hasInlineBody) {
|
|
377
|
+
const inline = op.inlineRequestBody!;
|
|
378
|
+
const bodyOptional = !op.requestBodyRequired;
|
|
379
|
+
const pathFields = pathParams.map(p => {
|
|
380
|
+
const zodType = p.enumName ? `z.enum(${p.enumName})` : 'z.string()';
|
|
381
|
+
return `${p.name}: ${zodType}`;
|
|
382
|
+
});
|
|
383
|
+
const requestField = bodyOptional
|
|
384
|
+
? `request: ${inline.zodSchema}.optional()`
|
|
385
|
+
: `request: ${inline.zodSchema}`;
|
|
386
|
+
const requestArg = bodyOptional
|
|
387
|
+
? `(request ?? {}) as ${inline.tsType}`
|
|
388
|
+
: `request as ${inline.tsType}`;
|
|
389
|
+
const paramNames = pathParams.map(p => p.name);
|
|
390
|
+
return {
|
|
391
|
+
schemaArg: `z.object({ ${pathFields.join(', ')}, ${requestField} })`,
|
|
392
|
+
paramList: `{ ${paramNames.join(', ')}, request }`,
|
|
393
|
+
apiCallArgs: `${paramNames.join(', ')}, ${requestArg}`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Path param(s) + query param(s), no body
|
|
398
|
+
if (pathParams.length >= 1 && queryParams.length > 0 && !hasBody) {
|
|
399
|
+
const pathFields = pathParams.map(p => {
|
|
400
|
+
const zodType = p.enumName ? `z.enum(${p.enumName})` : 'z.string()';
|
|
401
|
+
return `${p.name}: ${zodType}`;
|
|
402
|
+
});
|
|
403
|
+
const queryFields = queryParams.map(p => {
|
|
404
|
+
const zodType = p.enumName ? `z.enum(${p.enumName})` : getZodTypeForParam(p);
|
|
405
|
+
return `${p.name}: ${zodType}${p.required ? '' : '.optional()'}`;
|
|
406
|
+
});
|
|
407
|
+
const allFields = [...pathFields, ...queryFields];
|
|
408
|
+
const pathParamNames = pathParams.map(p => p.name);
|
|
409
|
+
const queryParamAccess = queryParams.map(p => `params.${p.name}`);
|
|
410
|
+
return {
|
|
411
|
+
schemaArg: `z.object({ ${allFields.join(', ')} })`,
|
|
412
|
+
paramList: 'params',
|
|
413
|
+
apiCallArgs: [...pathParamNames.map(n => `params.${n}`), ...queryParamAccess].join(', '),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Query params only
|
|
418
|
+
if (queryParams.length > 0 && !hasBody) {
|
|
419
|
+
const fields = queryParams.map(p => {
|
|
420
|
+
const zodType = p.enumName ? `z.enum(${p.enumName})` : getZodTypeForParam(p);
|
|
421
|
+
return `${p.name}: ${zodType}${p.required ? '' : '.optional()'}`;
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const schemaArg = `z.object({ ${fields.join(', ')} }).optional()`;
|
|
425
|
+
const paramList = 'params';
|
|
426
|
+
const apiCallArgs = queryParams.map(p => `params?.${p.name}`).join(', ');
|
|
427
|
+
|
|
428
|
+
return { schemaArg, paramList, apiCallArgs };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// No params
|
|
432
|
+
return {
|
|
433
|
+
schemaArg: '',
|
|
434
|
+
paramList: '',
|
|
435
|
+
apiCallArgs: '',
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function getZodType(type: string): string {
|
|
440
|
+
switch (type) {
|
|
441
|
+
case 'boolean': return 'z.boolean()';
|
|
442
|
+
case 'number': return 'z.number()';
|
|
443
|
+
case 'integer': return 'z.number().int()';
|
|
444
|
+
case 'Date': return 'z.coerce.date()';
|
|
445
|
+
default: return 'z.string()';
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Get Zod type for a parameter, handling array types by wrapping the item type.
|
|
451
|
+
*/
|
|
452
|
+
function getZodTypeForParam(param: ParameterInfo): string {
|
|
453
|
+
if (param.type === 'array') {
|
|
454
|
+
const itemZod = getZodType(param.itemType ?? 'string');
|
|
455
|
+
return `z.array(${itemZod})`;
|
|
456
|
+
}
|
|
457
|
+
return getZodType(param.type);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Get the method name from operationId.
|
|
462
|
+
* NSwag generates method names by removing the tag prefix.
|
|
463
|
+
* "Trackers_GetDefinitions" -> "getDefinitions"
|
|
464
|
+
*/
|
|
465
|
+
function getMethodName(operationId: string, tag: string): string {
|
|
466
|
+
const parts = operationId.split('_');
|
|
467
|
+
const name = parts.length > 1 ? parts.slice(1).join('_') : parts[0];
|
|
468
|
+
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function generateIndexFile(byTag: Map<string, OperationInfo[]>): string {
|
|
472
|
+
// Build map of function name -> list of tags that export it
|
|
473
|
+
const nameToTags = new Map<string, string[]>();
|
|
474
|
+
for (const [tag, operations] of byTag) {
|
|
475
|
+
for (const op of operations) {
|
|
476
|
+
const fnName = operationIdToFunctionName(op.operationId);
|
|
477
|
+
const existing = nameToTags.get(fnName) ?? [];
|
|
478
|
+
existing.push(tag);
|
|
479
|
+
nameToTags.set(fnName, existing);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Find names that appear in multiple tags (collisions)
|
|
484
|
+
const collidingNames = new Set<string>();
|
|
485
|
+
for (const [name, tags] of nameToTags) {
|
|
486
|
+
if (tags.length > 1) collidingNames.add(name);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Find tags that have at least one colliding export
|
|
490
|
+
const tagsWithCollisions = new Set<string>();
|
|
491
|
+
for (const [name, tags] of nameToTags) {
|
|
492
|
+
if (tags.length > 1) {
|
|
493
|
+
for (const tag of tags) tagsWithCollisions.add(tag);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const lines: string[] = [];
|
|
498
|
+
for (const [tag, operations] of byTag) {
|
|
499
|
+
const fileName = tagToFileName(tag);
|
|
500
|
+
if (!tagsWithCollisions.has(tag)) {
|
|
501
|
+
// No collisions - safe to export *
|
|
502
|
+
lines.push(`export * from './${fileName}.generated.remote';`);
|
|
503
|
+
} else {
|
|
504
|
+
// Has collisions - use explicit named exports, skipping colliding names
|
|
505
|
+
const safeNames = operations
|
|
506
|
+
.map(op => operationIdToFunctionName(op.operationId))
|
|
507
|
+
.filter(name => !collidingNames.has(name));
|
|
508
|
+
if (safeNames.length > 0) {
|
|
509
|
+
lines.push(`export { ${safeNames.join(', ')} } from './${fileName}.generated.remote';`);
|
|
510
|
+
} else {
|
|
511
|
+
lines.push(`// './${fileName}.generated.remote' - all exports collide, import directly`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return `// AUTO-GENERATED - DO NOT EDIT
|
|
517
|
+
// Generated by openapi-remote-codegen
|
|
518
|
+
|
|
519
|
+
${lines.join('\n')}
|
|
520
|
+
`;
|
|
521
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync } from 'fs';
|
|
3
|
+
import { resolve, dirname } from 'path';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
import { resolveConfig, type UserConfig } from './config.js';
|
|
6
|
+
import { parseOpenApiSpec } from './parser.js';
|
|
7
|
+
import { generateRemoteFunctions } from './generators/remote-functions.js';
|
|
8
|
+
import { generateApiClient } from './generators/api-client.js';
|
|
9
|
+
|
|
10
|
+
async function loadConfig(): Promise<UserConfig> {
|
|
11
|
+
const configNames = [
|
|
12
|
+
'remote-codegen.config.ts',
|
|
13
|
+
'remote-codegen.config.js',
|
|
14
|
+
'remote-codegen.config.mjs',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// Check for --config flag
|
|
18
|
+
const configFlagIndex = process.argv.indexOf('--config');
|
|
19
|
+
if (configFlagIndex !== -1 && process.argv[configFlagIndex + 1]) {
|
|
20
|
+
const configPath = resolve(process.cwd(), process.argv[configFlagIndex + 1]);
|
|
21
|
+
if (!existsSync(configPath)) {
|
|
22
|
+
console.error(`Config file not found: ${configPath}`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const mod = await import(pathToFileURL(configPath).href);
|
|
26
|
+
return mod.default ?? mod;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Auto-discover
|
|
30
|
+
for (const name of configNames) {
|
|
31
|
+
const configPath = resolve(process.cwd(), name);
|
|
32
|
+
if (existsSync(configPath)) {
|
|
33
|
+
console.log(`Using config: ${name}`);
|
|
34
|
+
const mod = await import(pathToFileURL(configPath).href);
|
|
35
|
+
return mod.default ?? mod;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
const userConfig = await loadConfig();
|
|
44
|
+
const config = resolveConfig(userConfig);
|
|
45
|
+
|
|
46
|
+
console.log('OpenAPI Remote Function Generator');
|
|
47
|
+
console.log('=================================\n');
|
|
48
|
+
|
|
49
|
+
const specPath = resolve(process.cwd(), config.openApiPath);
|
|
50
|
+
if (!existsSync(specPath)) {
|
|
51
|
+
console.error(`OpenAPI spec not found: ${specPath}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const spec = JSON.parse(readFileSync(specPath, 'utf-8'));
|
|
56
|
+
console.log(`Loaded OpenAPI spec: ${spec.info?.title ?? 'unknown'} v${spec.info?.version ?? 'unknown'}`);
|
|
57
|
+
|
|
58
|
+
const parsed = parseOpenApiSpec(spec);
|
|
59
|
+
console.log(`Found ${parsed.operations.length} annotated operations across ${parsed.tags.length} tags.\n`);
|
|
60
|
+
|
|
61
|
+
if (parsed.operations.length === 0) {
|
|
62
|
+
console.log('No operations with x-remote-* extensions found.');
|
|
63
|
+
console.log('Add x-remote-type to your OpenAPI operations, or use the companion attributes package.');
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Generate remote functions
|
|
68
|
+
console.log('Generating remote functions...');
|
|
69
|
+
const remoteFunctions = generateRemoteFunctions(parsed, config);
|
|
70
|
+
|
|
71
|
+
const remoteFunctionsDir = resolve(config.outputDir, config.remoteFunctionsOutput);
|
|
72
|
+
mkdirSync(remoteFunctionsDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
// Clean up stale generated files
|
|
75
|
+
const generatedFileNames = new Set(remoteFunctions.keys());
|
|
76
|
+
if (existsSync(remoteFunctionsDir)) {
|
|
77
|
+
for (const existing of readdirSync(remoteFunctionsDir)) {
|
|
78
|
+
if (existing.endsWith('.generated.remote.ts') && !generatedFileNames.has(existing)) {
|
|
79
|
+
unlinkSync(resolve(remoteFunctionsDir, existing));
|
|
80
|
+
console.log(` Removed stale: ${config.remoteFunctionsOutput}/${existing}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const [fileName, content] of remoteFunctions) {
|
|
86
|
+
const filePath = resolve(remoteFunctionsDir, fileName);
|
|
87
|
+
writeFileSync(filePath, content, 'utf-8');
|
|
88
|
+
console.log(` Generated: ${config.remoteFunctionsOutput}/${fileName}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Generate ApiClient
|
|
92
|
+
console.log('\nGenerating ApiClient...');
|
|
93
|
+
const apiClientContent = generateApiClient(spec, config);
|
|
94
|
+
const apiClientPath = resolve(config.outputDir, config.apiClientOutput);
|
|
95
|
+
mkdirSync(dirname(apiClientPath), { recursive: true });
|
|
96
|
+
writeFileSync(apiClientPath, apiClientContent, 'utf-8');
|
|
97
|
+
console.log(` Generated: ${config.apiClientOutput}`);
|
|
98
|
+
|
|
99
|
+
console.log('\nDone!');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
main().catch((err) => {
|
|
103
|
+
console.error(err);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|