@pikku/openapi-parser 0.12.20 → 0.12.21
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 +25 -0
- package/dist/auth-config.d.ts +3 -3
- package/package.json +8 -6
- package/src/auth-config.ts +0 -94
- package/src/codegen.test.ts +0 -973
- package/src/codegen.ts +0 -1670
- package/src/index.ts +0 -24
- package/src/naming.test.ts +0 -260
- package/src/naming.ts +0 -369
- package/src/openapi-to-zod-schema.test.ts +0 -664
- package/src/openapi-to-zod-schema.ts +0 -583
- package/src/parse-openapi.test.ts +0 -316
- package/src/parse-openapi.ts +0 -467
- package/src/zod-codegen.ts +0 -9
- package/tsconfig.json +0 -12
- package/tsconfig.tsbuildinfo +0 -1
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
|
-
}
|