@pikku/openapi-parser 0.12.8 → 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 +21 -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/src/codegen.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
schemaVarName,
|
|
13
13
|
sanitizeTypeName,
|
|
14
14
|
createContext,
|
|
15
|
+
snakeToCamel,
|
|
15
16
|
type ZodCodegenContext,
|
|
16
17
|
} from './zod-codegen.js'
|
|
17
18
|
import {
|
|
@@ -34,6 +35,7 @@ interface CodegenFlags {
|
|
|
34
35
|
secret: boolean
|
|
35
36
|
credential?: 'apikey' | 'bearer' | 'oauth2'
|
|
36
37
|
mcp?: boolean
|
|
38
|
+
camelCase?: boolean
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
function safeKey(key: string): string {
|
|
@@ -88,6 +90,175 @@ function getErrorClassesForResponses(
|
|
|
88
90
|
return classes
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Collect all component schema names directly referenced by an operation's
|
|
95
|
+
* params, request body, and response schema.
|
|
96
|
+
*/
|
|
97
|
+
function collectOperationSchemaRefs(
|
|
98
|
+
parsed: ParsedOperation,
|
|
99
|
+
componentNames: Set<string>,
|
|
100
|
+
schemaIdentityMap: Map<object, string>
|
|
101
|
+
): Set<string> {
|
|
102
|
+
const refs = new Set<string>()
|
|
103
|
+
const allSchemas: any[] = []
|
|
104
|
+
|
|
105
|
+
// Gather all schema objects from this operation
|
|
106
|
+
for (const p of [
|
|
107
|
+
...parsed.pathParams,
|
|
108
|
+
...parsed.queryParams,
|
|
109
|
+
...parsed.headerParams,
|
|
110
|
+
]) {
|
|
111
|
+
if (p.schema) allSchemas.push(p.schema)
|
|
112
|
+
}
|
|
113
|
+
if (parsed.requestBody) allSchemas.push(parsed.requestBody)
|
|
114
|
+
if (parsed.responseSchema) allSchemas.push(parsed.responseSchema)
|
|
115
|
+
|
|
116
|
+
for (const schema of allSchemas) {
|
|
117
|
+
// Check if the schema itself IS a component schema (by object identity).
|
|
118
|
+
// collectSchemaRefs skips the root identity check, so we must do it here.
|
|
119
|
+
if (schema && typeof schema === 'object') {
|
|
120
|
+
const identityName = schemaIdentityMap.get(schema)
|
|
121
|
+
if (identityName && componentNames.has(identityName)) {
|
|
122
|
+
refs.add(identityName)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const ref of collectSchemaRefs(
|
|
126
|
+
schema,
|
|
127
|
+
componentNames,
|
|
128
|
+
schemaIdentityMap
|
|
129
|
+
)) {
|
|
130
|
+
refs.add(ref)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return refs
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Compute the transitive closure of schema dependencies.
|
|
138
|
+
* Given a set of schema names, expand it to include all schemas they depend on.
|
|
139
|
+
*/
|
|
140
|
+
function transitiveClosure(
|
|
141
|
+
schemaNames: Set<string>,
|
|
142
|
+
allSchemas: Record<string, any>,
|
|
143
|
+
componentNames: Set<string>,
|
|
144
|
+
schemaIdentityMap: Map<object, string>
|
|
145
|
+
): Set<string> {
|
|
146
|
+
const result = new Set<string>()
|
|
147
|
+
const queue = [...schemaNames]
|
|
148
|
+
while (queue.length > 0) {
|
|
149
|
+
const name = queue.pop()!
|
|
150
|
+
if (result.has(name)) continue
|
|
151
|
+
if (!allSchemas[name]) continue
|
|
152
|
+
result.add(name)
|
|
153
|
+
const deps = collectSchemaRefs(
|
|
154
|
+
allSchemas[name],
|
|
155
|
+
componentNames,
|
|
156
|
+
schemaIdentityMap
|
|
157
|
+
)
|
|
158
|
+
for (const dep of deps) {
|
|
159
|
+
if (!result.has(dep)) queue.push(dep)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return result
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface SchemaPartition {
|
|
166
|
+
/** Schemas referenced by 2+ operations — go in the shared types file */
|
|
167
|
+
shared: Set<string>
|
|
168
|
+
/** Map from schema name to the single operation index that uses it */
|
|
169
|
+
single: Map<string, number>
|
|
170
|
+
/** Schemas not referenced by any operation (directly or transitively) */
|
|
171
|
+
unused: Set<string>
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Partition component schemas into shared, single-use, and unused buckets.
|
|
176
|
+
*
|
|
177
|
+
* Algorithm:
|
|
178
|
+
* 1. For each operation, collect its directly referenced component schemas
|
|
179
|
+
* 2. Expand each operation's refs to include transitive deps
|
|
180
|
+
* 3. Count how many operations reference each schema (via transitive closure)
|
|
181
|
+
* 4. Schemas with 2+ operations → shared, 1 operation → single, 0 → unused
|
|
182
|
+
* 5. Single-use schemas whose transitive deps include shared schemas still go
|
|
183
|
+
* into single-use (the function file will import those shared deps)
|
|
184
|
+
*/
|
|
185
|
+
function partitionSchemas(
|
|
186
|
+
spec: ParsedSpec,
|
|
187
|
+
schemaIdentityMap: Map<object, string>
|
|
188
|
+
): SchemaPartition {
|
|
189
|
+
const componentNames = new Set(Object.keys(spec.componentSchemas))
|
|
190
|
+
|
|
191
|
+
// Step 1: Direct refs per operation
|
|
192
|
+
const opDirectRefs: Set<string>[] = spec.operations.map((op) =>
|
|
193
|
+
collectOperationSchemaRefs(op, componentNames, schemaIdentityMap)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
// Step 2: Transitive refs per operation
|
|
197
|
+
const opTransitiveRefs: Set<string>[] = opDirectRefs.map((directRefs) =>
|
|
198
|
+
transitiveClosure(
|
|
199
|
+
directRefs,
|
|
200
|
+
spec.componentSchemas,
|
|
201
|
+
componentNames,
|
|
202
|
+
schemaIdentityMap
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
// Step 3: Count how many operations reference each schema
|
|
207
|
+
const refCount = new Map<string, Set<number>>()
|
|
208
|
+
for (let i = 0; i < opTransitiveRefs.length; i++) {
|
|
209
|
+
for (const name of opTransitiveRefs[i]) {
|
|
210
|
+
if (!refCount.has(name)) refCount.set(name, new Set())
|
|
211
|
+
refCount.get(name)!.add(i)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Step 4: Partition
|
|
216
|
+
const shared = new Set<string>()
|
|
217
|
+
const single = new Map<string, number>()
|
|
218
|
+
const unused = new Set<string>()
|
|
219
|
+
|
|
220
|
+
for (const name of componentNames) {
|
|
221
|
+
const ops = refCount.get(name)
|
|
222
|
+
if (!ops || ops.size === 0) {
|
|
223
|
+
unused.add(name)
|
|
224
|
+
} else if (ops.size === 1) {
|
|
225
|
+
single.set(name, [...ops][0])
|
|
226
|
+
} else {
|
|
227
|
+
shared.add(name)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Step 5: If a single-use schema transitively depends on another single-use
|
|
232
|
+
// schema from a DIFFERENT operation, promote both to shared.
|
|
233
|
+
// This handles chains like: OpA → SchemaX → SchemaY ← OpB
|
|
234
|
+
// Also: single-use schemas that depend on other single-use schemas from the
|
|
235
|
+
// same operation stay single-use (they'll be inlined together).
|
|
236
|
+
let changed = true
|
|
237
|
+
while (changed) {
|
|
238
|
+
changed = false
|
|
239
|
+
for (const [name, opIdx] of [...single]) {
|
|
240
|
+
const deps = collectSchemaRefs(
|
|
241
|
+
spec.componentSchemas[name],
|
|
242
|
+
componentNames,
|
|
243
|
+
schemaIdentityMap
|
|
244
|
+
)
|
|
245
|
+
for (const dep of deps) {
|
|
246
|
+
if (single.has(dep) && single.get(dep) !== opIdx) {
|
|
247
|
+
// Dep is single-use but for a different operation — promote both
|
|
248
|
+
shared.add(dep)
|
|
249
|
+
single.delete(dep)
|
|
250
|
+
shared.add(name)
|
|
251
|
+
single.delete(name)
|
|
252
|
+
changed = true
|
|
253
|
+
break
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { shared, single, unused }
|
|
260
|
+
}
|
|
261
|
+
|
|
91
262
|
export function generateAddonFromOpenAPI(
|
|
92
263
|
spec: ParsedSpec,
|
|
93
264
|
vars: AddonVars,
|
|
@@ -101,7 +272,52 @@ export function generateAddonFromOpenAPI(
|
|
|
101
272
|
for (const schemaName of Object.keys(spec.componentSchemas)) {
|
|
102
273
|
schemaRefs.set(schemaName, schemaVarName(schemaName))
|
|
103
274
|
}
|
|
104
|
-
|
|
275
|
+
|
|
276
|
+
// Build an identity map from resolved schema objects to their component names.
|
|
277
|
+
// After $ref resolution, many references become the same JS object as the
|
|
278
|
+
// component schema (or a spread-copy). This map lets the codegen detect those
|
|
279
|
+
// resolved references by object identity instead of relying on $ref strings.
|
|
280
|
+
const schemaIdentityMap = new Map<object, string>()
|
|
281
|
+
for (const [schemaName, schema] of Object.entries(spec.componentSchemas)) {
|
|
282
|
+
if (schema && typeof schema === 'object') {
|
|
283
|
+
schemaIdentityMap.set(schema, schemaName)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Partition schemas: shared (2+ ops), single-use (1 op), unused (0 ops)
|
|
288
|
+
const partition = partitionSchemas(spec, schemaIdentityMap)
|
|
289
|
+
|
|
290
|
+
// Build the set of schemas that go in the shared types file:
|
|
291
|
+
// shared schemas + their transitive dependencies
|
|
292
|
+
const componentNames = new Set(Object.keys(spec.componentSchemas))
|
|
293
|
+
const sharedWithDeps = transitiveClosure(
|
|
294
|
+
partition.shared,
|
|
295
|
+
spec.componentSchemas,
|
|
296
|
+
componentNames,
|
|
297
|
+
schemaIdentityMap
|
|
298
|
+
)
|
|
299
|
+
// Any transitive dep of a shared schema that was single-use gets promoted to shared
|
|
300
|
+
for (const name of sharedWithDeps) {
|
|
301
|
+
partition.single.delete(name)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Build a reduced componentSchemas for the types file (only shared schemas)
|
|
305
|
+
const sharedComponentSchemas: Record<string, any> = {}
|
|
306
|
+
for (const schemaName of sharedWithDeps) {
|
|
307
|
+
if (spec.componentSchemas[schemaName]) {
|
|
308
|
+
sharedComponentSchemas[schemaName] = spec.componentSchemas[schemaName]
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// schemaRefs for shared types file context — only shared schemas
|
|
313
|
+
const sharedSchemaRefs = new Map<string, string>()
|
|
314
|
+
for (const schemaName of sharedWithDeps) {
|
|
315
|
+
sharedSchemaRefs.set(schemaName, schemaVarName(schemaName))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const ctx = createContext(sharedSchemaRefs, schemaIdentityMap, {
|
|
319
|
+
camelCase: flags.camelCase,
|
|
320
|
+
})
|
|
105
321
|
|
|
106
322
|
// Generate operation names
|
|
107
323
|
const paths = spec.operations.map((op) => op.path)
|
|
@@ -120,21 +336,54 @@ export function generateAddonFromOpenAPI(
|
|
|
120
336
|
namedOps.map((named, i) => ({ named, parsed: spec.operations[i] }))
|
|
121
337
|
|
|
122
338
|
// Generate types file with shared schemas (only if there are any)
|
|
123
|
-
if (Object.keys(
|
|
124
|
-
files[`src/${name}.types.ts`] = generateTypesFile(
|
|
339
|
+
if (Object.keys(sharedComponentSchemas).length > 0) {
|
|
340
|
+
files[`src/${name}.types.ts`] = generateTypesFile(
|
|
341
|
+
{ ...spec, componentSchemas: sharedComponentSchemas },
|
|
342
|
+
ctx,
|
|
343
|
+
schemaIdentityMap
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Collect single-use schemas per operation index
|
|
348
|
+
const singleSchemasPerOp = new Map<number, Set<string>>()
|
|
349
|
+
for (const [schemaName, opIdx] of partition.single) {
|
|
350
|
+
if (!singleSchemasPerOp.has(opIdx)) singleSchemasPerOp.set(opIdx, new Set())
|
|
351
|
+
singleSchemasPerOp.get(opIdx)!.add(schemaName)
|
|
352
|
+
}
|
|
353
|
+
// Expand single-use schemas to include their transitive deps that are also single-use
|
|
354
|
+
for (const [_opIdx, schemas] of singleSchemasPerOp) {
|
|
355
|
+
const expanded = transitiveClosure(
|
|
356
|
+
schemas,
|
|
357
|
+
spec.componentSchemas,
|
|
358
|
+
componentNames,
|
|
359
|
+
schemaIdentityMap
|
|
360
|
+
)
|
|
361
|
+
// Only keep schemas that are NOT in the shared set
|
|
362
|
+
for (const s of expanded) {
|
|
363
|
+
if (!sharedWithDeps.has(s) && !partition.unused.has(s)) {
|
|
364
|
+
schemas.add(s)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
125
367
|
}
|
|
126
368
|
|
|
127
369
|
// Generate function files
|
|
128
370
|
const functionExports: string[] = []
|
|
129
|
-
for (
|
|
130
|
-
const
|
|
371
|
+
for (let i = 0; i < opPairs.length; i++) {
|
|
372
|
+
const { named, parsed } = opPairs[i]
|
|
373
|
+
const funcCtx = createContext(schemaRefs, schemaIdentityMap, {
|
|
374
|
+
camelCase: flags.camelCase,
|
|
375
|
+
})
|
|
376
|
+
const inlineSchemas = singleSchemasPerOp.get(i) ?? new Set<string>()
|
|
131
377
|
const funcCode = generateFunctionFile(
|
|
132
378
|
named,
|
|
133
379
|
parsed,
|
|
134
380
|
vars,
|
|
135
381
|
funcCtx,
|
|
136
382
|
spec,
|
|
137
|
-
flags
|
|
383
|
+
flags,
|
|
384
|
+
inlineSchemas,
|
|
385
|
+
sharedWithDeps,
|
|
386
|
+
schemaIdentityMap
|
|
138
387
|
)
|
|
139
388
|
files[`src/functions/${named.functionName}.function.ts`] = funcCode
|
|
140
389
|
functionExports.push(named.functionName)
|
|
@@ -157,20 +406,191 @@ export function generateAddonFromOpenAPI(
|
|
|
157
406
|
return files
|
|
158
407
|
}
|
|
159
408
|
|
|
160
|
-
|
|
409
|
+
/**
|
|
410
|
+
* Collect all component schema names referenced (directly or via properties/items/allOf/oneOf/anyOf)
|
|
411
|
+
* by a given OpenAPI schema object.
|
|
412
|
+
*
|
|
413
|
+
* After $ref resolution, most $ref strings are gone and replaced by the resolved
|
|
414
|
+
* object (often the same JS identity as the component schema). We detect
|
|
415
|
+
* dependencies both by surviving $ref strings AND by object identity via
|
|
416
|
+
* schemaIdentityMap.
|
|
417
|
+
*/
|
|
418
|
+
function collectSchemaRefs(
|
|
419
|
+
schema: any,
|
|
420
|
+
componentNames: Set<string>,
|
|
421
|
+
schemaIdentityMap: Map<object, string>
|
|
422
|
+
): Set<string> {
|
|
423
|
+
const refs = new Set<string>()
|
|
424
|
+
const visited = new Set<any>()
|
|
425
|
+
|
|
426
|
+
function walk(node: any, isRoot: boolean) {
|
|
427
|
+
if (!node || typeof node !== 'object' || visited.has(node)) return
|
|
428
|
+
visited.add(node)
|
|
429
|
+
|
|
430
|
+
// Check surviving $ref strings
|
|
431
|
+
if (node.$ref) {
|
|
432
|
+
const refName = node.$ref.split('/').pop()!
|
|
433
|
+
if (componentNames.has(refName)) refs.add(refName)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Check if this node IS a known component schema (resolved $ref by identity).
|
|
437
|
+
// Skip this check for the root node (the schema we're analyzing),
|
|
438
|
+
// otherwise we'd match the schema against itself and return early,
|
|
439
|
+
// missing its internal dependencies.
|
|
440
|
+
if (!isRoot) {
|
|
441
|
+
const identityName = schemaIdentityMap.get(node)
|
|
442
|
+
if (identityName && componentNames.has(identityName)) {
|
|
443
|
+
refs.add(identityName)
|
|
444
|
+
// Don't walk into the component schema's internals — those are
|
|
445
|
+
// handled when that schema is processed as a top-level entry.
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (node.properties) {
|
|
451
|
+
for (const prop of Object.values(node.properties)) walk(prop, false)
|
|
452
|
+
}
|
|
453
|
+
if (node.items) walk(node.items, false)
|
|
454
|
+
if (node.allOf) for (const s of node.allOf) walk(s, false)
|
|
455
|
+
if (node.oneOf) for (const s of node.oneOf) walk(s, false)
|
|
456
|
+
if (node.anyOf) for (const s of node.anyOf) walk(s, false)
|
|
457
|
+
if (
|
|
458
|
+
node.additionalProperties &&
|
|
459
|
+
typeof node.additionalProperties === 'object'
|
|
460
|
+
) {
|
|
461
|
+
walk(node.additionalProperties, false)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
walk(schema, true)
|
|
466
|
+
return refs
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Topologically sort component schemas so that dependencies come before dependents.
|
|
471
|
+
* Schemas involved in cycles are placed in arbitrary order (cycles are handled via z.lazy).
|
|
472
|
+
*/
|
|
473
|
+
function topoSortSchemas(
|
|
474
|
+
schemas: Record<string, any>,
|
|
475
|
+
schemaIdentityMap: Map<object, string>
|
|
476
|
+
): { sorted: string[]; cyclicEdges: Set<string> } {
|
|
477
|
+
const names = new Set(Object.keys(schemas))
|
|
478
|
+
const deps = new Map<string, Set<string>>()
|
|
479
|
+
|
|
480
|
+
const selfRefs = new Set<string>()
|
|
481
|
+
for (const [name, schema] of Object.entries(schemas)) {
|
|
482
|
+
const refNames = collectSchemaRefs(schema, names, schemaIdentityMap)
|
|
483
|
+
if (refNames.has(name)) {
|
|
484
|
+
selfRefs.add(name) // track self-referential schemas
|
|
485
|
+
}
|
|
486
|
+
refNames.delete(name) // remove self-refs from dependency graph
|
|
487
|
+
deps.set(name, refNames)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const sorted: string[] = []
|
|
491
|
+
const visited = new Set<string>()
|
|
492
|
+
const inStack = new Set<string>()
|
|
493
|
+
const cyclicEdges = new Set<string>() // "child" schemas that are referenced before declaration
|
|
494
|
+
|
|
495
|
+
function visit(name: string) {
|
|
496
|
+
if (visited.has(name)) return
|
|
497
|
+
if (inStack.has(name)) {
|
|
498
|
+
// This is a cycle — mark this name as needing z.lazy()
|
|
499
|
+
cyclicEdges.add(name)
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
inStack.add(name)
|
|
503
|
+
const children = deps.get(name) ?? new Set()
|
|
504
|
+
for (const child of children) {
|
|
505
|
+
if (names.has(child)) visit(child)
|
|
506
|
+
}
|
|
507
|
+
inStack.delete(name)
|
|
508
|
+
visited.add(name)
|
|
509
|
+
sorted.push(name)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
for (const name of names) {
|
|
513
|
+
visit(name)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Self-referential schemas also need z.lazy() forward declarations
|
|
517
|
+
for (const name of selfRefs) {
|
|
518
|
+
cyclicEdges.add(name)
|
|
519
|
+
}
|
|
520
|
+
return { sorted, cyclicEdges }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function generateTypesFile(
|
|
524
|
+
spec: ParsedSpec,
|
|
525
|
+
ctx: ZodCodegenContext,
|
|
526
|
+
schemaIdentityMap: Map<object, string>
|
|
527
|
+
): string {
|
|
161
528
|
const lines: string[] = []
|
|
162
529
|
lines.push("import { z } from 'zod'")
|
|
163
530
|
lines.push('')
|
|
164
531
|
lines.push(`// Shared schemas from ${spec.info.title} v${spec.info.version}`)
|
|
165
532
|
lines.push('')
|
|
166
533
|
|
|
167
|
-
|
|
168
|
-
|
|
534
|
+
const { sorted, cyclicEdges } = topoSortSchemas(
|
|
535
|
+
spec.componentSchemas,
|
|
536
|
+
schemaIdentityMap
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
// Track emitted variable names and type names to deduplicate collisions
|
|
540
|
+
// (e.g. two OpenAPI components that sanitize to the same JS identifier)
|
|
541
|
+
const emittedVarNames = new Set<string>()
|
|
542
|
+
const emittedTypeNames = new Set<string>()
|
|
543
|
+
|
|
544
|
+
function deduplicateName(baseName: string, usedSet: Set<string>): string {
|
|
545
|
+
if (!usedSet.has(baseName)) {
|
|
546
|
+
usedSet.add(baseName)
|
|
547
|
+
return baseName
|
|
548
|
+
}
|
|
549
|
+
let counter = 2
|
|
550
|
+
while (usedSet.has(`${baseName}_${counter}`)) {
|
|
551
|
+
counter++
|
|
552
|
+
}
|
|
553
|
+
const deduped = `${baseName}_${counter}`
|
|
554
|
+
usedSet.add(deduped)
|
|
555
|
+
return deduped
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// For schemas that participate in cycles, emit a z.lazy() forward declaration
|
|
559
|
+
// before any schemas are defined, so forward references work.
|
|
560
|
+
if (cyclicEdges.size > 0) {
|
|
561
|
+
lines.push('// Forward declarations for circular references')
|
|
562
|
+
for (const name of cyclicEdges) {
|
|
563
|
+
const varName = deduplicateName(schemaVarName(name), emittedVarNames)
|
|
564
|
+
const typeName = deduplicateName(sanitizeTypeName(name), emittedTypeNames)
|
|
565
|
+
lines.push(
|
|
566
|
+
`export const ${varName}: z.ZodType<any> = z.lazy(() => _${varName})`
|
|
567
|
+
)
|
|
568
|
+
lines.push(`export type ${typeName} = z.infer<typeof ${varName}>`)
|
|
569
|
+
lines.push('')
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Emit schemas in topological order
|
|
574
|
+
for (const name of sorted) {
|
|
575
|
+
const schema = spec.componentSchemas[name]
|
|
576
|
+
const varName = deduplicateName(schemaVarName(name), emittedVarNames)
|
|
577
|
+
const typeName = deduplicateName(sanitizeTypeName(name), emittedTypeNames)
|
|
578
|
+
// Temporarily remove the current schema from the identity map so that
|
|
579
|
+
// schemaToZod doesn't short-circuit to referencing the variable we're
|
|
580
|
+
// currently defining (which would produce `const XSchema = XSchema`).
|
|
581
|
+
ctx.schemaIdentityMap.delete(schema)
|
|
169
582
|
const zodCode = schemaToZod(schema, ctx)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
)
|
|
583
|
+
// Restore it for subsequent schemas that may reference this one.
|
|
584
|
+
ctx.schemaIdentityMap.set(schema, name)
|
|
585
|
+
|
|
586
|
+
if (cyclicEdges.has(name)) {
|
|
587
|
+
// The public variable was already declared above via z.lazy();
|
|
588
|
+
// emit the real implementation with an underscore prefix.
|
|
589
|
+
lines.push(`const _${varName} = ${zodCode}`)
|
|
590
|
+
} else {
|
|
591
|
+
lines.push(`export const ${varName} = ${zodCode}`)
|
|
592
|
+
lines.push(`export type ${typeName} = z.infer<typeof ${varName}>`)
|
|
593
|
+
}
|
|
174
594
|
lines.push('')
|
|
175
595
|
}
|
|
176
596
|
|
|
@@ -183,20 +603,13 @@ function generateFunctionFile(
|
|
|
183
603
|
vars: AddonVars,
|
|
184
604
|
ctx: ZodCodegenContext,
|
|
185
605
|
spec: ParsedSpec,
|
|
186
|
-
flags: CodegenFlags
|
|
606
|
+
flags: CodegenFlags,
|
|
607
|
+
inlineSchemas: Set<string> = new Set(),
|
|
608
|
+
sharedSchemas: Set<string> = new Set(),
|
|
609
|
+
schemaIdentityMap: Map<object, string> = new Map()
|
|
187
610
|
): string {
|
|
188
611
|
const lines: string[] = []
|
|
189
|
-
const { camelName } = vars
|
|
190
|
-
|
|
191
|
-
// Tag description as file header
|
|
192
|
-
if (parsed.tags.length > 0) {
|
|
193
|
-
const tag = parsed.tags[0]
|
|
194
|
-
const tagDesc = spec.tagDescriptions[tag]
|
|
195
|
-
if (tagDesc) {
|
|
196
|
-
lines.push(`// ${tag} — ${tagDesc}`)
|
|
197
|
-
lines.push('')
|
|
198
|
-
}
|
|
199
|
-
}
|
|
612
|
+
const { camelName, name } = vars
|
|
200
613
|
|
|
201
614
|
const hasInput =
|
|
202
615
|
parsed.pathParams.length > 0 ||
|
|
@@ -209,10 +622,44 @@ function generateFunctionFile(
|
|
|
209
622
|
const inputName = `${pascalName}Input`
|
|
210
623
|
const outputName = `${pascalName}Output`
|
|
211
624
|
|
|
625
|
+
// Pre-generate Zod code to discover which schema refs are used
|
|
626
|
+
let inputCode: string | undefined
|
|
627
|
+
let outputCode: string | undefined
|
|
628
|
+
if (hasInput) {
|
|
629
|
+
inputCode = buildInputSchema(parsed, ctx)
|
|
630
|
+
}
|
|
631
|
+
if (parsed.responseSchema) {
|
|
632
|
+
outputCode = buildOutputSchema(parsed.responseSchema, ctx)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Bug 2: If generated Zod code is too large (> 500 lines), TypeScript
|
|
636
|
+
// can't infer the type (TS7056). Replace with z.any() to stay compilable.
|
|
637
|
+
const LINE_LIMIT = 500
|
|
638
|
+
if (inputCode && inputCode.split('\n').length > LINE_LIMIT) {
|
|
639
|
+
inputCode = 'z.any()'
|
|
640
|
+
}
|
|
641
|
+
if (outputCode && outputCode.split('\n').length > LINE_LIMIT) {
|
|
642
|
+
outputCode = 'z.any()'
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Tag description as file header
|
|
646
|
+
if (parsed.tags.length > 0) {
|
|
647
|
+
const tag = parsed.tags[0]
|
|
648
|
+
const tagDesc = spec.tagDescriptions[tag]
|
|
649
|
+
if (tagDesc) {
|
|
650
|
+
const cleanDesc = tagDesc
|
|
651
|
+
.replace(/\r?\n/g, ' ')
|
|
652
|
+
.replace(/\s+/g, ' ')
|
|
653
|
+
.trim()
|
|
654
|
+
lines.push(`// ${tag} — ${cleanDesc}`)
|
|
655
|
+
lines.push('')
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
212
659
|
// Determine error imports needed
|
|
213
660
|
const errorClasses = getErrorClassesForResponses(parsed.errorResponses)
|
|
214
661
|
|
|
215
|
-
const needsZod = hasInput || !!parsed.responseSchema
|
|
662
|
+
const needsZod = hasInput || !!parsed.responseSchema || inlineSchemas.size > 0
|
|
216
663
|
if (needsZod) {
|
|
217
664
|
lines.push("import { z } from 'zod'")
|
|
218
665
|
}
|
|
@@ -224,18 +671,144 @@ function generateFunctionFile(
|
|
|
224
671
|
)
|
|
225
672
|
}
|
|
226
673
|
|
|
674
|
+
// Import referenced component schemas from the types file.
|
|
675
|
+
// Only import schemas that are in the shared set (not inlined ones).
|
|
676
|
+
// In addition to the refs tracked through usedRefs, scan the generated Zod
|
|
677
|
+
// code for schema variable names that may have been missed (e.g. when
|
|
678
|
+
// a $ref survived resolution in a nested path not covered by usedRefs).
|
|
679
|
+
if (Object.keys(spec.componentSchemas).length > 0) {
|
|
680
|
+
const allGeneratedCode = [inputCode, outputCode].filter(Boolean).join('\n')
|
|
681
|
+
for (const [refName, varName] of ctx.schemaRefs) {
|
|
682
|
+
if (!ctx.usedRefs.has(refName) && allGeneratedCode.includes(varName)) {
|
|
683
|
+
ctx.usedRefs.add(refName)
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const schemaImports = [
|
|
688
|
+
...new Set(
|
|
689
|
+
[...ctx.usedRefs]
|
|
690
|
+
.filter((refName) => sharedSchemas.has(refName)) // only import shared schemas
|
|
691
|
+
.map((refName) => ctx.schemaRefs.get(refName))
|
|
692
|
+
.filter(Boolean)
|
|
693
|
+
),
|
|
694
|
+
].sort()
|
|
695
|
+
if (schemaImports.length > 0) {
|
|
696
|
+
lines.push(
|
|
697
|
+
`import { ${schemaImports.join(', ')} } from '../${name}.types.js'`
|
|
698
|
+
)
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
227
702
|
lines.push('')
|
|
228
703
|
|
|
704
|
+
// Emit inline (single-use) schema definitions before input/output
|
|
705
|
+
if (inlineSchemas.size > 0) {
|
|
706
|
+
// Build a mini component schemas map for just the inline schemas
|
|
707
|
+
const inlineComponentSchemas: Record<string, any> = {}
|
|
708
|
+
for (const schemaName of inlineSchemas) {
|
|
709
|
+
if (spec.componentSchemas[schemaName]) {
|
|
710
|
+
inlineComponentSchemas[schemaName] = spec.componentSchemas[schemaName]
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (Object.keys(inlineComponentSchemas).length > 0) {
|
|
715
|
+
// Create a context that knows about both shared (for imports) and inline schemas
|
|
716
|
+
const inlineCtx = createContext(ctx.schemaRefs, schemaIdentityMap, {
|
|
717
|
+
camelCase: flags.camelCase,
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
const { sorted, cyclicEdges } = topoSortSchemas(
|
|
721
|
+
inlineComponentSchemas,
|
|
722
|
+
schemaIdentityMap
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
// Bug 4: Pre-generate all Zod code and detect forward references.
|
|
726
|
+
// The topological sort may miss dependencies when $ref resolution
|
|
727
|
+
// replaces references with copies (breaking object identity).
|
|
728
|
+
// Scan generated code for variable names that appear before their
|
|
729
|
+
// declaration and promote those to z.lazy() forward declarations.
|
|
730
|
+
const inlineSorted = sorted.filter((s) => inlineSchemas.has(s))
|
|
731
|
+
const generatedCode = new Map<string, string>()
|
|
732
|
+
for (const sName of inlineSorted) {
|
|
733
|
+
const schema = spec.componentSchemas[sName]
|
|
734
|
+
inlineCtx.schemaIdentityMap.delete(schema)
|
|
735
|
+
const zodCode = schemaToZod(schema, inlineCtx)
|
|
736
|
+
inlineCtx.schemaIdentityMap.set(schema, sName)
|
|
737
|
+
generatedCode.set(sName, zodCode)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Build a map of variable names to schema names for inline schemas
|
|
741
|
+
const inlineVarToName = new Map<string, string>()
|
|
742
|
+
for (const sName of inlineSorted) {
|
|
743
|
+
inlineVarToName.set(schemaVarName(sName), sName)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Detect forward references and self-references: for each schema in order,
|
|
747
|
+
// check if its generated code references a variable that appears later in
|
|
748
|
+
// the sorted list OR references its own variable (self-referential schema).
|
|
749
|
+
const forwardRefs = new Set<string>(cyclicEdges)
|
|
750
|
+
const emittedSoFar = new Set<string>()
|
|
751
|
+
for (const sName of inlineSorted) {
|
|
752
|
+
const code = generatedCode.get(sName)!
|
|
753
|
+
const ownVarN = schemaVarName(sName)
|
|
754
|
+
// Self-reference: schema uses its own variable name in its definition
|
|
755
|
+
if (code.includes(ownVarN)) {
|
|
756
|
+
forwardRefs.add(sName)
|
|
757
|
+
}
|
|
758
|
+
for (const [varN, refName] of inlineVarToName) {
|
|
759
|
+
if (
|
|
760
|
+
refName !== sName &&
|
|
761
|
+
!emittedSoFar.has(refName) &&
|
|
762
|
+
code.includes(varN)
|
|
763
|
+
) {
|
|
764
|
+
// This schema references another inline schema not yet emitted
|
|
765
|
+
forwardRefs.add(refName)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
emittedSoFar.add(sName)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const emittedVarNames = new Set<string>()
|
|
772
|
+
const emittedTypeNames = new Set<string>()
|
|
773
|
+
|
|
774
|
+
// Forward declarations for cycles and forward references within inline schemas
|
|
775
|
+
if (forwardRefs.size > 0) {
|
|
776
|
+
lines.push('// Forward declarations for circular references')
|
|
777
|
+
for (const sName of forwardRefs) {
|
|
778
|
+
if (!inlineSchemas.has(sName)) continue
|
|
779
|
+
const varN = schemaVarName(sName)
|
|
780
|
+
const typeN = sanitizeTypeName(sName)
|
|
781
|
+
emittedVarNames.add(varN)
|
|
782
|
+
emittedTypeNames.add(typeN)
|
|
783
|
+
lines.push(`const ${varN}: z.ZodType<any> = z.lazy(() => _${varN})`)
|
|
784
|
+
lines.push('')
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
for (const sName of inlineSorted) {
|
|
789
|
+
const varN = schemaVarName(sName)
|
|
790
|
+
emittedVarNames.add(varN)
|
|
791
|
+
|
|
792
|
+
const zodCode = generatedCode.get(sName)!
|
|
793
|
+
|
|
794
|
+
if (forwardRefs.has(sName)) {
|
|
795
|
+
lines.push(`const _${varN} = ${zodCode}`)
|
|
796
|
+
} else {
|
|
797
|
+
lines.push(`const ${varN} = ${zodCode}`)
|
|
798
|
+
}
|
|
799
|
+
lines.push('')
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
229
804
|
// Build Input schema (exported for pikku schema discovery)
|
|
230
|
-
if (hasInput) {
|
|
231
|
-
const inputCode = buildInputSchema(parsed, ctx)
|
|
805
|
+
if (hasInput && inputCode) {
|
|
232
806
|
lines.push(`export const ${inputName} = ${inputCode}`)
|
|
233
807
|
lines.push('')
|
|
234
808
|
}
|
|
235
809
|
|
|
236
810
|
// Build Output schema (exported for pikku schema discovery)
|
|
237
|
-
if (parsed.responseSchema) {
|
|
238
|
-
const outputCode = buildOutputSchema(parsed.responseSchema, ctx)
|
|
811
|
+
if (parsed.responseSchema && outputCode) {
|
|
239
812
|
lines.push(`export const ${outputName} = ${outputCode}`)
|
|
240
813
|
lines.push('')
|
|
241
814
|
}
|
|
@@ -260,12 +833,16 @@ function generateFunctionFile(
|
|
|
260
833
|
funcConfig.push(' mcp: true,')
|
|
261
834
|
}
|
|
262
835
|
|
|
263
|
-
|
|
836
|
+
// Bug 3: Avoid duplicate identifier when camelName is 'data' (TS2300)
|
|
837
|
+
const inputParamName = camelName === 'data' ? 'inputData' : 'data'
|
|
838
|
+
const funcParams = hasInput
|
|
839
|
+
? `{ ${camelName} }, ${inputParamName}`
|
|
840
|
+
: `{ ${camelName} }`
|
|
264
841
|
|
|
265
842
|
const returnCast = parsed.responseSchema ? ' as any' : ''
|
|
266
843
|
funcConfig.push(
|
|
267
844
|
` func: async (${funcParams}) => {`,
|
|
268
|
-
` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ?
|
|
845
|
+
` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ? `, ${inputParamName}` : ''})${returnCast}`,
|
|
269
846
|
' },'
|
|
270
847
|
)
|
|
271
848
|
|
|
@@ -282,42 +859,64 @@ function buildInputSchema(
|
|
|
282
859
|
ctx: ZodCodegenContext
|
|
283
860
|
): string {
|
|
284
861
|
const props: string[] = []
|
|
862
|
+
// Track which parameter names have been emitted to prevent duplicates.
|
|
863
|
+
// When shared path-level params and operation-level params overlap
|
|
864
|
+
// (e.g. both define "Version"), the later (operation-level) one wins.
|
|
865
|
+
const emittedNames = new Set<string>()
|
|
866
|
+
|
|
867
|
+
// Deduplicate params: operation-level params override shared path-level
|
|
868
|
+
// params with the same name. Build deduplicated lists per category.
|
|
869
|
+
function deduplicateParams(params: typeof parsed.pathParams) {
|
|
870
|
+
const seen = new Map<string, (typeof params)[0]>()
|
|
871
|
+
for (const param of params) {
|
|
872
|
+
seen.set(param.name, param) // last wins (operation-level comes after shared)
|
|
873
|
+
}
|
|
874
|
+
return [...seen.values()]
|
|
875
|
+
}
|
|
285
876
|
|
|
286
|
-
|
|
287
|
-
|
|
877
|
+
const useCamel = !!ctx.camelCase
|
|
878
|
+
|
|
879
|
+
for (const param of deduplicateParams(parsed.pathParams)) {
|
|
880
|
+
if (!emittedNames.has(param.name)) {
|
|
881
|
+
emittedNames.add(param.name)
|
|
882
|
+
props.push(formatParamProp(param, ctx, useCamel))
|
|
883
|
+
}
|
|
288
884
|
}
|
|
289
885
|
|
|
290
|
-
for (const param of parsed.queryParams) {
|
|
291
|
-
|
|
886
|
+
for (const param of deduplicateParams(parsed.queryParams)) {
|
|
887
|
+
if (!emittedNames.has(param.name)) {
|
|
888
|
+
emittedNames.add(param.name)
|
|
889
|
+
props.push(formatParamProp(param, ctx, useCamel))
|
|
890
|
+
}
|
|
292
891
|
}
|
|
293
892
|
|
|
294
|
-
for (const param of parsed.headerParams) {
|
|
295
|
-
|
|
893
|
+
for (const param of deduplicateParams(parsed.headerParams)) {
|
|
894
|
+
if (!emittedNames.has(param.name)) {
|
|
895
|
+
emittedNames.add(param.name)
|
|
896
|
+
props.push(formatParamProp(param, ctx, useCamel))
|
|
897
|
+
}
|
|
296
898
|
}
|
|
297
899
|
|
|
298
900
|
if (parsed.requestBody) {
|
|
299
901
|
if (parsed.requestBody.properties) {
|
|
300
|
-
const paramNames = new Set([
|
|
301
|
-
...parsed.pathParams.map((p) => p.name),
|
|
302
|
-
...parsed.queryParams.map((p) => p.name),
|
|
303
|
-
...parsed.headerParams.map((p) => p.name),
|
|
304
|
-
])
|
|
305
902
|
const requiredSet = new Set(parsed.requestBody.required ?? [])
|
|
306
903
|
for (const [key, propSchema] of Object.entries(
|
|
307
904
|
parsed.requestBody.properties
|
|
308
905
|
)) {
|
|
309
906
|
// Skip readOnly properties from input
|
|
310
907
|
if (propSchema.readOnly) continue
|
|
311
|
-
// Skip body properties that collide with
|
|
312
|
-
if (
|
|
908
|
+
// Skip body properties that collide with already-emitted params
|
|
909
|
+
if (emittedNames.has(key)) {
|
|
313
910
|
console.warn(
|
|
314
911
|
`[openapi] Skipping body property '${key}' — collides with path/query/header param`
|
|
315
912
|
)
|
|
316
913
|
continue
|
|
317
914
|
}
|
|
915
|
+
emittedNames.add(key)
|
|
318
916
|
const isOptional = !requiredSet.has(key)
|
|
319
917
|
const zodCode = schemaToZod(propSchema, ctx, { optional: isOptional })
|
|
320
|
-
|
|
918
|
+
const outputKey = useCamel ? snakeToCamel(key) : key
|
|
919
|
+
props.push(` ${safeKey(outputKey)}: ${zodCode},`)
|
|
321
920
|
}
|
|
322
921
|
} else {
|
|
323
922
|
const bodyZod = schemaToZod(parsed.requestBody, ctx)
|
|
@@ -336,12 +935,14 @@ function formatParamProp(
|
|
|
336
935
|
description?: string
|
|
337
936
|
example?: unknown
|
|
338
937
|
},
|
|
339
|
-
ctx: ZodCodegenContext
|
|
938
|
+
ctx: ZodCodegenContext,
|
|
939
|
+
camelCase = false
|
|
340
940
|
): string {
|
|
341
941
|
const zodCode = schemaToZod(param.schema, ctx, { optional: !param.required })
|
|
342
942
|
|
|
343
943
|
let descParts: string[] = []
|
|
344
|
-
if (param.description)
|
|
944
|
+
if (param.description)
|
|
945
|
+
descParts.push(param.description.replace(/\*\//g, '* /'))
|
|
345
946
|
if (param.example !== undefined)
|
|
346
947
|
descParts.push(`Example: ${JSON.stringify(param.example)}`)
|
|
347
948
|
|
|
@@ -350,23 +951,31 @@ function formatParamProp(
|
|
|
350
951
|
? `${zodCode}.describe(${JSON.stringify(descParts.join('. '))})`
|
|
351
952
|
: zodCode
|
|
352
953
|
|
|
353
|
-
|
|
954
|
+
const outputKey = camelCase ? snakeToCamel(param.name) : param.name
|
|
955
|
+
return ` ${safeKey(outputKey)}: ${desc},`
|
|
354
956
|
}
|
|
355
957
|
|
|
356
958
|
function buildOutputSchema(schema: any, ctx: ZodCodegenContext): string {
|
|
357
|
-
// For output schemas, filter out writeOnly properties
|
|
959
|
+
// For output schemas, filter out writeOnly properties.
|
|
960
|
+
// Only create a filtered copy if there are actually writeOnly properties,
|
|
961
|
+
// to preserve object identity for schema identity map matching.
|
|
358
962
|
if (schema.properties) {
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
963
|
+
const hasWriteOnly = Object.values(schema.properties).some(
|
|
964
|
+
(p: any) => p.writeOnly
|
|
965
|
+
)
|
|
966
|
+
if (hasWriteOnly) {
|
|
967
|
+
const filteredProps: Record<string, any> = {}
|
|
968
|
+
for (const [key, propSchema] of Object.entries(schema.properties) as [
|
|
969
|
+
string,
|
|
970
|
+
any,
|
|
971
|
+
][]) {
|
|
972
|
+
if (!propSchema.writeOnly) {
|
|
973
|
+
filteredProps[key] = propSchema
|
|
974
|
+
}
|
|
366
975
|
}
|
|
976
|
+
const filteredSchema = { ...schema, properties: filteredProps }
|
|
977
|
+
return schemaToZod(filteredSchema, ctx)
|
|
367
978
|
}
|
|
368
|
-
const filteredSchema = { ...schema, properties: filteredProps }
|
|
369
|
-
return schemaToZod(filteredSchema, ctx)
|
|
370
979
|
}
|
|
371
980
|
return schemaToZod(schema, ctx)
|
|
372
981
|
}
|
|
@@ -393,7 +1002,8 @@ function generateServiceFile(
|
|
|
393
1002
|
vars: AddonVars,
|
|
394
1003
|
flags: CodegenFlags
|
|
395
1004
|
): string {
|
|
396
|
-
const { name, pascalName, screamingName
|
|
1005
|
+
const { name, pascalName, screamingName } = vars
|
|
1006
|
+
const displayName = vars.displayName.replace(/'/g, '')
|
|
397
1007
|
const lines: string[] = []
|
|
398
1008
|
|
|
399
1009
|
// Always import all error classes used in the switch statement
|
|
@@ -469,6 +1079,29 @@ function generateServiceFile(
|
|
|
469
1079
|
)
|
|
470
1080
|
lines.push('')
|
|
471
1081
|
|
|
1082
|
+
if (flags.camelCase) {
|
|
1083
|
+
lines.push(
|
|
1084
|
+
'function _toSnakeCase(data: Record<string, unknown>): Record<string, unknown> {'
|
|
1085
|
+
)
|
|
1086
|
+
lines.push(' return Object.fromEntries(')
|
|
1087
|
+
lines.push(
|
|
1088
|
+
' Object.entries(data).map(([k, v]) => [k.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`), v])'
|
|
1089
|
+
)
|
|
1090
|
+
lines.push(' )')
|
|
1091
|
+
lines.push('}')
|
|
1092
|
+
lines.push('')
|
|
1093
|
+
lines.push(
|
|
1094
|
+
'function _toCamelCase(data: Record<string, unknown>): Record<string, unknown> {'
|
|
1095
|
+
)
|
|
1096
|
+
lines.push(' return Object.fromEntries(')
|
|
1097
|
+
lines.push(
|
|
1098
|
+
' Object.entries(data).map(([k, v]) => [k.replace(/[-_]+(.)/g, (_, c) => c.toUpperCase()), v])'
|
|
1099
|
+
)
|
|
1100
|
+
lines.push(' )')
|
|
1101
|
+
lines.push('}')
|
|
1102
|
+
lines.push('')
|
|
1103
|
+
}
|
|
1104
|
+
|
|
472
1105
|
// Class declaration
|
|
473
1106
|
lines.push(`export class ${pascalName}Service {`)
|
|
474
1107
|
lines.push(' private baseUrl: string')
|
|
@@ -524,6 +1157,9 @@ function generateServiceFile(
|
|
|
524
1157
|
lines.push(' path: string,')
|
|
525
1158
|
lines.push(' data?: Record<string, unknown>')
|
|
526
1159
|
lines.push(' ): Promise<T> {')
|
|
1160
|
+
if (flags.camelCase) {
|
|
1161
|
+
lines.push(' const rawData = data ? _toSnakeCase(data) : data')
|
|
1162
|
+
}
|
|
527
1163
|
lines.push(' const route = ROUTES[`${method} ${path}`]')
|
|
528
1164
|
lines.push(' let endpoint = path')
|
|
529
1165
|
lines.push(' let body: Record<string, unknown> | undefined')
|
|
@@ -532,25 +1168,27 @@ function generateServiceFile(
|
|
|
532
1168
|
lines.push(" 'Content-Type': 'application/json',")
|
|
533
1169
|
lines.push(' }')
|
|
534
1170
|
lines.push('')
|
|
535
|
-
|
|
1171
|
+
// When camelCase flag is on, use rawData (snake_case converted) for route matching
|
|
1172
|
+
const dataVar = flags.camelCase ? 'rawData' : 'data'
|
|
1173
|
+
lines.push(` if (${dataVar} && route) {`)
|
|
536
1174
|
lines.push(' // Interpolate path params')
|
|
537
1175
|
lines.push(' for (const param of route.path) {')
|
|
538
|
-
lines.push(
|
|
1176
|
+
lines.push(` if (${dataVar}[param] !== undefined) {`)
|
|
539
1177
|
lines.push(
|
|
540
|
-
|
|
1178
|
+
` endpoint = endpoint.replace(\`{\${param}}\`, String(${dataVar}[param]))`
|
|
541
1179
|
)
|
|
542
1180
|
lines.push(' }')
|
|
543
1181
|
lines.push(' }')
|
|
544
1182
|
lines.push(' // Extract query params')
|
|
545
1183
|
lines.push(' for (const param of route.query) {')
|
|
546
|
-
lines.push(
|
|
547
|
-
lines.push(
|
|
1184
|
+
lines.push(` if (${dataVar}[param] !== undefined) {`)
|
|
1185
|
+
lines.push(` query[param] = String(${dataVar}[param])`)
|
|
548
1186
|
lines.push(' }')
|
|
549
1187
|
lines.push(' }')
|
|
550
1188
|
lines.push(' // Extract header params')
|
|
551
1189
|
lines.push(' for (const param of route.headers) {')
|
|
552
|
-
lines.push(
|
|
553
|
-
lines.push(
|
|
1190
|
+
lines.push(` if (${dataVar}[param] !== undefined) {`)
|
|
1191
|
+
lines.push(` headers[param] = String(${dataVar}[param])`)
|
|
554
1192
|
lines.push(' }')
|
|
555
1193
|
lines.push(' }')
|
|
556
1194
|
lines.push(' // Everything else goes into body')
|
|
@@ -559,7 +1197,7 @@ function generateServiceFile(
|
|
|
559
1197
|
)
|
|
560
1198
|
lines.push(' const remaining = Object.fromEntries(')
|
|
561
1199
|
lines.push(
|
|
562
|
-
|
|
1200
|
+
` Object.entries(${dataVar}).filter(([k]) => !pathQueryHeaders.has(k))`
|
|
563
1201
|
)
|
|
564
1202
|
lines.push(' )')
|
|
565
1203
|
lines.push(' if (Object.keys(remaining).length > 0) {')
|
|
@@ -656,7 +1294,14 @@ function generateServiceFile(
|
|
|
656
1294
|
lines.push('')
|
|
657
1295
|
lines.push(' const text = await response.text()')
|
|
658
1296
|
lines.push(' if (!text) return {} as T')
|
|
659
|
-
|
|
1297
|
+
if (flags.camelCase) {
|
|
1298
|
+
lines.push(' const result = JSON.parse(text)')
|
|
1299
|
+
lines.push(
|
|
1300
|
+
' return (typeof result === "object" && result !== null && !Array.isArray(result) ? _toCamelCase(result) : result) as T'
|
|
1301
|
+
)
|
|
1302
|
+
} else {
|
|
1303
|
+
lines.push(' return JSON.parse(text) as T')
|
|
1304
|
+
}
|
|
660
1305
|
lines.push(' }')
|
|
661
1306
|
|
|
662
1307
|
lines.push('}')
|
|
@@ -666,7 +1311,8 @@ function generateServiceFile(
|
|
|
666
1311
|
}
|
|
667
1312
|
|
|
668
1313
|
function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
|
|
669
|
-
const { camelName, screamingName
|
|
1314
|
+
const { camelName, screamingName } = vars
|
|
1315
|
+
const displayName = vars.displayName.replace(/'/g, '')
|
|
670
1316
|
const serverUrls = spec.serverUrls.length > 0 ? spec.serverUrls : []
|
|
671
1317
|
const defaultUrl = serverUrls[0]
|
|
672
1318
|
|
|
@@ -677,10 +1323,16 @@ function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
|
|
|
677
1323
|
|
|
678
1324
|
const schemaVarName = `${camelName}BaseUrlSchema`
|
|
679
1325
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
1326
|
+
if (serverUrls.length > 0) {
|
|
1327
|
+
const urlsLiteral = serverUrls.map((u) => JSON.stringify(u)).join(', ')
|
|
1328
|
+
lines.push(
|
|
1329
|
+
`export const ${schemaVarName} = z.enum([${urlsLiteral}]).default(${JSON.stringify(defaultUrl)})`
|
|
1330
|
+
)
|
|
1331
|
+
} else {
|
|
1332
|
+
lines.push(
|
|
1333
|
+
`export const ${schemaVarName} = z.string().describe('Base URL for the ${displayName} API')`
|
|
1334
|
+
)
|
|
1335
|
+
}
|
|
684
1336
|
|
|
685
1337
|
lines.push('')
|
|
686
1338
|
lines.push(`wireVariable({`)
|