@pikku/openapi-parser 0.12.9 → 0.12.10
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/CHANGELOG.md +15 -0
- package/dist/codegen.d.ts +1 -0
- package/dist/codegen.js +586 -66
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/naming.d.ts +7 -0
- package/dist/naming.js +136 -11
- package/dist/openapi-to-zod-schema.d.ts +20 -7
- package/dist/openapi-to-zod-schema.js +245 -33
- package/dist/parse-openapi.js +22 -9
- package/dist/zod-codegen.d.ts +1 -1
- package/dist/zod-codegen.js +1 -1
- package/package.json +1 -1
- package/src/codegen.test.ts +782 -0
- package/src/codegen.ts +726 -74
- package/src/index.ts +2 -3
- package/src/naming.test.ts +260 -0
- package/src/naming.ts +155 -14
- package/src/openapi-to-zod-schema.test.ts +664 -0
- package/src/openapi-to-zod-schema.ts +270 -35
- package/src/parse-openapi.ts +22 -9
- package/src/zod-codegen.ts +1 -0
package/dist/codegen.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { schemaToZod, schemaVarName, sanitizeTypeName, createContext, } from './zod-codegen.js';
|
|
1
|
+
import { schemaToZod, schemaVarName, sanitizeTypeName, createContext, snakeToCamel, } from './zod-codegen.js';
|
|
2
2
|
import { generateOperationNames, detectCommonPrefix, } from './naming.js';
|
|
3
3
|
function safeKey(key) {
|
|
4
4
|
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
|
@@ -44,6 +44,130 @@ function getErrorClassesForResponses(errorResponses) {
|
|
|
44
44
|
}
|
|
45
45
|
return classes;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Collect all component schema names directly referenced by an operation's
|
|
49
|
+
* params, request body, and response schema.
|
|
50
|
+
*/
|
|
51
|
+
function collectOperationSchemaRefs(parsed, componentNames, schemaIdentityMap) {
|
|
52
|
+
const refs = new Set();
|
|
53
|
+
const allSchemas = [];
|
|
54
|
+
// Gather all schema objects from this operation
|
|
55
|
+
for (const p of [
|
|
56
|
+
...parsed.pathParams,
|
|
57
|
+
...parsed.queryParams,
|
|
58
|
+
...parsed.headerParams,
|
|
59
|
+
]) {
|
|
60
|
+
if (p.schema)
|
|
61
|
+
allSchemas.push(p.schema);
|
|
62
|
+
}
|
|
63
|
+
if (parsed.requestBody)
|
|
64
|
+
allSchemas.push(parsed.requestBody);
|
|
65
|
+
if (parsed.responseSchema)
|
|
66
|
+
allSchemas.push(parsed.responseSchema);
|
|
67
|
+
for (const schema of allSchemas) {
|
|
68
|
+
// Check if the schema itself IS a component schema (by object identity).
|
|
69
|
+
// collectSchemaRefs skips the root identity check, so we must do it here.
|
|
70
|
+
if (schema && typeof schema === 'object') {
|
|
71
|
+
const identityName = schemaIdentityMap.get(schema);
|
|
72
|
+
if (identityName && componentNames.has(identityName)) {
|
|
73
|
+
refs.add(identityName);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const ref of collectSchemaRefs(schema, componentNames, schemaIdentityMap)) {
|
|
77
|
+
refs.add(ref);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return refs;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Compute the transitive closure of schema dependencies.
|
|
84
|
+
* Given a set of schema names, expand it to include all schemas they depend on.
|
|
85
|
+
*/
|
|
86
|
+
function transitiveClosure(schemaNames, allSchemas, componentNames, schemaIdentityMap) {
|
|
87
|
+
const result = new Set();
|
|
88
|
+
const queue = [...schemaNames];
|
|
89
|
+
while (queue.length > 0) {
|
|
90
|
+
const name = queue.pop();
|
|
91
|
+
if (result.has(name))
|
|
92
|
+
continue;
|
|
93
|
+
if (!allSchemas[name])
|
|
94
|
+
continue;
|
|
95
|
+
result.add(name);
|
|
96
|
+
const deps = collectSchemaRefs(allSchemas[name], componentNames, schemaIdentityMap);
|
|
97
|
+
for (const dep of deps) {
|
|
98
|
+
if (!result.has(dep))
|
|
99
|
+
queue.push(dep);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Partition component schemas into shared, single-use, and unused buckets.
|
|
106
|
+
*
|
|
107
|
+
* Algorithm:
|
|
108
|
+
* 1. For each operation, collect its directly referenced component schemas
|
|
109
|
+
* 2. Expand each operation's refs to include transitive deps
|
|
110
|
+
* 3. Count how many operations reference each schema (via transitive closure)
|
|
111
|
+
* 4. Schemas with 2+ operations → shared, 1 operation → single, 0 → unused
|
|
112
|
+
* 5. Single-use schemas whose transitive deps include shared schemas still go
|
|
113
|
+
* into single-use (the function file will import those shared deps)
|
|
114
|
+
*/
|
|
115
|
+
function partitionSchemas(spec, schemaIdentityMap) {
|
|
116
|
+
const componentNames = new Set(Object.keys(spec.componentSchemas));
|
|
117
|
+
// Step 1: Direct refs per operation
|
|
118
|
+
const opDirectRefs = spec.operations.map((op) => collectOperationSchemaRefs(op, componentNames, schemaIdentityMap));
|
|
119
|
+
// Step 2: Transitive refs per operation
|
|
120
|
+
const opTransitiveRefs = opDirectRefs.map((directRefs) => transitiveClosure(directRefs, spec.componentSchemas, componentNames, schemaIdentityMap));
|
|
121
|
+
// Step 3: Count how many operations reference each schema
|
|
122
|
+
const refCount = new Map();
|
|
123
|
+
for (let i = 0; i < opTransitiveRefs.length; i++) {
|
|
124
|
+
for (const name of opTransitiveRefs[i]) {
|
|
125
|
+
if (!refCount.has(name))
|
|
126
|
+
refCount.set(name, new Set());
|
|
127
|
+
refCount.get(name).add(i);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Step 4: Partition
|
|
131
|
+
const shared = new Set();
|
|
132
|
+
const single = new Map();
|
|
133
|
+
const unused = new Set();
|
|
134
|
+
for (const name of componentNames) {
|
|
135
|
+
const ops = refCount.get(name);
|
|
136
|
+
if (!ops || ops.size === 0) {
|
|
137
|
+
unused.add(name);
|
|
138
|
+
}
|
|
139
|
+
else if (ops.size === 1) {
|
|
140
|
+
single.set(name, [...ops][0]);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
shared.add(name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Step 5: If a single-use schema transitively depends on another single-use
|
|
147
|
+
// schema from a DIFFERENT operation, promote both to shared.
|
|
148
|
+
// This handles chains like: OpA → SchemaX → SchemaY ← OpB
|
|
149
|
+
// Also: single-use schemas that depend on other single-use schemas from the
|
|
150
|
+
// same operation stay single-use (they'll be inlined together).
|
|
151
|
+
let changed = true;
|
|
152
|
+
while (changed) {
|
|
153
|
+
changed = false;
|
|
154
|
+
for (const [name, opIdx] of [...single]) {
|
|
155
|
+
const deps = collectSchemaRefs(spec.componentSchemas[name], componentNames, schemaIdentityMap);
|
|
156
|
+
for (const dep of deps) {
|
|
157
|
+
if (single.has(dep) && single.get(dep) !== opIdx) {
|
|
158
|
+
// Dep is single-use but for a different operation — promote both
|
|
159
|
+
shared.add(dep);
|
|
160
|
+
single.delete(dep);
|
|
161
|
+
shared.add(name);
|
|
162
|
+
single.delete(name);
|
|
163
|
+
changed = true;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { shared, single, unused };
|
|
170
|
+
}
|
|
47
171
|
export function generateAddonFromOpenAPI(spec, vars, flags) {
|
|
48
172
|
const files = {};
|
|
49
173
|
const { name } = vars;
|
|
@@ -52,7 +176,41 @@ export function generateAddonFromOpenAPI(spec, vars, flags) {
|
|
|
52
176
|
for (const schemaName of Object.keys(spec.componentSchemas)) {
|
|
53
177
|
schemaRefs.set(schemaName, schemaVarName(schemaName));
|
|
54
178
|
}
|
|
55
|
-
|
|
179
|
+
// Build an identity map from resolved schema objects to their component names.
|
|
180
|
+
// After $ref resolution, many references become the same JS object as the
|
|
181
|
+
// component schema (or a spread-copy). This map lets the codegen detect those
|
|
182
|
+
// resolved references by object identity instead of relying on $ref strings.
|
|
183
|
+
const schemaIdentityMap = new Map();
|
|
184
|
+
for (const [schemaName, schema] of Object.entries(spec.componentSchemas)) {
|
|
185
|
+
if (schema && typeof schema === 'object') {
|
|
186
|
+
schemaIdentityMap.set(schema, schemaName);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Partition schemas: shared (2+ ops), single-use (1 op), unused (0 ops)
|
|
190
|
+
const partition = partitionSchemas(spec, schemaIdentityMap);
|
|
191
|
+
// Build the set of schemas that go in the shared types file:
|
|
192
|
+
// shared schemas + their transitive dependencies
|
|
193
|
+
const componentNames = new Set(Object.keys(spec.componentSchemas));
|
|
194
|
+
const sharedWithDeps = transitiveClosure(partition.shared, spec.componentSchemas, componentNames, schemaIdentityMap);
|
|
195
|
+
// Any transitive dep of a shared schema that was single-use gets promoted to shared
|
|
196
|
+
for (const name of sharedWithDeps) {
|
|
197
|
+
partition.single.delete(name);
|
|
198
|
+
}
|
|
199
|
+
// Build a reduced componentSchemas for the types file (only shared schemas)
|
|
200
|
+
const sharedComponentSchemas = {};
|
|
201
|
+
for (const schemaName of sharedWithDeps) {
|
|
202
|
+
if (spec.componentSchemas[schemaName]) {
|
|
203
|
+
sharedComponentSchemas[schemaName] = spec.componentSchemas[schemaName];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// schemaRefs for shared types file context — only shared schemas
|
|
207
|
+
const sharedSchemaRefs = new Map();
|
|
208
|
+
for (const schemaName of sharedWithDeps) {
|
|
209
|
+
sharedSchemaRefs.set(schemaName, schemaVarName(schemaName));
|
|
210
|
+
}
|
|
211
|
+
const ctx = createContext(sharedSchemaRefs, schemaIdentityMap, {
|
|
212
|
+
camelCase: flags.camelCase,
|
|
213
|
+
});
|
|
56
214
|
// Generate operation names
|
|
57
215
|
const paths = spec.operations.map((op) => op.path);
|
|
58
216
|
const commonPrefix = detectCommonPrefix(paths);
|
|
@@ -64,14 +222,35 @@ export function generateAddonFromOpenAPI(spec, vars, flags) {
|
|
|
64
222
|
// Pair named operations with their parsed data
|
|
65
223
|
const opPairs = namedOps.map((named, i) => ({ named, parsed: spec.operations[i] }));
|
|
66
224
|
// Generate types file with shared schemas (only if there are any)
|
|
67
|
-
if (Object.keys(
|
|
68
|
-
files[`src/${name}.types.ts`] = generateTypesFile(spec, ctx);
|
|
225
|
+
if (Object.keys(sharedComponentSchemas).length > 0) {
|
|
226
|
+
files[`src/${name}.types.ts`] = generateTypesFile({ ...spec, componentSchemas: sharedComponentSchemas }, ctx, schemaIdentityMap);
|
|
227
|
+
}
|
|
228
|
+
// Collect single-use schemas per operation index
|
|
229
|
+
const singleSchemasPerOp = new Map();
|
|
230
|
+
for (const [schemaName, opIdx] of partition.single) {
|
|
231
|
+
if (!singleSchemasPerOp.has(opIdx))
|
|
232
|
+
singleSchemasPerOp.set(opIdx, new Set());
|
|
233
|
+
singleSchemasPerOp.get(opIdx).add(schemaName);
|
|
234
|
+
}
|
|
235
|
+
// Expand single-use schemas to include their transitive deps that are also single-use
|
|
236
|
+
for (const [_opIdx, schemas] of singleSchemasPerOp) {
|
|
237
|
+
const expanded = transitiveClosure(schemas, spec.componentSchemas, componentNames, schemaIdentityMap);
|
|
238
|
+
// Only keep schemas that are NOT in the shared set
|
|
239
|
+
for (const s of expanded) {
|
|
240
|
+
if (!sharedWithDeps.has(s) && !partition.unused.has(s)) {
|
|
241
|
+
schemas.add(s);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
69
244
|
}
|
|
70
245
|
// Generate function files
|
|
71
246
|
const functionExports = [];
|
|
72
|
-
for (
|
|
73
|
-
const
|
|
74
|
-
const
|
|
247
|
+
for (let i = 0; i < opPairs.length; i++) {
|
|
248
|
+
const { named, parsed } = opPairs[i];
|
|
249
|
+
const funcCtx = createContext(schemaRefs, schemaIdentityMap, {
|
|
250
|
+
camelCase: flags.camelCase,
|
|
251
|
+
});
|
|
252
|
+
const inlineSchemas = singleSchemasPerOp.get(i) ?? new Set();
|
|
253
|
+
const funcCode = generateFunctionFile(named, parsed, vars, funcCtx, spec, flags, inlineSchemas, sharedWithDeps, schemaIdentityMap);
|
|
75
254
|
files[`src/functions/${named.functionName}.function.ts`] = funcCode;
|
|
76
255
|
functionExports.push(named.functionName);
|
|
77
256
|
}
|
|
@@ -83,43 +262,216 @@ export function generateAddonFromOpenAPI(spec, vars, flags) {
|
|
|
83
262
|
files[`src/${name}.variable.ts`] = generateVariableFile(spec, vars);
|
|
84
263
|
return files;
|
|
85
264
|
}
|
|
86
|
-
|
|
265
|
+
/**
|
|
266
|
+
* Collect all component schema names referenced (directly or via properties/items/allOf/oneOf/anyOf)
|
|
267
|
+
* by a given OpenAPI schema object.
|
|
268
|
+
*
|
|
269
|
+
* After $ref resolution, most $ref strings are gone and replaced by the resolved
|
|
270
|
+
* object (often the same JS identity as the component schema). We detect
|
|
271
|
+
* dependencies both by surviving $ref strings AND by object identity via
|
|
272
|
+
* schemaIdentityMap.
|
|
273
|
+
*/
|
|
274
|
+
function collectSchemaRefs(schema, componentNames, schemaIdentityMap) {
|
|
275
|
+
const refs = new Set();
|
|
276
|
+
const visited = new Set();
|
|
277
|
+
function walk(node, isRoot) {
|
|
278
|
+
if (!node || typeof node !== 'object' || visited.has(node))
|
|
279
|
+
return;
|
|
280
|
+
visited.add(node);
|
|
281
|
+
// Check surviving $ref strings
|
|
282
|
+
if (node.$ref) {
|
|
283
|
+
const refName = node.$ref.split('/').pop();
|
|
284
|
+
if (componentNames.has(refName))
|
|
285
|
+
refs.add(refName);
|
|
286
|
+
}
|
|
287
|
+
// Check if this node IS a known component schema (resolved $ref by identity).
|
|
288
|
+
// Skip this check for the root node (the schema we're analyzing),
|
|
289
|
+
// otherwise we'd match the schema against itself and return early,
|
|
290
|
+
// missing its internal dependencies.
|
|
291
|
+
if (!isRoot) {
|
|
292
|
+
const identityName = schemaIdentityMap.get(node);
|
|
293
|
+
if (identityName && componentNames.has(identityName)) {
|
|
294
|
+
refs.add(identityName);
|
|
295
|
+
// Don't walk into the component schema's internals — those are
|
|
296
|
+
// handled when that schema is processed as a top-level entry.
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (node.properties) {
|
|
301
|
+
for (const prop of Object.values(node.properties))
|
|
302
|
+
walk(prop, false);
|
|
303
|
+
}
|
|
304
|
+
if (node.items)
|
|
305
|
+
walk(node.items, false);
|
|
306
|
+
if (node.allOf)
|
|
307
|
+
for (const s of node.allOf)
|
|
308
|
+
walk(s, false);
|
|
309
|
+
if (node.oneOf)
|
|
310
|
+
for (const s of node.oneOf)
|
|
311
|
+
walk(s, false);
|
|
312
|
+
if (node.anyOf)
|
|
313
|
+
for (const s of node.anyOf)
|
|
314
|
+
walk(s, false);
|
|
315
|
+
if (node.additionalProperties &&
|
|
316
|
+
typeof node.additionalProperties === 'object') {
|
|
317
|
+
walk(node.additionalProperties, false);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
walk(schema, true);
|
|
321
|
+
return refs;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Topologically sort component schemas so that dependencies come before dependents.
|
|
325
|
+
* Schemas involved in cycles are placed in arbitrary order (cycles are handled via z.lazy).
|
|
326
|
+
*/
|
|
327
|
+
function topoSortSchemas(schemas, schemaIdentityMap) {
|
|
328
|
+
const names = new Set(Object.keys(schemas));
|
|
329
|
+
const deps = new Map();
|
|
330
|
+
const selfRefs = new Set();
|
|
331
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
332
|
+
const refNames = collectSchemaRefs(schema, names, schemaIdentityMap);
|
|
333
|
+
if (refNames.has(name)) {
|
|
334
|
+
selfRefs.add(name); // track self-referential schemas
|
|
335
|
+
}
|
|
336
|
+
refNames.delete(name); // remove self-refs from dependency graph
|
|
337
|
+
deps.set(name, refNames);
|
|
338
|
+
}
|
|
339
|
+
const sorted = [];
|
|
340
|
+
const visited = new Set();
|
|
341
|
+
const inStack = new Set();
|
|
342
|
+
const cyclicEdges = new Set(); // "child" schemas that are referenced before declaration
|
|
343
|
+
function visit(name) {
|
|
344
|
+
if (visited.has(name))
|
|
345
|
+
return;
|
|
346
|
+
if (inStack.has(name)) {
|
|
347
|
+
// This is a cycle — mark this name as needing z.lazy()
|
|
348
|
+
cyclicEdges.add(name);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
inStack.add(name);
|
|
352
|
+
const children = deps.get(name) ?? new Set();
|
|
353
|
+
for (const child of children) {
|
|
354
|
+
if (names.has(child))
|
|
355
|
+
visit(child);
|
|
356
|
+
}
|
|
357
|
+
inStack.delete(name);
|
|
358
|
+
visited.add(name);
|
|
359
|
+
sorted.push(name);
|
|
360
|
+
}
|
|
361
|
+
for (const name of names) {
|
|
362
|
+
visit(name);
|
|
363
|
+
}
|
|
364
|
+
// Self-referential schemas also need z.lazy() forward declarations
|
|
365
|
+
for (const name of selfRefs) {
|
|
366
|
+
cyclicEdges.add(name);
|
|
367
|
+
}
|
|
368
|
+
return { sorted, cyclicEdges };
|
|
369
|
+
}
|
|
370
|
+
function generateTypesFile(spec, ctx, schemaIdentityMap) {
|
|
87
371
|
const lines = [];
|
|
88
372
|
lines.push("import { z } from 'zod'");
|
|
89
373
|
lines.push('');
|
|
90
374
|
lines.push(`// Shared schemas from ${spec.info.title} v${spec.info.version}`);
|
|
91
375
|
lines.push('');
|
|
92
|
-
|
|
93
|
-
|
|
376
|
+
const { sorted, cyclicEdges } = topoSortSchemas(spec.componentSchemas, schemaIdentityMap);
|
|
377
|
+
// Track emitted variable names and type names to deduplicate collisions
|
|
378
|
+
// (e.g. two OpenAPI components that sanitize to the same JS identifier)
|
|
379
|
+
const emittedVarNames = new Set();
|
|
380
|
+
const emittedTypeNames = new Set();
|
|
381
|
+
function deduplicateName(baseName, usedSet) {
|
|
382
|
+
if (!usedSet.has(baseName)) {
|
|
383
|
+
usedSet.add(baseName);
|
|
384
|
+
return baseName;
|
|
385
|
+
}
|
|
386
|
+
let counter = 2;
|
|
387
|
+
while (usedSet.has(`${baseName}_${counter}`)) {
|
|
388
|
+
counter++;
|
|
389
|
+
}
|
|
390
|
+
const deduped = `${baseName}_${counter}`;
|
|
391
|
+
usedSet.add(deduped);
|
|
392
|
+
return deduped;
|
|
393
|
+
}
|
|
394
|
+
// For schemas that participate in cycles, emit a z.lazy() forward declaration
|
|
395
|
+
// before any schemas are defined, so forward references work.
|
|
396
|
+
if (cyclicEdges.size > 0) {
|
|
397
|
+
lines.push('// Forward declarations for circular references');
|
|
398
|
+
for (const name of cyclicEdges) {
|
|
399
|
+
const varName = deduplicateName(schemaVarName(name), emittedVarNames);
|
|
400
|
+
const typeName = deduplicateName(sanitizeTypeName(name), emittedTypeNames);
|
|
401
|
+
lines.push(`export const ${varName}: z.ZodType<any> = z.lazy(() => _${varName})`);
|
|
402
|
+
lines.push(`export type ${typeName} = z.infer<typeof ${varName}>`);
|
|
403
|
+
lines.push('');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Emit schemas in topological order
|
|
407
|
+
for (const name of sorted) {
|
|
408
|
+
const schema = spec.componentSchemas[name];
|
|
409
|
+
const varName = deduplicateName(schemaVarName(name), emittedVarNames);
|
|
410
|
+
const typeName = deduplicateName(sanitizeTypeName(name), emittedTypeNames);
|
|
411
|
+
// Temporarily remove the current schema from the identity map so that
|
|
412
|
+
// schemaToZod doesn't short-circuit to referencing the variable we're
|
|
413
|
+
// currently defining (which would produce `const XSchema = XSchema`).
|
|
414
|
+
ctx.schemaIdentityMap.delete(schema);
|
|
94
415
|
const zodCode = schemaToZod(schema, ctx);
|
|
95
|
-
|
|
96
|
-
|
|
416
|
+
// Restore it for subsequent schemas that may reference this one.
|
|
417
|
+
ctx.schemaIdentityMap.set(schema, name);
|
|
418
|
+
if (cyclicEdges.has(name)) {
|
|
419
|
+
// The public variable was already declared above via z.lazy();
|
|
420
|
+
// emit the real implementation with an underscore prefix.
|
|
421
|
+
lines.push(`const _${varName} = ${zodCode}`);
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
lines.push(`export const ${varName} = ${zodCode}`);
|
|
425
|
+
lines.push(`export type ${typeName} = z.infer<typeof ${varName}>`);
|
|
426
|
+
}
|
|
97
427
|
lines.push('');
|
|
98
428
|
}
|
|
99
429
|
return lines.join('\n');
|
|
100
430
|
}
|
|
101
|
-
function generateFunctionFile(named, parsed, vars, ctx, spec, flags) {
|
|
431
|
+
function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchemas = new Set(), sharedSchemas = new Set(), schemaIdentityMap = new Map()) {
|
|
102
432
|
const lines = [];
|
|
103
|
-
const { camelName } = vars;
|
|
433
|
+
const { camelName, name } = vars;
|
|
434
|
+
const hasInput = parsed.pathParams.length > 0 ||
|
|
435
|
+
parsed.queryParams.length > 0 ||
|
|
436
|
+
parsed.headerParams.length > 0 ||
|
|
437
|
+
parsed.requestBody;
|
|
438
|
+
const pascalName = named.functionName.charAt(0).toUpperCase() + named.functionName.slice(1);
|
|
439
|
+
const inputName = `${pascalName}Input`;
|
|
440
|
+
const outputName = `${pascalName}Output`;
|
|
441
|
+
// Pre-generate Zod code to discover which schema refs are used
|
|
442
|
+
let inputCode;
|
|
443
|
+
let outputCode;
|
|
444
|
+
if (hasInput) {
|
|
445
|
+
inputCode = buildInputSchema(parsed, ctx);
|
|
446
|
+
}
|
|
447
|
+
if (parsed.responseSchema) {
|
|
448
|
+
outputCode = buildOutputSchema(parsed.responseSchema, ctx);
|
|
449
|
+
}
|
|
450
|
+
// Bug 2: If generated Zod code is too large (> 500 lines), TypeScript
|
|
451
|
+
// can't infer the type (TS7056). Replace with z.any() to stay compilable.
|
|
452
|
+
const LINE_LIMIT = 500;
|
|
453
|
+
if (inputCode && inputCode.split('\n').length > LINE_LIMIT) {
|
|
454
|
+
inputCode = 'z.any()';
|
|
455
|
+
}
|
|
456
|
+
if (outputCode && outputCode.split('\n').length > LINE_LIMIT) {
|
|
457
|
+
outputCode = 'z.any()';
|
|
458
|
+
}
|
|
104
459
|
// Tag description as file header
|
|
105
460
|
if (parsed.tags.length > 0) {
|
|
106
461
|
const tag = parsed.tags[0];
|
|
107
462
|
const tagDesc = spec.tagDescriptions[tag];
|
|
108
463
|
if (tagDesc) {
|
|
109
|
-
|
|
464
|
+
const cleanDesc = tagDesc
|
|
465
|
+
.replace(/\r?\n/g, ' ')
|
|
466
|
+
.replace(/\s+/g, ' ')
|
|
467
|
+
.trim();
|
|
468
|
+
lines.push(`// ${tag} — ${cleanDesc}`);
|
|
110
469
|
lines.push('');
|
|
111
470
|
}
|
|
112
471
|
}
|
|
113
|
-
const hasInput = parsed.pathParams.length > 0 ||
|
|
114
|
-
parsed.queryParams.length > 0 ||
|
|
115
|
-
parsed.headerParams.length > 0 ||
|
|
116
|
-
parsed.requestBody;
|
|
117
|
-
const pascalName = named.functionName.charAt(0).toUpperCase() + named.functionName.slice(1);
|
|
118
|
-
const inputName = `${pascalName}Input`;
|
|
119
|
-
const outputName = `${pascalName}Output`;
|
|
120
472
|
// Determine error imports needed
|
|
121
473
|
const errorClasses = getErrorClassesForResponses(parsed.errorResponses);
|
|
122
|
-
const needsZod = hasInput || !!parsed.responseSchema;
|
|
474
|
+
const needsZod = hasInput || !!parsed.responseSchema || inlineSchemas.size > 0;
|
|
123
475
|
if (needsZod) {
|
|
124
476
|
lines.push("import { z } from 'zod'");
|
|
125
477
|
}
|
|
@@ -127,16 +479,122 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags) {
|
|
|
127
479
|
if (errorClasses.length > 0) {
|
|
128
480
|
lines.push(`import { ${errorClasses.join(', ')} } from '@pikku/core/errors'`);
|
|
129
481
|
}
|
|
482
|
+
// Import referenced component schemas from the types file.
|
|
483
|
+
// Only import schemas that are in the shared set (not inlined ones).
|
|
484
|
+
// In addition to the refs tracked through usedRefs, scan the generated Zod
|
|
485
|
+
// code for schema variable names that may have been missed (e.g. when
|
|
486
|
+
// a $ref survived resolution in a nested path not covered by usedRefs).
|
|
487
|
+
if (Object.keys(spec.componentSchemas).length > 0) {
|
|
488
|
+
const allGeneratedCode = [inputCode, outputCode].filter(Boolean).join('\n');
|
|
489
|
+
for (const [refName, varName] of ctx.schemaRefs) {
|
|
490
|
+
if (!ctx.usedRefs.has(refName) && allGeneratedCode.includes(varName)) {
|
|
491
|
+
ctx.usedRefs.add(refName);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const schemaImports = [
|
|
495
|
+
...new Set([...ctx.usedRefs]
|
|
496
|
+
.filter((refName) => sharedSchemas.has(refName)) // only import shared schemas
|
|
497
|
+
.map((refName) => ctx.schemaRefs.get(refName))
|
|
498
|
+
.filter(Boolean)),
|
|
499
|
+
].sort();
|
|
500
|
+
if (schemaImports.length > 0) {
|
|
501
|
+
lines.push(`import { ${schemaImports.join(', ')} } from '../${name}.types.js'`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
130
504
|
lines.push('');
|
|
505
|
+
// Emit inline (single-use) schema definitions before input/output
|
|
506
|
+
if (inlineSchemas.size > 0) {
|
|
507
|
+
// Build a mini component schemas map for just the inline schemas
|
|
508
|
+
const inlineComponentSchemas = {};
|
|
509
|
+
for (const schemaName of inlineSchemas) {
|
|
510
|
+
if (spec.componentSchemas[schemaName]) {
|
|
511
|
+
inlineComponentSchemas[schemaName] = spec.componentSchemas[schemaName];
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (Object.keys(inlineComponentSchemas).length > 0) {
|
|
515
|
+
// Create a context that knows about both shared (for imports) and inline schemas
|
|
516
|
+
const inlineCtx = createContext(ctx.schemaRefs, schemaIdentityMap, {
|
|
517
|
+
camelCase: flags.camelCase,
|
|
518
|
+
});
|
|
519
|
+
const { sorted, cyclicEdges } = topoSortSchemas(inlineComponentSchemas, schemaIdentityMap);
|
|
520
|
+
// Bug 4: Pre-generate all Zod code and detect forward references.
|
|
521
|
+
// The topological sort may miss dependencies when $ref resolution
|
|
522
|
+
// replaces references with copies (breaking object identity).
|
|
523
|
+
// Scan generated code for variable names that appear before their
|
|
524
|
+
// declaration and promote those to z.lazy() forward declarations.
|
|
525
|
+
const inlineSorted = sorted.filter((s) => inlineSchemas.has(s));
|
|
526
|
+
const generatedCode = new Map();
|
|
527
|
+
for (const sName of inlineSorted) {
|
|
528
|
+
const schema = spec.componentSchemas[sName];
|
|
529
|
+
inlineCtx.schemaIdentityMap.delete(schema);
|
|
530
|
+
const zodCode = schemaToZod(schema, inlineCtx);
|
|
531
|
+
inlineCtx.schemaIdentityMap.set(schema, sName);
|
|
532
|
+
generatedCode.set(sName, zodCode);
|
|
533
|
+
}
|
|
534
|
+
// Build a map of variable names to schema names for inline schemas
|
|
535
|
+
const inlineVarToName = new Map();
|
|
536
|
+
for (const sName of inlineSorted) {
|
|
537
|
+
inlineVarToName.set(schemaVarName(sName), sName);
|
|
538
|
+
}
|
|
539
|
+
// Detect forward references and self-references: for each schema in order,
|
|
540
|
+
// check if its generated code references a variable that appears later in
|
|
541
|
+
// the sorted list OR references its own variable (self-referential schema).
|
|
542
|
+
const forwardRefs = new Set(cyclicEdges);
|
|
543
|
+
const emittedSoFar = new Set();
|
|
544
|
+
for (const sName of inlineSorted) {
|
|
545
|
+
const code = generatedCode.get(sName);
|
|
546
|
+
const ownVarN = schemaVarName(sName);
|
|
547
|
+
// Self-reference: schema uses its own variable name in its definition
|
|
548
|
+
if (code.includes(ownVarN)) {
|
|
549
|
+
forwardRefs.add(sName);
|
|
550
|
+
}
|
|
551
|
+
for (const [varN, refName] of inlineVarToName) {
|
|
552
|
+
if (refName !== sName &&
|
|
553
|
+
!emittedSoFar.has(refName) &&
|
|
554
|
+
code.includes(varN)) {
|
|
555
|
+
// This schema references another inline schema not yet emitted
|
|
556
|
+
forwardRefs.add(refName);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
emittedSoFar.add(sName);
|
|
560
|
+
}
|
|
561
|
+
const emittedVarNames = new Set();
|
|
562
|
+
const emittedTypeNames = new Set();
|
|
563
|
+
// Forward declarations for cycles and forward references within inline schemas
|
|
564
|
+
if (forwardRefs.size > 0) {
|
|
565
|
+
lines.push('// Forward declarations for circular references');
|
|
566
|
+
for (const sName of forwardRefs) {
|
|
567
|
+
if (!inlineSchemas.has(sName))
|
|
568
|
+
continue;
|
|
569
|
+
const varN = schemaVarName(sName);
|
|
570
|
+
const typeN = sanitizeTypeName(sName);
|
|
571
|
+
emittedVarNames.add(varN);
|
|
572
|
+
emittedTypeNames.add(typeN);
|
|
573
|
+
lines.push(`const ${varN}: z.ZodType<any> = z.lazy(() => _${varN})`);
|
|
574
|
+
lines.push('');
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
for (const sName of inlineSorted) {
|
|
578
|
+
const varN = schemaVarName(sName);
|
|
579
|
+
emittedVarNames.add(varN);
|
|
580
|
+
const zodCode = generatedCode.get(sName);
|
|
581
|
+
if (forwardRefs.has(sName)) {
|
|
582
|
+
lines.push(`const _${varN} = ${zodCode}`);
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
lines.push(`const ${varN} = ${zodCode}`);
|
|
586
|
+
}
|
|
587
|
+
lines.push('');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
131
591
|
// Build Input schema (exported for pikku schema discovery)
|
|
132
|
-
if (hasInput) {
|
|
133
|
-
const inputCode = buildInputSchema(parsed, ctx);
|
|
592
|
+
if (hasInput && inputCode) {
|
|
134
593
|
lines.push(`export const ${inputName} = ${inputCode}`);
|
|
135
594
|
lines.push('');
|
|
136
595
|
}
|
|
137
596
|
// Build Output schema (exported for pikku schema discovery)
|
|
138
|
-
if (parsed.responseSchema) {
|
|
139
|
-
const outputCode = buildOutputSchema(parsed.responseSchema, ctx);
|
|
597
|
+
if (parsed.responseSchema && outputCode) {
|
|
140
598
|
lines.push(`export const ${outputName} = ${outputCode}`);
|
|
141
599
|
lines.push('');
|
|
142
600
|
}
|
|
@@ -157,9 +615,13 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags) {
|
|
|
157
615
|
if (flags.mcp) {
|
|
158
616
|
funcConfig.push(' mcp: true,');
|
|
159
617
|
}
|
|
160
|
-
|
|
618
|
+
// Bug 3: Avoid duplicate identifier when camelName is 'data' (TS2300)
|
|
619
|
+
const inputParamName = camelName === 'data' ? 'inputData' : 'data';
|
|
620
|
+
const funcParams = hasInput
|
|
621
|
+
? `{ ${camelName} }, ${inputParamName}`
|
|
622
|
+
: `{ ${camelName} }`;
|
|
161
623
|
const returnCast = parsed.responseSchema ? ' as any' : '';
|
|
162
|
-
funcConfig.push(` func: async (${funcParams}) => {`, ` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ?
|
|
624
|
+
funcConfig.push(` func: async (${funcParams}) => {`, ` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ? `, ${inputParamName}` : ''})${returnCast}`, ' },');
|
|
163
625
|
lines.push(`export const ${named.functionName} = pikkuSessionlessFunc({`);
|
|
164
626
|
lines.push(funcConfig.join('\n'));
|
|
165
627
|
lines.push('})');
|
|
@@ -168,35 +630,55 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags) {
|
|
|
168
630
|
}
|
|
169
631
|
function buildInputSchema(parsed, ctx) {
|
|
170
632
|
const props = [];
|
|
171
|
-
|
|
172
|
-
|
|
633
|
+
// Track which parameter names have been emitted to prevent duplicates.
|
|
634
|
+
// When shared path-level params and operation-level params overlap
|
|
635
|
+
// (e.g. both define "Version"), the later (operation-level) one wins.
|
|
636
|
+
const emittedNames = new Set();
|
|
637
|
+
// Deduplicate params: operation-level params override shared path-level
|
|
638
|
+
// params with the same name. Build deduplicated lists per category.
|
|
639
|
+
function deduplicateParams(params) {
|
|
640
|
+
const seen = new Map();
|
|
641
|
+
for (const param of params) {
|
|
642
|
+
seen.set(param.name, param); // last wins (operation-level comes after shared)
|
|
643
|
+
}
|
|
644
|
+
return [...seen.values()];
|
|
173
645
|
}
|
|
174
|
-
|
|
175
|
-
|
|
646
|
+
const useCamel = !!ctx.camelCase;
|
|
647
|
+
for (const param of deduplicateParams(parsed.pathParams)) {
|
|
648
|
+
if (!emittedNames.has(param.name)) {
|
|
649
|
+
emittedNames.add(param.name);
|
|
650
|
+
props.push(formatParamProp(param, ctx, useCamel));
|
|
651
|
+
}
|
|
176
652
|
}
|
|
177
|
-
for (const param of parsed.
|
|
178
|
-
|
|
653
|
+
for (const param of deduplicateParams(parsed.queryParams)) {
|
|
654
|
+
if (!emittedNames.has(param.name)) {
|
|
655
|
+
emittedNames.add(param.name);
|
|
656
|
+
props.push(formatParamProp(param, ctx, useCamel));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
for (const param of deduplicateParams(parsed.headerParams)) {
|
|
660
|
+
if (!emittedNames.has(param.name)) {
|
|
661
|
+
emittedNames.add(param.name);
|
|
662
|
+
props.push(formatParamProp(param, ctx, useCamel));
|
|
663
|
+
}
|
|
179
664
|
}
|
|
180
665
|
if (parsed.requestBody) {
|
|
181
666
|
if (parsed.requestBody.properties) {
|
|
182
|
-
const paramNames = new Set([
|
|
183
|
-
...parsed.pathParams.map((p) => p.name),
|
|
184
|
-
...parsed.queryParams.map((p) => p.name),
|
|
185
|
-
...parsed.headerParams.map((p) => p.name),
|
|
186
|
-
]);
|
|
187
667
|
const requiredSet = new Set(parsed.requestBody.required ?? []);
|
|
188
668
|
for (const [key, propSchema] of Object.entries(parsed.requestBody.properties)) {
|
|
189
669
|
// Skip readOnly properties from input
|
|
190
670
|
if (propSchema.readOnly)
|
|
191
671
|
continue;
|
|
192
|
-
// Skip body properties that collide with
|
|
193
|
-
if (
|
|
672
|
+
// Skip body properties that collide with already-emitted params
|
|
673
|
+
if (emittedNames.has(key)) {
|
|
194
674
|
console.warn(`[openapi] Skipping body property '${key}' — collides with path/query/header param`);
|
|
195
675
|
continue;
|
|
196
676
|
}
|
|
677
|
+
emittedNames.add(key);
|
|
197
678
|
const isOptional = !requiredSet.has(key);
|
|
198
679
|
const zodCode = schemaToZod(propSchema, ctx, { optional: isOptional });
|
|
199
|
-
|
|
680
|
+
const outputKey = useCamel ? snakeToCamel(key) : key;
|
|
681
|
+
props.push(` ${safeKey(outputKey)}: ${zodCode},`);
|
|
200
682
|
}
|
|
201
683
|
}
|
|
202
684
|
else {
|
|
@@ -206,29 +688,35 @@ function buildInputSchema(parsed, ctx) {
|
|
|
206
688
|
}
|
|
207
689
|
return `z.object({\n${props.join('\n')}\n})`;
|
|
208
690
|
}
|
|
209
|
-
function formatParamProp(param, ctx) {
|
|
691
|
+
function formatParamProp(param, ctx, camelCase = false) {
|
|
210
692
|
const zodCode = schemaToZod(param.schema, ctx, { optional: !param.required });
|
|
211
693
|
let descParts = [];
|
|
212
694
|
if (param.description)
|
|
213
|
-
descParts.push(param.description);
|
|
695
|
+
descParts.push(param.description.replace(/\*\//g, '* /'));
|
|
214
696
|
if (param.example !== undefined)
|
|
215
697
|
descParts.push(`Example: ${JSON.stringify(param.example)}`);
|
|
216
698
|
const desc = descParts.length > 0
|
|
217
699
|
? `${zodCode}.describe(${JSON.stringify(descParts.join('. '))})`
|
|
218
700
|
: zodCode;
|
|
219
|
-
|
|
701
|
+
const outputKey = camelCase ? snakeToCamel(param.name) : param.name;
|
|
702
|
+
return ` ${safeKey(outputKey)}: ${desc},`;
|
|
220
703
|
}
|
|
221
704
|
function buildOutputSchema(schema, ctx) {
|
|
222
|
-
// For output schemas, filter out writeOnly properties
|
|
705
|
+
// For output schemas, filter out writeOnly properties.
|
|
706
|
+
// Only create a filtered copy if there are actually writeOnly properties,
|
|
707
|
+
// to preserve object identity for schema identity map matching.
|
|
223
708
|
if (schema.properties) {
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
709
|
+
const hasWriteOnly = Object.values(schema.properties).some((p) => p.writeOnly);
|
|
710
|
+
if (hasWriteOnly) {
|
|
711
|
+
const filteredProps = {};
|
|
712
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
713
|
+
if (!propSchema.writeOnly) {
|
|
714
|
+
filteredProps[key] = propSchema;
|
|
715
|
+
}
|
|
228
716
|
}
|
|
717
|
+
const filteredSchema = { ...schema, properties: filteredProps };
|
|
718
|
+
return schemaToZod(filteredSchema, ctx);
|
|
229
719
|
}
|
|
230
|
-
const filteredSchema = { ...schema, properties: filteredProps };
|
|
231
|
-
return schemaToZod(filteredSchema, ctx);
|
|
232
720
|
}
|
|
233
721
|
return schemaToZod(schema, ctx);
|
|
234
722
|
}
|
|
@@ -241,7 +729,8 @@ function generateIndexFile(functionExports) {
|
|
|
241
729
|
return lines.join('\n');
|
|
242
730
|
}
|
|
243
731
|
function generateServiceFile(spec, opPairs, vars, flags) {
|
|
244
|
-
const { name, pascalName, screamingName
|
|
732
|
+
const { name, pascalName, screamingName } = vars;
|
|
733
|
+
const displayName = vars.displayName.replace(/'/g, '');
|
|
245
734
|
const lines = [];
|
|
246
735
|
// Always import all error classes used in the switch statement
|
|
247
736
|
const allErrorClasses = new Set(Object.values(STATUS_TO_ERROR));
|
|
@@ -296,6 +785,20 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
296
785
|
}
|
|
297
786
|
lines.push(`const ROUTES: Record<string, { path: string[], query: string[], headers: string[], errors?: Record<number, string> }> = ${JSON.stringify(routes, null, 2)}`);
|
|
298
787
|
lines.push('');
|
|
788
|
+
if (flags.camelCase) {
|
|
789
|
+
lines.push('function _toSnakeCase(data: Record<string, unknown>): Record<string, unknown> {');
|
|
790
|
+
lines.push(' return Object.fromEntries(');
|
|
791
|
+
lines.push(' Object.entries(data).map(([k, v]) => [k.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`), v])');
|
|
792
|
+
lines.push(' )');
|
|
793
|
+
lines.push('}');
|
|
794
|
+
lines.push('');
|
|
795
|
+
lines.push('function _toCamelCase(data: Record<string, unknown>): Record<string, unknown> {');
|
|
796
|
+
lines.push(' return Object.fromEntries(');
|
|
797
|
+
lines.push(' Object.entries(data).map(([k, v]) => [k.replace(/[-_]+(.)/g, (_, c) => c.toUpperCase()), v])');
|
|
798
|
+
lines.push(' )');
|
|
799
|
+
lines.push('}');
|
|
800
|
+
lines.push('');
|
|
801
|
+
}
|
|
299
802
|
// Class declaration
|
|
300
803
|
lines.push(`export class ${pascalName}Service {`);
|
|
301
804
|
lines.push(' private baseUrl: string');
|
|
@@ -337,6 +840,9 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
337
840
|
lines.push(' path: string,');
|
|
338
841
|
lines.push(' data?: Record<string, unknown>');
|
|
339
842
|
lines.push(' ): Promise<T> {');
|
|
843
|
+
if (flags.camelCase) {
|
|
844
|
+
lines.push(' const rawData = data ? _toSnakeCase(data) : data');
|
|
845
|
+
}
|
|
340
846
|
lines.push(' const route = ROUTES[`${method} ${path}`]');
|
|
341
847
|
lines.push(' let endpoint = path');
|
|
342
848
|
lines.push(' let body: Record<string, unknown> | undefined');
|
|
@@ -345,29 +851,31 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
345
851
|
lines.push(" 'Content-Type': 'application/json',");
|
|
346
852
|
lines.push(' }');
|
|
347
853
|
lines.push('');
|
|
348
|
-
|
|
854
|
+
// When camelCase flag is on, use rawData (snake_case converted) for route matching
|
|
855
|
+
const dataVar = flags.camelCase ? 'rawData' : 'data';
|
|
856
|
+
lines.push(` if (${dataVar} && route) {`);
|
|
349
857
|
lines.push(' // Interpolate path params');
|
|
350
858
|
lines.push(' for (const param of route.path) {');
|
|
351
|
-
lines.push(
|
|
352
|
-
lines.push(
|
|
859
|
+
lines.push(` if (${dataVar}[param] !== undefined) {`);
|
|
860
|
+
lines.push(` endpoint = endpoint.replace(\`{\${param}}\`, String(${dataVar}[param]))`);
|
|
353
861
|
lines.push(' }');
|
|
354
862
|
lines.push(' }');
|
|
355
863
|
lines.push(' // Extract query params');
|
|
356
864
|
lines.push(' for (const param of route.query) {');
|
|
357
|
-
lines.push(
|
|
358
|
-
lines.push(
|
|
865
|
+
lines.push(` if (${dataVar}[param] !== undefined) {`);
|
|
866
|
+
lines.push(` query[param] = String(${dataVar}[param])`);
|
|
359
867
|
lines.push(' }');
|
|
360
868
|
lines.push(' }');
|
|
361
869
|
lines.push(' // Extract header params');
|
|
362
870
|
lines.push(' for (const param of route.headers) {');
|
|
363
|
-
lines.push(
|
|
364
|
-
lines.push(
|
|
871
|
+
lines.push(` if (${dataVar}[param] !== undefined) {`);
|
|
872
|
+
lines.push(` headers[param] = String(${dataVar}[param])`);
|
|
365
873
|
lines.push(' }');
|
|
366
874
|
lines.push(' }');
|
|
367
875
|
lines.push(' // Everything else goes into body');
|
|
368
876
|
lines.push(' const pathQueryHeaders = new Set([...route.path, ...route.query, ...route.headers])');
|
|
369
877
|
lines.push(' const remaining = Object.fromEntries(');
|
|
370
|
-
lines.push(
|
|
878
|
+
lines.push(` Object.entries(${dataVar}).filter(([k]) => !pathQueryHeaders.has(k))`);
|
|
371
879
|
lines.push(' )');
|
|
372
880
|
lines.push(' if (Object.keys(remaining).length > 0) {');
|
|
373
881
|
lines.push(' body = remaining');
|
|
@@ -451,14 +959,21 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
451
959
|
lines.push('');
|
|
452
960
|
lines.push(' const text = await response.text()');
|
|
453
961
|
lines.push(' if (!text) return {} as T');
|
|
454
|
-
|
|
962
|
+
if (flags.camelCase) {
|
|
963
|
+
lines.push(' const result = JSON.parse(text)');
|
|
964
|
+
lines.push(' return (typeof result === "object" && result !== null && !Array.isArray(result) ? _toCamelCase(result) : result) as T');
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
lines.push(' return JSON.parse(text) as T');
|
|
968
|
+
}
|
|
455
969
|
lines.push(' }');
|
|
456
970
|
lines.push('}');
|
|
457
971
|
lines.push('');
|
|
458
972
|
return lines.join('\n');
|
|
459
973
|
}
|
|
460
974
|
function generateVariableFile(spec, vars) {
|
|
461
|
-
const { camelName, screamingName
|
|
975
|
+
const { camelName, screamingName } = vars;
|
|
976
|
+
const displayName = vars.displayName.replace(/'/g, '');
|
|
462
977
|
const serverUrls = spec.serverUrls.length > 0 ? spec.serverUrls : [];
|
|
463
978
|
const defaultUrl = serverUrls[0];
|
|
464
979
|
const lines = [];
|
|
@@ -466,8 +981,13 @@ function generateVariableFile(spec, vars) {
|
|
|
466
981
|
lines.push("import { wireVariable } from '@pikku/core/variable'");
|
|
467
982
|
lines.push('');
|
|
468
983
|
const schemaVarName = `${camelName}BaseUrlSchema`;
|
|
469
|
-
|
|
470
|
-
|
|
984
|
+
if (serverUrls.length > 0) {
|
|
985
|
+
const urlsLiteral = serverUrls.map((u) => JSON.stringify(u)).join(', ');
|
|
986
|
+
lines.push(`export const ${schemaVarName} = z.enum([${urlsLiteral}]).default(${JSON.stringify(defaultUrl)})`);
|
|
987
|
+
}
|
|
988
|
+
else {
|
|
989
|
+
lines.push(`export const ${schemaVarName} = z.string().describe('Base URL for the ${displayName} API')`);
|
|
990
|
+
}
|
|
471
991
|
lines.push('');
|
|
472
992
|
lines.push(`wireVariable({`);
|
|
473
993
|
lines.push(` name: '${screamingName}_BASE_URL',`);
|