@pikku/openapi-parser 0.12.20 → 0.12.22

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/src/codegen.ts DELETED
@@ -1,1670 +0,0 @@
1
- /**
2
- * Generates addon files from a parsed OpenAPI spec.
3
- * Returns Record<string, string> compatible with getAddonFiles output.
4
- */
5
- import type {
6
- ParsedSpec,
7
- ParsedOperation,
8
- ErrorResponse,
9
- } from './parse-openapi.js'
10
- import {
11
- schemaToZod,
12
- schemaVarName,
13
- sanitizeTypeName,
14
- createContext,
15
- snakeToCamel,
16
- type ZodCodegenContext,
17
- } from './zod-codegen.js'
18
- import {
19
- generateOperationNames,
20
- detectCommonPrefix,
21
- type NamedOperation,
22
- } from './naming.js'
23
- import { authHeaderValue, type AuthConfig } from './auth-config.js'
24
-
25
- interface AddonVars {
26
- name: string
27
- camelName: string
28
- pascalName: string
29
- screamingName: string
30
- displayName: string
31
- description: string
32
- }
33
-
34
- interface CodegenFlags {
35
- oauth: boolean
36
- secret: boolean
37
- credential?: 'apikey' | 'bearer' | 'oauth2'
38
- mcp?: boolean
39
- camelCase?: boolean
40
- /** Operator-supplied auth overrides (custom header, delegated login). */
41
- authConfig?: AuthConfig
42
- }
43
-
44
- function safeKey(key: string): string {
45
- return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
46
- }
47
-
48
- const GENERIC_SUMMARIES = new Set([
49
- 'index',
50
- 'show',
51
- 'create',
52
- 'update',
53
- 'destroy',
54
- 'delete',
55
- 'list',
56
- ])
57
-
58
- /** Map from HTTP status code to pikku error class name */
59
- const STATUS_TO_ERROR: Record<number, string> = {
60
- 400: 'BadRequestError',
61
- 401: 'UnauthorizedError',
62
- 403: 'ForbiddenError',
63
- 404: 'NotFoundError',
64
- 405: 'MethodNotAllowedError',
65
- 409: 'ConflictError',
66
- 422: 'UnprocessableContentError',
67
- 429: 'TooManyRequestsError',
68
- 500: 'InternalServerError',
69
- }
70
-
71
- function capitalize(str: string): string {
72
- return str.charAt(0).toUpperCase() + str.slice(1)
73
- }
74
-
75
- /**
76
- * Turn an operationId like `contactsControllerGetContacts` (or snake/kebab
77
- * variants) into a readable phrase — split camel/snake/kebab into words, drop
78
- * noise segments ("controller"), and capitalize. Returns undefined if nothing
79
- * usable remains.
80
- */
81
- function humanizeOperationId(operationId?: string): string | undefined {
82
- if (!operationId) return undefined
83
- const words = operationId
84
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
85
- .replace(/[_-]+/g, ' ')
86
- .split(/\s+/)
87
- .map((w) => w.trim())
88
- .filter((w) => w && w.toLowerCase() !== 'controller')
89
- if (words.length === 0) return undefined
90
- return capitalize(words.join(' ').toLowerCase())
91
- }
92
-
93
- /**
94
- * A description for the generated function (and its MCP tool). Prefers the
95
- * spec's `description`, then `summary`; when a spec omits both (common), a
96
- * synthesized description is still emitted so no function — and no MCP tool —
97
- * ships description-less: a humanized operationId, else "METHOD /path".
98
- */
99
- function humanDescription(parsed: ParsedOperation): string {
100
- const description = parsed.description?.trim()
101
- if (description && !GENERIC_SUMMARIES.has(description.toLowerCase())) {
102
- return capitalize(description)
103
- }
104
- const summary = parsed.summary?.trim()
105
- if (summary && !GENERIC_SUMMARIES.has(summary.toLowerCase())) {
106
- return capitalize(summary)
107
- }
108
- return (
109
- humanizeOperationId(parsed.operationId) ??
110
- `${parsed.method.toUpperCase()} ${parsed.path}`
111
- )
112
- }
113
-
114
- function getErrorClassesForResponses(
115
- errorResponses: ErrorResponse[]
116
- ): string[] {
117
- const classes: string[] = []
118
- for (const err of errorResponses) {
119
- const cls = STATUS_TO_ERROR[err.statusCode]
120
- if (cls && !classes.includes(cls)) {
121
- classes.push(cls)
122
- }
123
- }
124
- return classes
125
- }
126
-
127
- /**
128
- * Collect all component schema names directly referenced by an operation's
129
- * params, request body, and response schema.
130
- */
131
- function collectOperationSchemaRefs(
132
- parsed: ParsedOperation,
133
- componentNames: Set<string>,
134
- schemaIdentityMap: Map<object, string>
135
- ): Set<string> {
136
- const refs = new Set<string>()
137
- const allSchemas: any[] = []
138
-
139
- // Gather all schema objects from this operation
140
- for (const p of [
141
- ...parsed.pathParams,
142
- ...parsed.queryParams,
143
- ...parsed.headerParams,
144
- ]) {
145
- if (p.schema) allSchemas.push(p.schema)
146
- }
147
- if (parsed.requestBody) allSchemas.push(parsed.requestBody)
148
- if (parsed.responseSchema) allSchemas.push(parsed.responseSchema)
149
-
150
- for (const schema of allSchemas) {
151
- // Check if the schema itself IS a component schema (by object identity).
152
- // collectSchemaRefs skips the root identity check, so we must do it here.
153
- if (schema && typeof schema === 'object') {
154
- const identityName = schemaIdentityMap.get(schema)
155
- if (identityName && componentNames.has(identityName)) {
156
- refs.add(identityName)
157
- }
158
- }
159
- for (const ref of collectSchemaRefs(
160
- schema,
161
- componentNames,
162
- schemaIdentityMap
163
- )) {
164
- refs.add(ref)
165
- }
166
- }
167
- return refs
168
- }
169
-
170
- /**
171
- * Compute the transitive closure of schema dependencies.
172
- * Given a set of schema names, expand it to include all schemas they depend on.
173
- */
174
- function transitiveClosure(
175
- schemaNames: Set<string>,
176
- allSchemas: Record<string, any>,
177
- componentNames: Set<string>,
178
- schemaIdentityMap: Map<object, string>
179
- ): Set<string> {
180
- const result = new Set<string>()
181
- const queue = [...schemaNames]
182
- while (queue.length > 0) {
183
- const name = queue.pop()!
184
- if (result.has(name)) continue
185
- if (!allSchemas[name]) continue
186
- result.add(name)
187
- const deps = collectSchemaRefs(
188
- allSchemas[name],
189
- componentNames,
190
- schemaIdentityMap
191
- )
192
- for (const dep of deps) {
193
- if (!result.has(dep)) queue.push(dep)
194
- }
195
- }
196
- return result
197
- }
198
-
199
- interface SchemaPartition {
200
- /** Schemas referenced by 2+ operations — go in the shared types file */
201
- shared: Set<string>
202
- /** Map from schema name to the single operation index that uses it */
203
- single: Map<string, number>
204
- /** Schemas not referenced by any operation (directly or transitively) */
205
- unused: Set<string>
206
- }
207
-
208
- /**
209
- * Partition component schemas into shared, single-use, and unused buckets.
210
- *
211
- * Algorithm:
212
- * 1. For each operation, collect its directly referenced component schemas
213
- * 2. Expand each operation's refs to include transitive deps
214
- * 3. Count how many operations reference each schema (via transitive closure)
215
- * 4. Schemas with 2+ operations → shared, 1 operation → single, 0 → unused
216
- * 5. Single-use schemas whose transitive deps include shared schemas still go
217
- * into single-use (the function file will import those shared deps)
218
- */
219
- function partitionSchemas(
220
- spec: ParsedSpec,
221
- schemaIdentityMap: Map<object, string>
222
- ): SchemaPartition {
223
- const componentNames = new Set(Object.keys(spec.componentSchemas))
224
-
225
- // Step 1: Direct refs per operation
226
- const opDirectRefs: Set<string>[] = spec.operations.map((op) =>
227
- collectOperationSchemaRefs(op, componentNames, schemaIdentityMap)
228
- )
229
-
230
- // Step 2: Transitive refs per operation
231
- const opTransitiveRefs: Set<string>[] = opDirectRefs.map((directRefs) =>
232
- transitiveClosure(
233
- directRefs,
234
- spec.componentSchemas,
235
- componentNames,
236
- schemaIdentityMap
237
- )
238
- )
239
-
240
- // Step 3: Count how many operations reference each schema
241
- const refCount = new Map<string, Set<number>>()
242
- for (let i = 0; i < opTransitiveRefs.length; i++) {
243
- for (const name of opTransitiveRefs[i]) {
244
- if (!refCount.has(name)) refCount.set(name, new Set())
245
- refCount.get(name)!.add(i)
246
- }
247
- }
248
-
249
- // Step 4: Partition
250
- const shared = new Set<string>()
251
- const single = new Map<string, number>()
252
- const unused = new Set<string>()
253
-
254
- for (const name of componentNames) {
255
- const ops = refCount.get(name)
256
- if (!ops || ops.size === 0) {
257
- unused.add(name)
258
- } else if (ops.size === 1) {
259
- single.set(name, [...ops][0])
260
- } else {
261
- shared.add(name)
262
- }
263
- }
264
-
265
- // Step 5: If a single-use schema transitively depends on another single-use
266
- // schema from a DIFFERENT operation, promote both to shared.
267
- // This handles chains like: OpA → SchemaX → SchemaY ← OpB
268
- // Also: single-use schemas that depend on other single-use schemas from the
269
- // same operation stay single-use (they'll be inlined together).
270
- let changed = true
271
- while (changed) {
272
- changed = false
273
- for (const [name, opIdx] of [...single]) {
274
- const deps = collectSchemaRefs(
275
- spec.componentSchemas[name],
276
- componentNames,
277
- schemaIdentityMap
278
- )
279
- for (const dep of deps) {
280
- if (single.has(dep) && single.get(dep) !== opIdx) {
281
- // Dep is single-use but for a different operation — promote both
282
- shared.add(dep)
283
- single.delete(dep)
284
- shared.add(name)
285
- single.delete(name)
286
- changed = true
287
- break
288
- }
289
- }
290
- }
291
- }
292
-
293
- return { shared, single, unused }
294
- }
295
-
296
- export function generateAddonFromOpenAPI(
297
- spec: ParsedSpec,
298
- vars: AddonVars,
299
- flags: CodegenFlags
300
- ): Record<string, string> {
301
- const files: Record<string, string> = {}
302
- const { name } = vars
303
-
304
- // Build context for Zod codegen with component schema refs
305
- const schemaRefs = new Map<string, string>()
306
- for (const schemaName of Object.keys(spec.componentSchemas)) {
307
- schemaRefs.set(schemaName, schemaVarName(schemaName))
308
- }
309
-
310
- // Build an identity map from resolved schema objects to their component names.
311
- // After $ref resolution, many references become the same JS object as the
312
- // component schema (or a spread-copy). This map lets the codegen detect those
313
- // resolved references by object identity instead of relying on $ref strings.
314
- const schemaIdentityMap = new Map<object, string>()
315
- for (const [schemaName, schema] of Object.entries(spec.componentSchemas)) {
316
- if (schema && typeof schema === 'object') {
317
- schemaIdentityMap.set(schema, schemaName)
318
- }
319
- }
320
-
321
- // Partition schemas: shared (2+ ops), single-use (1 op), unused (0 ops)
322
- const partition = partitionSchemas(spec, schemaIdentityMap)
323
-
324
- // Build the set of schemas that go in the shared types file:
325
- // shared schemas + their transitive dependencies
326
- const componentNames = new Set(Object.keys(spec.componentSchemas))
327
- const sharedWithDeps = transitiveClosure(
328
- partition.shared,
329
- spec.componentSchemas,
330
- componentNames,
331
- schemaIdentityMap
332
- )
333
- // Any transitive dep of a shared schema that was single-use gets promoted to shared
334
- for (const name of sharedWithDeps) {
335
- partition.single.delete(name)
336
- }
337
-
338
- // Build a reduced componentSchemas for the types file (only shared schemas)
339
- const sharedComponentSchemas: Record<string, any> = {}
340
- for (const schemaName of sharedWithDeps) {
341
- if (spec.componentSchemas[schemaName]) {
342
- sharedComponentSchemas[schemaName] = spec.componentSchemas[schemaName]
343
- }
344
- }
345
-
346
- // schemaRefs for shared types file context — only shared schemas
347
- const sharedSchemaRefs = new Map<string, string>()
348
- for (const schemaName of sharedWithDeps) {
349
- sharedSchemaRefs.set(schemaName, schemaVarName(schemaName))
350
- }
351
-
352
- const ctx = createContext(sharedSchemaRefs, schemaIdentityMap, {
353
- camelCase: flags.camelCase,
354
- })
355
-
356
- // Generate operation names
357
- const paths = spec.operations.map((op) => op.path)
358
- const commonPrefix = detectCommonPrefix(paths)
359
- const namedOps = generateOperationNames(
360
- spec.operations.map((op) => ({
361
- method: op.method,
362
- path: op.path,
363
- operationId: op.operationId,
364
- })),
365
- commonPrefix
366
- )
367
-
368
- // Pair named operations with their parsed data
369
- const opPairs: Array<{ named: NamedOperation; parsed: ParsedOperation }> =
370
- namedOps.map((named, i) => ({ named, parsed: spec.operations[i] }))
371
-
372
- // Generate types file with shared schemas (only if there are any)
373
- if (Object.keys(sharedComponentSchemas).length > 0) {
374
- files[`src/${name}.types.ts`] = generateTypesFile(
375
- { ...spec, componentSchemas: sharedComponentSchemas },
376
- ctx,
377
- schemaIdentityMap
378
- )
379
- }
380
-
381
- // Collect single-use schemas per operation index
382
- const singleSchemasPerOp = new Map<number, Set<string>>()
383
- for (const [schemaName, opIdx] of partition.single) {
384
- if (!singleSchemasPerOp.has(opIdx)) singleSchemasPerOp.set(opIdx, new Set())
385
- singleSchemasPerOp.get(opIdx)!.add(schemaName)
386
- }
387
- // Expand single-use schemas to include their transitive deps that are also single-use
388
- for (const [_opIdx, schemas] of singleSchemasPerOp) {
389
- const expanded = transitiveClosure(
390
- schemas,
391
- spec.componentSchemas,
392
- componentNames,
393
- schemaIdentityMap
394
- )
395
- // Only keep schemas that are NOT in the shared set
396
- for (const s of expanded) {
397
- if (!sharedWithDeps.has(s) && !partition.unused.has(s)) {
398
- schemas.add(s)
399
- }
400
- }
401
- }
402
-
403
- // Generate function files
404
- const functionExports: string[] = []
405
- for (let i = 0; i < opPairs.length; i++) {
406
- const { named, parsed } = opPairs[i]
407
- const funcCtx = createContext(schemaRefs, schemaIdentityMap, {
408
- camelCase: flags.camelCase,
409
- })
410
- const inlineSchemas = singleSchemasPerOp.get(i) ?? new Set<string>()
411
- const funcCode = generateFunctionFile(
412
- named,
413
- parsed,
414
- vars,
415
- funcCtx,
416
- spec,
417
- flags,
418
- inlineSchemas,
419
- sharedWithDeps,
420
- schemaIdentityMap
421
- )
422
- files[`src/functions/${named.functionName}.function.ts`] = funcCode
423
- functionExports.push(named.functionName)
424
- }
425
-
426
- // Delegated login: emit the upstream authenticate() implementation
427
- const upstreamAuthExport = flags.authConfig?.delegated
428
- ? `authenticate${vars.pascalName}Upstream`
429
- : undefined
430
- if (flags.authConfig?.delegated) {
431
- files[`src/${name}-upstream-auth.ts`] = generateUpstreamAuthFile(
432
- vars,
433
- flags.authConfig
434
- )
435
- }
436
-
437
- // Generate index.ts with all exports
438
- files['src/index.ts'] = generateIndexFile(functionExports, {
439
- upstreamAuth: upstreamAuthExport
440
- ? { name, export: upstreamAuthExport }
441
- : undefined,
442
- })
443
-
444
- // Generate typed API service class with route map
445
- files[`src/${name}-api.service.ts`] = generateServiceFile(
446
- spec,
447
- opPairs,
448
- vars,
449
- flags
450
- )
451
-
452
- if (flags.oauth || flags.credential === 'oauth2') {
453
- files[`src/${name}.credential.ts`] = generateCredentialFile(spec, vars)
454
- }
455
-
456
- // Generate variable file for BASE_URL
457
- files[`src/${name}.variable.ts`] = generateVariableFile(spec, vars)
458
-
459
- return files
460
- }
461
-
462
- /**
463
- * Collect all component schema names referenced (directly or via properties/items/allOf/oneOf/anyOf)
464
- * by a given OpenAPI schema object.
465
- *
466
- * After $ref resolution, most $ref strings are gone and replaced by the resolved
467
- * object (often the same JS identity as the component schema). We detect
468
- * dependencies both by surviving $ref strings AND by object identity via
469
- * schemaIdentityMap.
470
- */
471
- function collectSchemaRefs(
472
- schema: any,
473
- componentNames: Set<string>,
474
- schemaIdentityMap: Map<object, string>
475
- ): Set<string> {
476
- const refs = new Set<string>()
477
- const visited = new Set<any>()
478
-
479
- function walk(node: any, isRoot: boolean) {
480
- if (!node || typeof node !== 'object' || visited.has(node)) return
481
- visited.add(node)
482
-
483
- // Check surviving $ref strings
484
- if (node.$ref) {
485
- const refName = node.$ref.split('/').pop()!
486
- if (componentNames.has(refName)) refs.add(refName)
487
- }
488
-
489
- // Check if this node IS a known component schema (resolved $ref by identity).
490
- // Skip this check for the root node (the schema we're analyzing),
491
- // otherwise we'd match the schema against itself and return early,
492
- // missing its internal dependencies.
493
- if (!isRoot) {
494
- const identityName = schemaIdentityMap.get(node)
495
- if (identityName && componentNames.has(identityName)) {
496
- refs.add(identityName)
497
- // Don't walk into the component schema's internals — those are
498
- // handled when that schema is processed as a top-level entry.
499
- return
500
- }
501
- }
502
-
503
- if (node.properties) {
504
- for (const prop of Object.values(node.properties)) walk(prop, false)
505
- }
506
- if (node.items) walk(node.items, false)
507
- if (node.allOf) for (const s of node.allOf) walk(s, false)
508
- if (node.oneOf) for (const s of node.oneOf) walk(s, false)
509
- if (node.anyOf) for (const s of node.anyOf) walk(s, false)
510
- if (
511
- node.additionalProperties &&
512
- typeof node.additionalProperties === 'object'
513
- ) {
514
- walk(node.additionalProperties, false)
515
- }
516
- }
517
-
518
- walk(schema, true)
519
- return refs
520
- }
521
-
522
- /**
523
- * Topologically sort component schemas so that dependencies come before dependents.
524
- * Schemas involved in cycles are placed in arbitrary order (cycles are handled via z.lazy).
525
- */
526
- function topoSortSchemas(
527
- schemas: Record<string, any>,
528
- schemaIdentityMap: Map<object, string>
529
- ): { sorted: string[]; cyclicEdges: Set<string> } {
530
- const names = new Set(Object.keys(schemas))
531
- const deps = new Map<string, Set<string>>()
532
-
533
- const selfRefs = new Set<string>()
534
- for (const [name, schema] of Object.entries(schemas)) {
535
- const refNames = collectSchemaRefs(schema, names, schemaIdentityMap)
536
- if (refNames.has(name)) {
537
- selfRefs.add(name) // track self-referential schemas
538
- }
539
- refNames.delete(name) // remove self-refs from dependency graph
540
- deps.set(name, refNames)
541
- }
542
-
543
- const sorted: string[] = []
544
- const visited = new Set<string>()
545
- const inStack = new Set<string>()
546
- const cyclicEdges = new Set<string>() // "child" schemas that are referenced before declaration
547
-
548
- function visit(name: string) {
549
- if (visited.has(name)) return
550
- if (inStack.has(name)) {
551
- // This is a cycle — mark this name as needing z.lazy()
552
- cyclicEdges.add(name)
553
- return
554
- }
555
- inStack.add(name)
556
- const children = deps.get(name) ?? new Set()
557
- for (const child of children) {
558
- if (names.has(child)) visit(child)
559
- }
560
- inStack.delete(name)
561
- visited.add(name)
562
- sorted.push(name)
563
- }
564
-
565
- for (const name of names) {
566
- visit(name)
567
- }
568
-
569
- // Self-referential schemas also need z.lazy() forward declarations
570
- for (const name of selfRefs) {
571
- cyclicEdges.add(name)
572
- }
573
- return { sorted, cyclicEdges }
574
- }
575
-
576
- function generateTypesFile(
577
- spec: ParsedSpec,
578
- ctx: ZodCodegenContext,
579
- schemaIdentityMap: Map<object, string>
580
- ): string {
581
- const lines: string[] = []
582
- lines.push("import { z } from 'zod'")
583
- lines.push('')
584
- lines.push(`// Shared schemas from ${spec.info.title} v${spec.info.version}`)
585
- lines.push('')
586
-
587
- const { sorted, cyclicEdges } = topoSortSchemas(
588
- spec.componentSchemas,
589
- schemaIdentityMap
590
- )
591
-
592
- // Track emitted variable names and type names to deduplicate collisions
593
- // (e.g. two OpenAPI components that sanitize to the same JS identifier)
594
- const emittedVarNames = new Set<string>()
595
- const emittedTypeNames = new Set<string>()
596
-
597
- function deduplicateName(baseName: string, usedSet: Set<string>): string {
598
- if (!usedSet.has(baseName)) {
599
- usedSet.add(baseName)
600
- return baseName
601
- }
602
- let counter = 2
603
- while (usedSet.has(`${baseName}_${counter}`)) {
604
- counter++
605
- }
606
- const deduped = `${baseName}_${counter}`
607
- usedSet.add(deduped)
608
- return deduped
609
- }
610
-
611
- // For schemas that participate in cycles, emit a z.lazy() forward declaration
612
- // before any schemas are defined, so forward references work.
613
- if (cyclicEdges.size > 0) {
614
- lines.push('// Forward declarations for circular references')
615
- for (const name of cyclicEdges) {
616
- const varName = deduplicateName(schemaVarName(name), emittedVarNames)
617
- const typeName = deduplicateName(sanitizeTypeName(name), emittedTypeNames)
618
- lines.push(
619
- `export const ${varName}: z.ZodType<any> = z.lazy(() => _${varName})`
620
- )
621
- lines.push(`export type ${typeName} = z.infer<typeof ${varName}>`)
622
- lines.push('')
623
- }
624
- }
625
-
626
- // Emit schemas in topological order
627
- for (const name of sorted) {
628
- const schema = spec.componentSchemas[name]
629
- const varName = deduplicateName(schemaVarName(name), emittedVarNames)
630
- const typeName = deduplicateName(sanitizeTypeName(name), emittedTypeNames)
631
- // Temporarily remove the current schema from the identity map so that
632
- // schemaToZod doesn't short-circuit to referencing the variable we're
633
- // currently defining (which would produce `const XSchema = XSchema`).
634
- ctx.schemaIdentityMap.delete(schema)
635
- const zodCode = schemaToZod(schema, ctx)
636
- // Restore it for subsequent schemas that may reference this one.
637
- ctx.schemaIdentityMap.set(schema, name)
638
-
639
- if (cyclicEdges.has(name)) {
640
- // The public variable was already declared above via z.lazy();
641
- // emit the real implementation with an underscore prefix.
642
- lines.push(`const _${varName} = ${zodCode}`)
643
- } else {
644
- lines.push(`export const ${varName} = ${zodCode}`)
645
- lines.push(`export type ${typeName} = z.infer<typeof ${varName}>`)
646
- }
647
- lines.push('')
648
- }
649
-
650
- return lines.join('\n')
651
- }
652
-
653
- function generateFunctionFile(
654
- named: NamedOperation,
655
- parsed: ParsedOperation,
656
- vars: AddonVars,
657
- ctx: ZodCodegenContext,
658
- spec: ParsedSpec,
659
- flags: CodegenFlags,
660
- inlineSchemas: Set<string> = new Set(),
661
- sharedSchemas: Set<string> = new Set(),
662
- schemaIdentityMap: Map<object, string> = new Map()
663
- ): string {
664
- const lines: string[] = []
665
- const { camelName, name } = vars
666
-
667
- const hasInput =
668
- parsed.pathParams.length > 0 ||
669
- parsed.queryParams.length > 0 ||
670
- parsed.headerParams.length > 0 ||
671
- parsed.requestBody
672
-
673
- const pascalName =
674
- named.functionName.charAt(0).toUpperCase() + named.functionName.slice(1)
675
- const inputName = `${pascalName}Input`
676
- const outputName = `${pascalName}Output`
677
-
678
- // Pre-generate Zod code to discover which schema refs are used
679
- let inputCode: string | undefined
680
- let outputCode: string | undefined
681
- if (hasInput) {
682
- inputCode = buildInputSchema(parsed, ctx)
683
- }
684
- if (parsed.responseSchema) {
685
- outputCode = buildOutputSchema(parsed.responseSchema, ctx)
686
- }
687
-
688
- // Bug 2: If generated Zod code is too large (> 500 lines), TypeScript
689
- // can't infer the type (TS7056). Replace with z.any() to stay compilable.
690
- const LINE_LIMIT = 500
691
- if (inputCode && inputCode.split('\n').length > LINE_LIMIT) {
692
- inputCode = 'z.any()'
693
- }
694
- if (outputCode && outputCode.split('\n').length > LINE_LIMIT) {
695
- outputCode = 'z.any()'
696
- }
697
-
698
- // Tag description as file header
699
- if (parsed.tags.length > 0) {
700
- const tag = parsed.tags[0]
701
- const tagDesc = spec.tagDescriptions[tag]
702
- if (tagDesc) {
703
- const cleanDesc = tagDesc
704
- .replace(/\r?\n/g, ' ')
705
- .replace(/\s+/g, ' ')
706
- .trim()
707
- lines.push(`// ${tag} — ${cleanDesc}`)
708
- lines.push('')
709
- }
710
- }
711
-
712
- // Determine error imports needed
713
- const errorClasses = getErrorClassesForResponses(parsed.errorResponses)
714
-
715
- const needsZod = hasInput || !!parsed.responseSchema || inlineSchemas.size > 0
716
- if (needsZod) {
717
- lines.push("import { z } from 'zod'")
718
- }
719
- lines.push("import { pikkuSessionlessFunc } from '#pikku/function'")
720
-
721
- if (errorClasses.length > 0) {
722
- lines.push(
723
- `import { ${errorClasses.join(', ')} } from '@pikku/core/errors'`
724
- )
725
- }
726
-
727
- // Import referenced component schemas from the types file.
728
- // Only import schemas that are in the shared set (not inlined ones).
729
- // In addition to the refs tracked through usedRefs, scan the generated Zod
730
- // code for schema variable names that may have been missed (e.g. when
731
- // a $ref survived resolution in a nested path not covered by usedRefs).
732
- if (Object.keys(spec.componentSchemas).length > 0) {
733
- const allGeneratedCode = [inputCode, outputCode].filter(Boolean).join('\n')
734
- for (const [refName, varName] of ctx.schemaRefs) {
735
- if (!ctx.usedRefs.has(refName) && allGeneratedCode.includes(varName)) {
736
- ctx.usedRefs.add(refName)
737
- }
738
- }
739
-
740
- const schemaImports = [
741
- ...new Set(
742
- [...ctx.usedRefs]
743
- .filter((refName) => sharedSchemas.has(refName)) // only import shared schemas
744
- .map((refName) => ctx.schemaRefs.get(refName))
745
- .filter(Boolean)
746
- ),
747
- ].sort()
748
- if (schemaImports.length > 0) {
749
- lines.push(
750
- `import { ${schemaImports.join(', ')} } from '../${name}.types.js'`
751
- )
752
- }
753
- }
754
-
755
- lines.push('')
756
-
757
- // Emit inline (single-use) schema definitions before input/output
758
- if (inlineSchemas.size > 0) {
759
- // Build a mini component schemas map for just the inline schemas
760
- const inlineComponentSchemas: Record<string, any> = {}
761
- for (const schemaName of inlineSchemas) {
762
- if (spec.componentSchemas[schemaName]) {
763
- inlineComponentSchemas[schemaName] = spec.componentSchemas[schemaName]
764
- }
765
- }
766
-
767
- if (Object.keys(inlineComponentSchemas).length > 0) {
768
- // Create a context that knows about both shared (for imports) and inline schemas
769
- const inlineCtx = createContext(ctx.schemaRefs, schemaIdentityMap, {
770
- camelCase: flags.camelCase,
771
- })
772
-
773
- const { sorted, cyclicEdges } = topoSortSchemas(
774
- inlineComponentSchemas,
775
- schemaIdentityMap
776
- )
777
-
778
- // Bug 4: Pre-generate all Zod code and detect forward references.
779
- // The topological sort may miss dependencies when $ref resolution
780
- // replaces references with copies (breaking object identity).
781
- // Scan generated code for variable names that appear before their
782
- // declaration and promote those to z.lazy() forward declarations.
783
- const inlineSorted = sorted.filter((s) => inlineSchemas.has(s))
784
- const generatedCode = new Map<string, string>()
785
- for (const sName of inlineSorted) {
786
- const schema = spec.componentSchemas[sName]
787
- inlineCtx.schemaIdentityMap.delete(schema)
788
- const zodCode = schemaToZod(schema, inlineCtx)
789
- inlineCtx.schemaIdentityMap.set(schema, sName)
790
- generatedCode.set(sName, zodCode)
791
- }
792
-
793
- // Build a map of variable names to schema names for inline schemas
794
- const inlineVarToName = new Map<string, string>()
795
- for (const sName of inlineSorted) {
796
- inlineVarToName.set(schemaVarName(sName), sName)
797
- }
798
-
799
- // Detect forward references and self-references: for each schema in order,
800
- // check if its generated code references a variable that appears later in
801
- // the sorted list OR references its own variable (self-referential schema).
802
- const forwardRefs = new Set<string>(cyclicEdges)
803
- const emittedSoFar = new Set<string>()
804
- for (const sName of inlineSorted) {
805
- const code = generatedCode.get(sName)!
806
- const ownVarN = schemaVarName(sName)
807
- // Self-reference: schema uses its own variable name in its definition
808
- if (code.includes(ownVarN)) {
809
- forwardRefs.add(sName)
810
- }
811
- for (const [varN, refName] of inlineVarToName) {
812
- if (
813
- refName !== sName &&
814
- !emittedSoFar.has(refName) &&
815
- code.includes(varN)
816
- ) {
817
- // This schema references another inline schema not yet emitted
818
- forwardRefs.add(refName)
819
- }
820
- }
821
- emittedSoFar.add(sName)
822
- }
823
-
824
- const emittedVarNames = new Set<string>()
825
- const emittedTypeNames = new Set<string>()
826
-
827
- // Forward declarations for cycles and forward references within inline schemas
828
- if (forwardRefs.size > 0) {
829
- lines.push('// Forward declarations for circular references')
830
- for (const sName of forwardRefs) {
831
- if (!inlineSchemas.has(sName)) continue
832
- const varN = schemaVarName(sName)
833
- const typeN = sanitizeTypeName(sName)
834
- emittedVarNames.add(varN)
835
- emittedTypeNames.add(typeN)
836
- lines.push(`const ${varN}: z.ZodType<any> = z.lazy(() => _${varN})`)
837
- lines.push('')
838
- }
839
- }
840
-
841
- for (const sName of inlineSorted) {
842
- const varN = schemaVarName(sName)
843
- emittedVarNames.add(varN)
844
-
845
- const zodCode = generatedCode.get(sName)!
846
-
847
- if (forwardRefs.has(sName)) {
848
- lines.push(`const _${varN} = ${zodCode}`)
849
- } else {
850
- lines.push(`const ${varN} = ${zodCode}`)
851
- }
852
- lines.push('')
853
- }
854
- }
855
- }
856
-
857
- // Build Input schema (exported for pikku schema discovery)
858
- if (hasInput && inputCode) {
859
- lines.push(`export const ${inputName} = ${inputCode}`)
860
- lines.push('')
861
- }
862
-
863
- // Build Output schema (exported for pikku schema discovery)
864
- if (parsed.responseSchema && outputCode) {
865
- lines.push(`export const ${outputName} = ${outputCode}`)
866
- lines.push('')
867
- }
868
-
869
- const description = humanDescription(parsed)
870
- const method = parsed.method.toUpperCase()
871
-
872
- const funcConfig: string[] = []
873
- if (description) {
874
- funcConfig.push(` description: ${JSON.stringify(description)},`)
875
- }
876
- if (hasInput) funcConfig.push(` input: ${inputName},`)
877
- if (parsed.responseSchema) {
878
- funcConfig.push(` output: ${outputName},`)
879
- }
880
-
881
- if (errorClasses.length > 0) {
882
- funcConfig.push(` errors: [${errorClasses.join(', ')}],`)
883
- }
884
-
885
- if (flags.mcp) {
886
- funcConfig.push(' mcp: true,')
887
- }
888
-
889
- // Bug 3: Avoid duplicate identifier when camelName is 'data' (TS2300)
890
- const inputParamName = camelName === 'data' ? 'inputData' : 'data'
891
- const funcParams = hasInput
892
- ? `{ ${camelName} }, ${inputParamName}`
893
- : `{ ${camelName} }`
894
-
895
- const returnCast = parsed.responseSchema ? ' as any' : ''
896
- funcConfig.push(
897
- ` func: async (${funcParams}) => {`,
898
- ` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ? `, ${inputParamName}` : ''})${returnCast}`,
899
- ' },'
900
- )
901
-
902
- lines.push(`export const ${named.functionName} = pikkuSessionlessFunc({`)
903
- lines.push(funcConfig.join('\n'))
904
- lines.push('})')
905
- lines.push('')
906
-
907
- return lines.join('\n')
908
- }
909
-
910
- function buildInputSchema(
911
- parsed: ParsedOperation,
912
- ctx: ZodCodegenContext
913
- ): string {
914
- const props: string[] = []
915
- // Track which parameter names have been emitted to prevent duplicates.
916
- // When shared path-level params and operation-level params overlap
917
- // (e.g. both define "Version"), the later (operation-level) one wins.
918
- const emittedNames = new Set<string>()
919
-
920
- // Deduplicate params: operation-level params override shared path-level
921
- // params with the same name. Build deduplicated lists per category.
922
- function deduplicateParams(params: typeof parsed.pathParams) {
923
- const seen = new Map<string, (typeof params)[0]>()
924
- for (const param of params) {
925
- seen.set(param.name, param) // last wins (operation-level comes after shared)
926
- }
927
- return [...seen.values()]
928
- }
929
-
930
- const useCamel = !!ctx.camelCase
931
-
932
- for (const param of deduplicateParams(parsed.pathParams)) {
933
- if (!emittedNames.has(param.name)) {
934
- emittedNames.add(param.name)
935
- props.push(formatParamProp(param, ctx, useCamel))
936
- }
937
- }
938
-
939
- for (const param of deduplicateParams(parsed.queryParams)) {
940
- if (!emittedNames.has(param.name)) {
941
- emittedNames.add(param.name)
942
- props.push(formatParamProp(param, ctx, useCamel))
943
- }
944
- }
945
-
946
- for (const param of deduplicateParams(parsed.headerParams)) {
947
- if (!emittedNames.has(param.name)) {
948
- emittedNames.add(param.name)
949
- props.push(formatParamProp(param, ctx, useCamel))
950
- }
951
- }
952
-
953
- if (parsed.requestBody) {
954
- if (parsed.requestBody.properties) {
955
- const requiredSet = new Set(parsed.requestBody.required ?? [])
956
- for (const [key, propSchema] of Object.entries(
957
- parsed.requestBody.properties
958
- )) {
959
- // Skip readOnly properties from input
960
- if (propSchema.readOnly) continue
961
- // Skip body properties that collide with already-emitted params
962
- if (emittedNames.has(key)) {
963
- console.warn(
964
- `[openapi] Skipping body property '${key}' — collides with path/query/header param`
965
- )
966
- continue
967
- }
968
- emittedNames.add(key)
969
- const isOptional = !requiredSet.has(key)
970
- const zodCode = schemaToZod(propSchema, ctx, { optional: isOptional })
971
- const outputKey = useCamel ? snakeToCamel(key) : key
972
- props.push(` ${safeKey(outputKey)}: ${zodCode},`)
973
- }
974
- } else {
975
- const bodyZod = schemaToZod(parsed.requestBody, ctx)
976
- props.push(` body: ${bodyZod},`)
977
- }
978
- }
979
-
980
- return `z.object({\n${props.join('\n')}\n})`
981
- }
982
-
983
- function formatParamProp(
984
- param: {
985
- name: string
986
- required: boolean
987
- schema: any
988
- description?: string
989
- example?: unknown
990
- },
991
- ctx: ZodCodegenContext,
992
- camelCase = false
993
- ): string {
994
- const zodCode = schemaToZod(param.schema, ctx, { optional: !param.required })
995
-
996
- let descParts: string[] = []
997
- if (param.description)
998
- descParts.push(param.description.replace(/\*\//g, '* /'))
999
- if (param.example !== undefined)
1000
- descParts.push(`Example: ${JSON.stringify(param.example)}`)
1001
-
1002
- const desc =
1003
- descParts.length > 0
1004
- ? `${zodCode}.describe(${JSON.stringify(descParts.join('. '))})`
1005
- : zodCode
1006
-
1007
- const outputKey = camelCase ? snakeToCamel(param.name) : param.name
1008
- return ` ${safeKey(outputKey)}: ${desc},`
1009
- }
1010
-
1011
- function buildOutputSchema(schema: any, ctx: ZodCodegenContext): string {
1012
- // For output schemas, filter out writeOnly properties.
1013
- // Only create a filtered copy if there are actually writeOnly properties,
1014
- // to preserve object identity for schema identity map matching.
1015
- if (schema.properties) {
1016
- const hasWriteOnly = Object.values(schema.properties).some(
1017
- (p: any) => p.writeOnly
1018
- )
1019
- if (hasWriteOnly) {
1020
- const filteredProps: Record<string, any> = {}
1021
- for (const [key, propSchema] of Object.entries(schema.properties) as [
1022
- string,
1023
- any,
1024
- ][]) {
1025
- if (!propSchema.writeOnly) {
1026
- filteredProps[key] = propSchema
1027
- }
1028
- }
1029
- const filteredSchema = { ...schema, properties: filteredProps }
1030
- return schemaToZod(filteredSchema, ctx)
1031
- }
1032
- }
1033
- return schemaToZod(schema, ctx)
1034
- }
1035
-
1036
- function generateIndexFile(
1037
- functionExports: string[],
1038
- extras?: { upstreamAuth?: { name: string; export: string } }
1039
- ): string {
1040
- const lines: string[] = []
1041
- for (const name of functionExports) {
1042
- lines.push(`export { ${name} } from './functions/${name}.function.js'`)
1043
- }
1044
- if (extras?.upstreamAuth) {
1045
- lines.push(
1046
- `export { ${extras.upstreamAuth.export} } from './${extras.upstreamAuth.name}-upstream-auth.js'`
1047
- )
1048
- }
1049
- lines.push('')
1050
- return lines.join('\n')
1051
- }
1052
-
1053
- /**
1054
- * Emit `src/<name>-upstream-auth.ts`: a self-contained authenticate() for
1055
- * delegated login. It performs the upstream login call described by the auth
1056
- * config, extracts the token, reads identity claims from the decoded JWT
1057
- * payload (base64url, NOT signature-verified — the token was just received
1058
- * over TLS from the login we ourselves performed) or the response body, and
1059
- * returns an identity object structurally compatible with
1060
- * `@pikku/better-auth`'s `UpstreamIdentity` — without depending on it.
1061
- */
1062
- function generateUpstreamAuthFile(
1063
- vars: AddonVars,
1064
- authConfig: AuthConfig
1065
- ): string {
1066
- const { pascalName } = vars
1067
- const delegated = authConfig.delegated!
1068
- const claims = delegated.claims
1069
- const namePaths = claims.name
1070
- ? Array.isArray(claims.name)
1071
- ? claims.name
1072
- : [claims.name]
1073
- : []
1074
-
1075
- const lines: string[] = []
1076
- lines.push(`export interface ${pascalName}UpstreamIdentity {`)
1077
- lines.push(' externalId: string')
1078
- lines.push(' email: string')
1079
- lines.push(' name?: string')
1080
- lines.push(' role?: string')
1081
- lines.push(' tenantId?: string')
1082
- lines.push(
1083
- ' credential: { token: string; expiresAt?: number; tenantId?: string }'
1084
- )
1085
- lines.push('}')
1086
- lines.push('')
1087
- lines.push(`export interface ${pascalName}UpstreamCredentials {`)
1088
- lines.push(' email?: string')
1089
- lines.push(' password?: string')
1090
- lines.push(' apiKey?: string')
1091
- lines.push('}')
1092
- lines.push('')
1093
- lines.push('const pick = (obj: unknown, path: string): unknown =>')
1094
- lines.push(' path')
1095
- lines.push(" .split('.')")
1096
- lines.push(' .reduce<any>(')
1097
- lines.push(
1098
- " (o, key) => (o && typeof o === 'object' ? o[key] : undefined),"
1099
- )
1100
- lines.push(' obj')
1101
- lines.push(' )')
1102
- lines.push('')
1103
- lines.push('const str = (value: unknown): string | undefined =>')
1104
- lines.push(
1105
- " typeof value === 'string' && value ? value : typeof value === 'number' ? String(value) : undefined"
1106
- )
1107
- lines.push('')
1108
- lines.push(
1109
- '/** Decode a JWT payload without verifying — see file docblock. */'
1110
- )
1111
- lines.push('const decodeJwtPayload = (token: string): unknown => {')
1112
- lines.push(" const part = token.split('.')[1]")
1113
- lines.push(' if (!part) return undefined')
1114
- lines.push(' try {')
1115
- lines.push(" const pad = part + '==='.slice((part.length + 3) % 4)")
1116
- lines.push(" const bin = atob(pad.replace(/-/g, '+').replace(/_/g, '/'))")
1117
- lines.push(' const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))')
1118
- lines.push(' return JSON.parse(new TextDecoder().decode(bytes))')
1119
- lines.push(' } catch {')
1120
- lines.push(' // Not a decodable JWT — caller treats it as missing claims.')
1121
- lines.push(' return undefined')
1122
- lines.push(' }')
1123
- lines.push('}')
1124
- lines.push('')
1125
- lines.push('/**')
1126
- lines.push(
1127
- ` * Verify credentials against the upstream ${vars.displayName} login and map`
1128
- )
1129
- lines.push(
1130
- ' * its response onto an upstream identity. Returns null when the upstream'
1131
- )
1132
- lines.push(
1133
- ' * rejects the credentials; network errors propagate to the caller.'
1134
- )
1135
- lines.push(' */')
1136
- lines.push(`export const authenticate${pascalName}Upstream = async (`)
1137
- lines.push(` credentials: ${pascalName}UpstreamCredentials,`)
1138
- lines.push(' baseUrl: string')
1139
- lines.push(`): Promise<${pascalName}UpstreamIdentity | null> => {`)
1140
- lines.push(' const headers: Record<string, string> = {')
1141
- lines.push(" 'Content-Type': 'application/json',")
1142
- for (const [header, value] of Object.entries(authConfig.extraHeaders ?? {})) {
1143
- lines.push(` ${JSON.stringify(header)}: ${JSON.stringify(value)},`)
1144
- }
1145
- lines.push(' }')
1146
- lines.push(' const body: Record<string, string> = {}')
1147
- if (delegated.credentials.includes('email')) {
1148
- lines.push(' if (credentials.email) body.email = credentials.email')
1149
- }
1150
- if (delegated.credentials.includes('password')) {
1151
- lines.push(
1152
- ' if (credentials.password) body.password = credentials.password'
1153
- )
1154
- }
1155
- if (delegated.credentials.includes('apiKey')) {
1156
- lines.push(
1157
- ` if (credentials.apiKey) headers[${JSON.stringify(delegated.apiKeyHeader)}] = credentials.apiKey`
1158
- )
1159
- }
1160
- lines.push('')
1161
- lines.push(
1162
- ` const response = await fetch(\`\${baseUrl.replace(/\\/+$/, '')}${delegated.loginPath}\`, {`
1163
- )
1164
- lines.push(
1165
- ` method: ${JSON.stringify(delegated.loginMethod.toUpperCase())},`
1166
- )
1167
- lines.push(' headers,')
1168
- lines.push(
1169
- ' body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,'
1170
- )
1171
- lines.push(' })')
1172
- lines.push(' if (!response.ok) return null')
1173
- lines.push('')
1174
- lines.push(' const data: unknown = await response.json()')
1175
- lines.push(
1176
- ` const token = str(pick(data, ${JSON.stringify(delegated.tokenPath)}))`
1177
- )
1178
- lines.push(' if (!token) return null')
1179
- lines.push('')
1180
- if (claims.source === 'jwt') {
1181
- lines.push(' const claims = decodeJwtPayload(token)')
1182
- } else {
1183
- lines.push(' const claims = data')
1184
- }
1185
- lines.push(' if (!claims) return null')
1186
- lines.push('')
1187
- lines.push(
1188
- ` const externalId = str(pick(claims, ${JSON.stringify(claims.externalId)}))`
1189
- )
1190
- lines.push(
1191
- ` const email = str(pick(claims, ${JSON.stringify(claims.email)}))`
1192
- )
1193
- lines.push(' if (!externalId || !email) return null')
1194
- lines.push('')
1195
- if (namePaths.length > 0) {
1196
- const parts = namePaths
1197
- .map((p) => `str(pick(claims, ${JSON.stringify(p)}))`)
1198
- .join(', ')
1199
- lines.push(` const name = [${parts}]`)
1200
- lines.push(' .filter((part): part is string => Boolean(part))')
1201
- lines.push(" .join(' ') || undefined")
1202
- } else {
1203
- lines.push(' const name = undefined')
1204
- }
1205
- if (claims.role) {
1206
- lines.push(
1207
- ` const role = str(pick(claims, ${JSON.stringify(claims.role)}))`
1208
- )
1209
- } else {
1210
- lines.push(' const role = undefined')
1211
- }
1212
- if (claims.tenantId) {
1213
- lines.push(
1214
- ` const tenantId = str(pick(claims, ${JSON.stringify(claims.tenantId)}))`
1215
- )
1216
- } else {
1217
- lines.push(' const tenantId = undefined')
1218
- }
1219
- if (delegated.expiresAtPath) {
1220
- lines.push(
1221
- ` const expiresAtRaw = pick(data, ${JSON.stringify(delegated.expiresAtPath)})`
1222
- )
1223
- } else if (claims.source === 'jwt') {
1224
- lines.push(" const expiresAtRaw = pick(claims, 'exp')")
1225
- } else {
1226
- lines.push(' const expiresAtRaw = undefined')
1227
- }
1228
- lines.push(
1229
- " const expiresAt = typeof expiresAtRaw === 'number' ? expiresAtRaw : undefined"
1230
- )
1231
- lines.push('')
1232
- lines.push(' return {')
1233
- lines.push(' externalId,')
1234
- lines.push(' email,')
1235
- lines.push(' name,')
1236
- lines.push(' role,')
1237
- lines.push(' tenantId,')
1238
- lines.push(' credential: { token, expiresAt, tenantId },')
1239
- lines.push(' }')
1240
- lines.push('}')
1241
- lines.push('')
1242
- return lines.join('\n')
1243
- }
1244
-
1245
- interface RouteInfo {
1246
- path: string[]
1247
- query: string[]
1248
- headers: string[]
1249
- errors?: Record<number, string>
1250
- }
1251
-
1252
- function generateServiceFile(
1253
- spec: ParsedSpec,
1254
- opPairs: Array<{ named: NamedOperation; parsed: ParsedOperation }>,
1255
- vars: AddonVars,
1256
- flags: CodegenFlags
1257
- ): string {
1258
- const { name, pascalName, screamingName } = vars
1259
- const displayName = vars.displayName.replace(/'/g, '')
1260
- const lines: string[] = []
1261
-
1262
- // Always import all error classes used in the switch statement
1263
- const allErrorClasses = new Set<string>(Object.values(STATUS_TO_ERROR))
1264
-
1265
- if (flags.credential && flags.credential !== 'oauth2') {
1266
- // Per-user credential: no special imports needed, creds passed via constructor
1267
- } else if (flags.oauth || flags.credential === 'oauth2') {
1268
- // OAuth2: the access token is resolved via the credential service and passed
1269
- // via the constructor — no special imports needed.
1270
- } else if (flags.secret) {
1271
- lines.push(
1272
- `import type { ${pascalName}Secrets } from './${name}.secret.js'`
1273
- )
1274
- }
1275
-
1276
- if (allErrorClasses.size > 0) {
1277
- lines.push(
1278
- `import { ${[...allErrorClasses].sort().join(', ')} } from '@pikku/core/errors'`
1279
- )
1280
- }
1281
-
1282
- lines.push(
1283
- `import type { TypedVariablesService } from '#pikku/variables/pikku-variables.gen.js'`
1284
- )
1285
- lines.push('')
1286
-
1287
- // Generate route map from parsed operations
1288
- const routes: Record<string, RouteInfo> = {}
1289
- for (const { parsed } of opPairs) {
1290
- const key = `${parsed.method.toUpperCase()} ${parsed.path}`
1291
- const route: RouteInfo = {
1292
- path: parsed.pathParams.map((p) => p.name),
1293
- query: parsed.queryParams.map((p) => p.name),
1294
- headers: parsed.headerParams.map((p) => p.name),
1295
- }
1296
- if (parsed.errorResponses.length > 0) {
1297
- route.errors = {}
1298
- for (const err of parsed.errorResponses) {
1299
- route.errors[err.statusCode] = err.description
1300
- }
1301
- }
1302
- routes[key] = route
1303
- }
1304
-
1305
- lines.push(
1306
- `const ROUTES: Record<string, { path: string[], query: string[], headers: string[], errors?: Record<number, string> }> = ${JSON.stringify(routes, null, 2)}`
1307
- )
1308
- lines.push('')
1309
-
1310
- if (flags.camelCase) {
1311
- lines.push(
1312
- 'function _toSnakeCase(data: Record<string, unknown>): Record<string, unknown> {'
1313
- )
1314
- lines.push(' return Object.fromEntries(')
1315
- lines.push(
1316
- ' Object.entries(data).map(([k, v]) => [k.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`), v])'
1317
- )
1318
- lines.push(' )')
1319
- lines.push('}')
1320
- lines.push('')
1321
- lines.push(
1322
- 'function _toCamelCase(data: Record<string, unknown>): Record<string, unknown> {'
1323
- )
1324
- lines.push(' return Object.fromEntries(')
1325
- lines.push(
1326
- ' Object.entries(data).map(([k, v]) => [k.replace(/[-_]+(.)/g, (_, c) => c.toUpperCase()), v])'
1327
- )
1328
- lines.push(' )')
1329
- lines.push('}')
1330
- lines.push('')
1331
- }
1332
-
1333
- // Class declaration
1334
- lines.push(`export class ${pascalName}Service {`)
1335
- lines.push(' private baseUrl: string')
1336
-
1337
- if (flags.credential && flags.credential !== 'oauth2') {
1338
- const credField = flags.credential === 'bearer' ? 'token' : 'apiKey'
1339
- lines.push('')
1340
- lines.push(
1341
- ` constructor(private creds: { ${credField}: string }, variables: TypedVariablesService) {`
1342
- )
1343
- lines.push(
1344
- ` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`
1345
- )
1346
- lines.push(' }')
1347
- } else if (flags.oauth || flags.credential === 'oauth2') {
1348
- lines.push('')
1349
- lines.push(
1350
- ` constructor(private creds: { accessToken: string }, variables: TypedVariablesService) {`
1351
- )
1352
- lines.push(
1353
- ` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`
1354
- )
1355
- lines.push(' }')
1356
- } else if (flags.secret) {
1357
- lines.push('')
1358
- lines.push(
1359
- ` constructor(private creds: ${pascalName}Secrets, variables: TypedVariablesService) {`
1360
- )
1361
- lines.push(
1362
- ` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`
1363
- )
1364
- lines.push(' }')
1365
- } else {
1366
- lines.push('')
1367
- lines.push(` constructor(variables: TypedVariablesService) {`)
1368
- lines.push(
1369
- ` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`
1370
- )
1371
- lines.push(' }')
1372
- }
1373
-
1374
- lines.push('')
1375
-
1376
- // call() method — splits data into path/query/headers/body using route map
1377
- lines.push(' async call<T>(')
1378
- lines.push(" method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',")
1379
- lines.push(' path: string,')
1380
- lines.push(' data?: unknown')
1381
- lines.push(' ): Promise<T> {')
1382
- lines.push(' const input = data as Record<string, unknown> | undefined')
1383
- if (flags.camelCase) {
1384
- lines.push(' const rawData = input ? _toSnakeCase(input) : input')
1385
- }
1386
- lines.push(' const route = ROUTES[`${method} ${path}`]')
1387
- lines.push(' let endpoint = path')
1388
- lines.push(' let body: Record<string, unknown> | undefined')
1389
- lines.push(' const query: Record<string, string> = {}')
1390
- lines.push(' const headers: Record<string, string> = {')
1391
- lines.push(" 'Content-Type': 'application/json',")
1392
- for (const [header, value] of Object.entries(
1393
- flags.authConfig?.extraHeaders ?? {}
1394
- )) {
1395
- lines.push(` ${JSON.stringify(header)}: ${JSON.stringify(value)},`)
1396
- }
1397
- lines.push(' }')
1398
- lines.push('')
1399
- // When camelCase flag is on, use rawData (snake_case converted) for route matching
1400
- const dataVar = flags.camelCase ? 'rawData' : 'input'
1401
- lines.push(` if (${dataVar} && route) {`)
1402
- lines.push(' // Interpolate path params')
1403
- lines.push(' for (const param of route.path) {')
1404
- lines.push(` if (${dataVar}[param] !== undefined) {`)
1405
- lines.push(
1406
- ` endpoint = endpoint.replace(\`{\${param}}\`, String(${dataVar}[param]))`
1407
- )
1408
- lines.push(' }')
1409
- lines.push(' }')
1410
- lines.push(' // Extract query params')
1411
- lines.push(' for (const param of route.query) {')
1412
- lines.push(` if (${dataVar}[param] !== undefined) {`)
1413
- lines.push(` query[param] = String(${dataVar}[param])`)
1414
- lines.push(' }')
1415
- lines.push(' }')
1416
- lines.push(' // Extract header params')
1417
- lines.push(' for (const param of route.headers) {')
1418
- lines.push(` if (${dataVar}[param] !== undefined) {`)
1419
- lines.push(` headers[param] = String(${dataVar}[param])`)
1420
- lines.push(' }')
1421
- lines.push(' }')
1422
- lines.push(' // Everything else goes into body')
1423
- lines.push(
1424
- ' const pathQueryHeaders = new Set([...route.path, ...route.query, ...route.headers])'
1425
- )
1426
- lines.push(' const remaining = Object.fromEntries(')
1427
- lines.push(
1428
- ` Object.entries(${dataVar}).filter(([k]) => !pathQueryHeaders.has(k))`
1429
- )
1430
- lines.push(' )')
1431
- lines.push(' if (Object.keys(remaining).length > 0) {')
1432
- lines.push(' body = remaining')
1433
- lines.push(' }')
1434
- lines.push(' }')
1435
- lines.push('')
1436
- lines.push(' const url = new URL(`${this.baseUrl}${endpoint}`)')
1437
- lines.push(' for (const [key, value] of Object.entries(query)) {')
1438
- lines.push(' url.searchParams.set(key, value)')
1439
- lines.push(' }')
1440
- lines.push('')
1441
-
1442
- if (flags.credential && flags.credential !== 'oauth2') {
1443
- // Per-user credential: use creds from wire.getCredentials()
1444
- if (flags.credential === 'bearer') {
1445
- if (flags.authConfig?.headerName) {
1446
- // Custom auth header (e.g. `authentication: <raw jwt>`) from the auth config.
1447
- const { header, value } = authHeaderValue(
1448
- flags.authConfig,
1449
- 'this.creds.token'
1450
- )
1451
- lines.push(` headers[${JSON.stringify(header)}] = ${value}`)
1452
- } else {
1453
- lines.push(' headers.Authorization = `Bearer ${this.creds.token}`')
1454
- }
1455
- } else {
1456
- // apikey: auth-config override wins, else check spec for custom header name
1457
- const apiKeyScheme = Object.values(spec.securitySchemes).find(
1458
- (s) => s.type === 'apiKey'
1459
- )
1460
- if (flags.authConfig?.headerName) {
1461
- const { header, value } = authHeaderValue(
1462
- flags.authConfig,
1463
- 'this.creds.apiKey'
1464
- )
1465
- lines.push(` headers[${JSON.stringify(header)}] = ${value}`)
1466
- } else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
1467
- lines.push(
1468
- ` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`
1469
- )
1470
- } else {
1471
- lines.push(' headers.Authorization = `Bearer ${this.creds.apiKey}`')
1472
- }
1473
- }
1474
- lines.push('')
1475
- lines.push(' const response = await fetch(url.toString(), {')
1476
- lines.push(' method,')
1477
- lines.push(' headers,')
1478
- lines.push(' body: body ? JSON.stringify(body) : undefined,')
1479
- lines.push(' })')
1480
- } else if (flags.oauth || flags.credential === 'oauth2') {
1481
- lines.push(' headers.Authorization = `Bearer ${this.creds.accessToken}`')
1482
- lines.push('')
1483
- lines.push(' const response = await fetch(url.toString(), {')
1484
- lines.push(' method,')
1485
- lines.push(' headers,')
1486
- lines.push(' body: body ? JSON.stringify(body) : undefined,')
1487
- lines.push(' })')
1488
- } else if (flags.secret) {
1489
- // Auth-config override wins, else use apiKey details from spec if available
1490
- const apiKeyScheme = Object.values(spec.securitySchemes).find(
1491
- (s) => s.type === 'apiKey'
1492
- )
1493
- if (flags.authConfig?.headerName) {
1494
- const { header, value } = authHeaderValue(
1495
- flags.authConfig,
1496
- 'this.creds.apiKey'
1497
- )
1498
- lines.push(` headers[${JSON.stringify(header)}] = ${value}`)
1499
- } else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
1500
- lines.push(
1501
- ` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`
1502
- )
1503
- } else {
1504
- lines.push(' headers.Authorization = `Bearer ${this.creds.apiKey}`')
1505
- }
1506
- lines.push('')
1507
- lines.push(' const response = await fetch(url.toString(), {')
1508
- lines.push(' method,')
1509
- lines.push(' headers,')
1510
- lines.push(' body: body ? JSON.stringify(body) : undefined,')
1511
- lines.push(' })')
1512
- } else {
1513
- lines.push(' const response = await fetch(url.toString(), {')
1514
- lines.push(' method,')
1515
- lines.push(' headers,')
1516
- lines.push(' body: body ? JSON.stringify(body) : undefined,')
1517
- lines.push(' })')
1518
- }
1519
-
1520
- lines.push('')
1521
- lines.push(' if (!response.ok) {')
1522
- lines.push(' const errorText = await response.text()')
1523
- lines.push(
1524
- ' const errorMessage = route?.errors?.[response.status] ?? errorText'
1525
- )
1526
- lines.push(' switch (response.status) {')
1527
- lines.push(' case 400: throw new BadRequestError(errorMessage)')
1528
- lines.push(' case 401: throw new UnauthorizedError(errorMessage)')
1529
- lines.push(' case 403: throw new ForbiddenError(errorMessage)')
1530
- lines.push(' case 404: throw new NotFoundError(errorMessage)')
1531
- lines.push(' case 405: throw new MethodNotAllowedError(errorMessage)')
1532
- lines.push(' case 409: throw new ConflictError(errorMessage)')
1533
- lines.push(
1534
- ' case 422: throw new UnprocessableContentError(errorMessage)'
1535
- )
1536
- lines.push(' case 429: throw new TooManyRequestsError(errorMessage)')
1537
- lines.push(' case 500: throw new InternalServerError(errorMessage)')
1538
- lines.push(
1539
- ` default: throw new Error(\`${displayName} API error (\${response.status}): \${errorText}\`)`
1540
- )
1541
- lines.push(' }')
1542
- lines.push(' }')
1543
- lines.push('')
1544
- lines.push(' const text = await response.text()')
1545
- lines.push(' if (!text) return {} as T')
1546
- if (flags.camelCase) {
1547
- lines.push(' const result = JSON.parse(text)')
1548
- lines.push(
1549
- ' return (typeof result === "object" && result !== null && !Array.isArray(result) ? _toCamelCase(result) : result) as T'
1550
- )
1551
- } else {
1552
- lines.push(' return JSON.parse(text) as T')
1553
- }
1554
- lines.push(' }')
1555
-
1556
- lines.push('}')
1557
- lines.push('')
1558
-
1559
- return lines.join('\n')
1560
- }
1561
-
1562
- function generateCredentialFile(spec: ParsedSpec, vars: AddonVars): string {
1563
- const { camelName, screamingName } = vars
1564
- const displayName = vars.displayName.replace(/'/g, '')
1565
- const description = vars.description.replace(/'/g, '')
1566
-
1567
- const oauthScheme = Object.values(spec.securitySchemes).find(
1568
- (s) => s.type === 'oauth2'
1569
- )
1570
- const authorizationUrl = oauthScheme?.flows?.authorizationUrl
1571
- const tokenUrl = oauthScheme?.flows?.tokenUrl
1572
- const scopes = oauthScheme?.flows?.scopes
1573
- ? Object.keys(oauthScheme.flows.scopes)
1574
- : ['read', 'write']
1575
-
1576
- const lines: string[] = []
1577
- lines.push("import { z } from 'zod'")
1578
- lines.push("import { defineCredential } from '@pikku/core/credential'")
1579
- lines.push("import { defineSecret } from '@pikku/core/secret'")
1580
- lines.push('')
1581
- lines.push(`export const ${camelName}TokenSchema = z.object({`)
1582
- lines.push(' accessToken: z.string(),')
1583
- lines.push(' refreshToken: z.string().optional(),')
1584
- lines.push('})')
1585
- lines.push('')
1586
- lines.push(`export const ${camelName}OAuthAppSchema = z.object({`)
1587
- lines.push(" clientId: z.string().describe('OAuth2 app client ID'),")
1588
- lines.push(" clientSecret: z.string().describe('OAuth2 app client secret'),")
1589
- lines.push('})')
1590
- lines.push('')
1591
-
1592
- if (!authorizationUrl || !tokenUrl) {
1593
- lines.push(
1594
- '// TODO: this spec declares no oauth2 flow URLs, so the placeholder(s)'
1595
- )
1596
- lines.push(
1597
- `// below are not real. Replace them with ${displayName}'s endpoints —`
1598
- )
1599
- lines.push('// OAuth will fail against example.com.')
1600
- }
1601
-
1602
- // oauth2 must stay an inline object literal: the inspector reads it via AST
1603
- // (add-credential.ts) and silently drops a config behind an identifier.
1604
- lines.push('defineCredential({')
1605
- lines.push(` name: '${camelName}',`)
1606
- lines.push(` displayName: '${displayName}',`)
1607
- lines.push(` description: '${description}',`)
1608
- lines.push(` type: 'wire',`)
1609
- lines.push(` schema: ${camelName}TokenSchema,`)
1610
- lines.push(' oauth2: {')
1611
- lines.push(` appCredentialSecretId: '${screamingName}_OAUTH_APP',`)
1612
- lines.push(` tokenSecretId: '${screamingName}_OAUTH_TOKENS',`)
1613
- lines.push(
1614
- ` authorizationUrl: ${JSON.stringify(authorizationUrl ?? 'https://example.com/oauth2/authorize')},`
1615
- )
1616
- lines.push(
1617
- ` tokenUrl: ${JSON.stringify(tokenUrl ?? 'https://example.com/oauth2/token')},`
1618
- )
1619
- lines.push(` scopes: ${JSON.stringify(scopes)},`)
1620
- lines.push(' },')
1621
- lines.push('})')
1622
- lines.push('')
1623
- lines.push('defineSecret({')
1624
- lines.push(` name: '${camelName}OAuthApp',`)
1625
- lines.push(` displayName: '${displayName} OAuth App',`)
1626
- lines.push(` description: 'OAuth2 app credentials for ${displayName}',`)
1627
- lines.push(` secretId: '${screamingName}_OAUTH_APP',`)
1628
- lines.push(` schema: ${camelName}OAuthAppSchema,`)
1629
- lines.push('})')
1630
- lines.push('')
1631
-
1632
- return lines.join('\n')
1633
- }
1634
-
1635
- function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
1636
- const { camelName, screamingName } = vars
1637
- const displayName = vars.displayName.replace(/'/g, '')
1638
- const serverUrls = spec.serverUrls.length > 0 ? spec.serverUrls : []
1639
- const defaultUrl = serverUrls[0]
1640
-
1641
- const lines: string[] = []
1642
- lines.push("import { z } from 'zod'")
1643
- lines.push("import { defineVariable } from '@pikku/core/variable'")
1644
- lines.push('')
1645
-
1646
- const schemaVarName = `${camelName}BaseUrlSchema`
1647
-
1648
- if (serverUrls.length > 0) {
1649
- const urlsLiteral = serverUrls.map((u) => JSON.stringify(u)).join(', ')
1650
- lines.push(
1651
- `export const ${schemaVarName} = z.enum([${urlsLiteral}]).default(${JSON.stringify(defaultUrl)})`
1652
- )
1653
- } else {
1654
- lines.push(
1655
- `export const ${schemaVarName} = z.string().describe('Base URL for the ${displayName} API')`
1656
- )
1657
- }
1658
-
1659
- lines.push('')
1660
- lines.push(`defineVariable({`)
1661
- lines.push(` name: '${screamingName}_BASE_URL',`)
1662
- lines.push(` displayName: '${displayName} Base URL',`)
1663
- lines.push(` description: 'The base URL for the ${displayName} API.',`)
1664
- lines.push(` variableId: '${screamingName}_BASE_URL',`)
1665
- lines.push(` schema: ${schemaVarName},`)
1666
- lines.push(`})`)
1667
- lines.push('')
1668
-
1669
- return lines.join('\n')
1670
- }