@pikku/openapi-parser 0.12.3 → 0.12.4

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 CHANGED
@@ -1 +1,7 @@
1
1
  # @pikku/openapi-parser
2
+
3
+ ## 0.12.4
4
+
5
+ ### Patch Changes
6
+
7
+ - e3142ad: Use JSON.stringify for safe interpolation of OpenAPI spec values in generated code to prevent code injection via malicious specs.
package/dist/codegen.js CHANGED
@@ -159,7 +159,7 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags) {
159
159
  }
160
160
  const funcParams = hasInput ? `{ ${camelName} }, data` : `{ ${camelName} }`;
161
161
  const returnCast = parsed.responseSchema ? ' as any' : '';
162
- funcConfig.push(` func: async (${funcParams}) => {`, ` return ${camelName}.call('${method}', '${parsed.path}'${hasInput ? ', data' : ''})${returnCast}`, ' },');
162
+ funcConfig.push(` func: async (${funcParams}) => {`, ` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ? ', data' : ''})${returnCast}`, ' },');
163
163
  lines.push(`export const ${named.functionName} = pikkuSessionlessFunc({`);
164
164
  lines.push(funcConfig.join('\n'));
165
165
  lines.push('})');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/openapi-parser",
3
- "version": "0.12.3",
3
+ "version": "0.12.4",
4
4
  "description": "OpenAPI spec parser and code generator for Pikku",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
package/src/codegen.ts ADDED
@@ -0,0 +1,650 @@
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
+ type ZodCodegenContext,
16
+ } from './zod-codegen.js'
17
+ import {
18
+ generateOperationNames,
19
+ detectCommonPrefix,
20
+ type NamedOperation,
21
+ } from './naming.js'
22
+
23
+ interface AddonVars {
24
+ name: string
25
+ camelName: string
26
+ pascalName: string
27
+ screamingName: string
28
+ displayName: string
29
+ description: string
30
+ }
31
+
32
+ interface CodegenFlags {
33
+ oauth: boolean
34
+ secret: boolean
35
+ mcp?: boolean
36
+ }
37
+
38
+ function safeKey(key: string): string {
39
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
40
+ }
41
+
42
+ const GENERIC_SUMMARIES = new Set([
43
+ 'index',
44
+ 'show',
45
+ 'create',
46
+ 'update',
47
+ 'destroy',
48
+ 'delete',
49
+ 'list',
50
+ ])
51
+
52
+ /** Map from HTTP status code to pikku error class name */
53
+ const STATUS_TO_ERROR: Record<number, string> = {
54
+ 400: 'BadRequestError',
55
+ 401: 'UnauthorizedError',
56
+ 403: 'ForbiddenError',
57
+ 404: 'NotFoundError',
58
+ 405: 'MethodNotAllowedError',
59
+ 409: 'ConflictError',
60
+ 422: 'UnprocessableContentError',
61
+ 429: 'TooManyRequestsError',
62
+ 500: 'InternalServerError',
63
+ }
64
+
65
+ function capitalize(str: string): string {
66
+ return str.charAt(0).toUpperCase() + str.slice(1)
67
+ }
68
+
69
+ function humanDescription(parsed: ParsedOperation): string | undefined {
70
+ const description = parsed.description?.trim()
71
+ if (description && !GENERIC_SUMMARIES.has(description.toLowerCase())) {
72
+ return capitalize(description)
73
+ }
74
+ return undefined
75
+ }
76
+
77
+ function getErrorClassesForResponses(
78
+ errorResponses: ErrorResponse[]
79
+ ): string[] {
80
+ const classes: string[] = []
81
+ for (const err of errorResponses) {
82
+ const cls = STATUS_TO_ERROR[err.statusCode]
83
+ if (cls && !classes.includes(cls)) {
84
+ classes.push(cls)
85
+ }
86
+ }
87
+ return classes
88
+ }
89
+
90
+ export function generateAddonFromOpenAPI(
91
+ spec: ParsedSpec,
92
+ vars: AddonVars,
93
+ flags: CodegenFlags
94
+ ): Record<string, string> {
95
+ const files: Record<string, string> = {}
96
+ const { name } = vars
97
+
98
+ // Build context for Zod codegen with component schema refs
99
+ const schemaRefs = new Map<string, string>()
100
+ for (const schemaName of Object.keys(spec.componentSchemas)) {
101
+ schemaRefs.set(schemaName, schemaVarName(schemaName))
102
+ }
103
+ const ctx = createContext(schemaRefs)
104
+
105
+ // Generate operation names
106
+ const paths = spec.operations.map((op) => op.path)
107
+ const commonPrefix = detectCommonPrefix(paths)
108
+ const namedOps = generateOperationNames(
109
+ spec.operations.map((op) => ({
110
+ method: op.method,
111
+ path: op.path,
112
+ operationId: op.operationId,
113
+ })),
114
+ commonPrefix
115
+ )
116
+
117
+ // Pair named operations with their parsed data
118
+ const opPairs: Array<{ named: NamedOperation; parsed: ParsedOperation }> =
119
+ namedOps.map((named, i) => ({ named, parsed: spec.operations[i] }))
120
+
121
+ // Generate types file with shared schemas (only if there are any)
122
+ if (Object.keys(spec.componentSchemas).length > 0) {
123
+ files[`src/${name}.types.ts`] = generateTypesFile(spec, ctx)
124
+ }
125
+
126
+ // Generate function files
127
+ const functionExports: string[] = []
128
+ for (const { named, parsed } of opPairs) {
129
+ const funcCtx = createContext(schemaRefs)
130
+ const funcCode = generateFunctionFile(
131
+ named,
132
+ parsed,
133
+ vars,
134
+ funcCtx,
135
+ spec,
136
+ flags
137
+ )
138
+ files[`src/functions/${named.functionName}.function.ts`] = funcCode
139
+ functionExports.push(named.functionName)
140
+ }
141
+
142
+ // Generate index.ts with all exports
143
+ files['src/index.ts'] = generateIndexFile(functionExports)
144
+
145
+ // Generate typed API service class with route map
146
+ files[`src/${name}-api.service.ts`] = generateServiceFile(
147
+ spec,
148
+ opPairs,
149
+ vars,
150
+ flags
151
+ )
152
+
153
+ // Generate variable file for BASE_URL
154
+ files[`src/${name}.variable.ts`] = generateVariableFile(spec, vars)
155
+
156
+ return files
157
+ }
158
+
159
+ function generateTypesFile(spec: ParsedSpec, ctx: ZodCodegenContext): string {
160
+ const lines: string[] = []
161
+ lines.push("import { z } from 'zod'")
162
+ lines.push('')
163
+ lines.push(`// Shared schemas from ${spec.info.title} v${spec.info.version}`)
164
+ lines.push('')
165
+
166
+ for (const [name, schema] of Object.entries(spec.componentSchemas)) {
167
+ const varName = schemaVarName(name)
168
+ const zodCode = schemaToZod(schema, ctx)
169
+ lines.push(`export const ${varName} = ${zodCode}`)
170
+ lines.push(
171
+ `export type ${sanitizeTypeName(name)} = z.infer<typeof ${varName}>`
172
+ )
173
+ lines.push('')
174
+ }
175
+
176
+ return lines.join('\n')
177
+ }
178
+
179
+ function generateFunctionFile(
180
+ named: NamedOperation,
181
+ parsed: ParsedOperation,
182
+ vars: AddonVars,
183
+ ctx: ZodCodegenContext,
184
+ spec: ParsedSpec,
185
+ flags: CodegenFlags
186
+ ): string {
187
+ const lines: string[] = []
188
+ const { camelName } = vars
189
+
190
+ // Tag description as file header
191
+ if (parsed.tags.length > 0) {
192
+ const tag = parsed.tags[0]
193
+ const tagDesc = spec.tagDescriptions[tag]
194
+ if (tagDesc) {
195
+ lines.push(`// ${tag} — ${tagDesc}`)
196
+ lines.push('')
197
+ }
198
+ }
199
+
200
+ const hasInput =
201
+ parsed.pathParams.length > 0 ||
202
+ parsed.queryParams.length > 0 ||
203
+ parsed.headerParams.length > 0 ||
204
+ parsed.requestBody
205
+
206
+ const pascalName =
207
+ named.functionName.charAt(0).toUpperCase() + named.functionName.slice(1)
208
+ const inputName = `${pascalName}Input`
209
+ const outputName = `${pascalName}Output`
210
+
211
+ // Determine error imports needed
212
+ const errorClasses = getErrorClassesForResponses(parsed.errorResponses)
213
+
214
+ lines.push("import { z } from 'zod'")
215
+ lines.push("import { pikkuSessionlessFunc } from '#pikku'")
216
+
217
+ if (errorClasses.length > 0) {
218
+ lines.push(
219
+ `import { ${errorClasses.join(', ')} } from '@pikku/core/errors'`
220
+ )
221
+ }
222
+
223
+ lines.push('')
224
+
225
+ // Build Input schema (exported for pikku schema discovery)
226
+ if (hasInput) {
227
+ const inputCode = buildInputSchema(parsed, ctx)
228
+ lines.push(`export const ${inputName} = ${inputCode}`)
229
+ lines.push('')
230
+ }
231
+
232
+ // Build Output schema (exported for pikku schema discovery)
233
+ if (parsed.responseSchema) {
234
+ const outputCode = buildOutputSchema(parsed.responseSchema, ctx)
235
+ lines.push(`export const ${outputName} = ${outputCode}`)
236
+ lines.push('')
237
+ }
238
+
239
+ const description = humanDescription(parsed)
240
+ const method = parsed.method.toUpperCase()
241
+
242
+ const funcConfig: string[] = []
243
+ if (description) {
244
+ funcConfig.push(` description: ${JSON.stringify(description)},`)
245
+ }
246
+ if (hasInput) funcConfig.push(` input: ${inputName},`)
247
+ if (parsed.responseSchema) {
248
+ funcConfig.push(` output: ${outputName},`)
249
+ } else {
250
+ funcConfig.push(' output: z.void(),')
251
+ }
252
+
253
+ if (errorClasses.length > 0) {
254
+ funcConfig.push(` errors: [${errorClasses.join(', ')}],`)
255
+ }
256
+
257
+ if (flags.mcp) {
258
+ funcConfig.push(' mcp: true,')
259
+ }
260
+
261
+ const funcParams = hasInput ? `{ ${camelName} }, data` : `{ ${camelName} }`
262
+
263
+ const returnCast = parsed.responseSchema ? ' as any' : ''
264
+ funcConfig.push(
265
+ ` func: async (${funcParams}) => {`,
266
+ ` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ? ', data' : ''})${returnCast}`,
267
+ ' },'
268
+ )
269
+
270
+ lines.push(`export const ${named.functionName} = pikkuSessionlessFunc({`)
271
+ lines.push(funcConfig.join('\n'))
272
+ lines.push('})')
273
+ lines.push('')
274
+
275
+ return lines.join('\n')
276
+ }
277
+
278
+ function buildInputSchema(
279
+ parsed: ParsedOperation,
280
+ ctx: ZodCodegenContext
281
+ ): string {
282
+ const props: string[] = []
283
+
284
+ for (const param of parsed.pathParams) {
285
+ props.push(formatParamProp(param, ctx))
286
+ }
287
+
288
+ for (const param of parsed.queryParams) {
289
+ props.push(formatParamProp(param, ctx))
290
+ }
291
+
292
+ for (const param of parsed.headerParams) {
293
+ props.push(formatParamProp(param, ctx))
294
+ }
295
+
296
+ if (parsed.requestBody) {
297
+ if (parsed.requestBody.properties) {
298
+ const requiredSet = new Set(parsed.requestBody.required ?? [])
299
+ for (const [key, propSchema] of Object.entries(
300
+ parsed.requestBody.properties
301
+ )) {
302
+ // Skip readOnly properties from input
303
+ if (propSchema.readOnly) continue
304
+ const isOptional = !requiredSet.has(key)
305
+ const zodCode = schemaToZod(propSchema, ctx, { optional: isOptional })
306
+ const withDesc = propSchema.description
307
+ ? `${zodCode}.describe(${JSON.stringify(propSchema.description)})`
308
+ : zodCode
309
+ props.push(` ${safeKey(key)}: ${withDesc},`)
310
+ }
311
+ } else {
312
+ const bodyZod = schemaToZod(parsed.requestBody, ctx)
313
+ props.push(` body: ${bodyZod},`)
314
+ }
315
+ }
316
+
317
+ return `z.object({\n${props.join('\n')}\n})`
318
+ }
319
+
320
+ function formatParamProp(
321
+ param: {
322
+ name: string
323
+ required: boolean
324
+ schema: any
325
+ description?: string
326
+ example?: unknown
327
+ },
328
+ ctx: ZodCodegenContext
329
+ ): string {
330
+ const zodCode = schemaToZod(param.schema, ctx, { optional: !param.required })
331
+
332
+ let descParts: string[] = []
333
+ if (param.description) descParts.push(param.description)
334
+ if (param.example !== undefined)
335
+ descParts.push(`Example: ${JSON.stringify(param.example)}`)
336
+
337
+ const desc =
338
+ descParts.length > 0
339
+ ? `${zodCode}.describe(${JSON.stringify(descParts.join('. '))})`
340
+ : zodCode
341
+
342
+ return ` ${safeKey(param.name)}: ${desc},`
343
+ }
344
+
345
+ function buildOutputSchema(schema: any, ctx: ZodCodegenContext): string {
346
+ // For output schemas, filter out writeOnly properties
347
+ if (schema.properties) {
348
+ const filteredProps: Record<string, any> = {}
349
+ for (const [key, propSchema] of Object.entries(schema.properties) as [
350
+ string,
351
+ any,
352
+ ][]) {
353
+ if (!propSchema.writeOnly) {
354
+ filteredProps[key] = propSchema
355
+ }
356
+ }
357
+ const filteredSchema = { ...schema, properties: filteredProps }
358
+ return schemaToZod(filteredSchema, ctx)
359
+ }
360
+ return schemaToZod(schema, ctx)
361
+ }
362
+
363
+ function generateIndexFile(functionExports: string[]): string {
364
+ const lines: string[] = []
365
+ for (const name of functionExports) {
366
+ lines.push(`export { ${name} } from './functions/${name}.function.js'`)
367
+ }
368
+ lines.push('')
369
+ return lines.join('\n')
370
+ }
371
+
372
+ interface RouteInfo {
373
+ path: string[]
374
+ query: string[]
375
+ headers: string[]
376
+ errors?: Record<number, string>
377
+ }
378
+
379
+ function generateServiceFile(
380
+ spec: ParsedSpec,
381
+ opPairs: Array<{ named: NamedOperation; parsed: ParsedOperation }>,
382
+ vars: AddonVars,
383
+ flags: CodegenFlags
384
+ ): string {
385
+ const { name, pascalName, screamingName, displayName } = vars
386
+ const lines: string[] = []
387
+
388
+ // Always import all error classes used in the switch statement
389
+ const allErrorClasses = new Set<string>(Object.values(STATUS_TO_ERROR))
390
+
391
+ if (flags.oauth) {
392
+ lines.push("import { OAuth2Client } from '@pikku/core/oauth2'")
393
+ lines.push(
394
+ "import type { TypedSecretService } from '#pikku/secrets/pikku-secrets.gen.js'"
395
+ )
396
+ } else if (flags.secret) {
397
+ lines.push(
398
+ `import type { ${pascalName}Secrets } from './${name}.secret.js'`
399
+ )
400
+ }
401
+
402
+ if (allErrorClasses.size > 0) {
403
+ lines.push(
404
+ `import { ${[...allErrorClasses].sort().join(', ')} } from '@pikku/core/errors'`
405
+ )
406
+ }
407
+
408
+ lines.push(
409
+ `import type { TypedVariablesService } from '#pikku/variables/pikku-variables.gen.js'`
410
+ )
411
+ lines.push('')
412
+
413
+ if (flags.oauth) {
414
+ // Use OAuth2 details from spec if available
415
+ const oauthScheme = Object.values(spec.securitySchemes).find(
416
+ (s) => s.type === 'oauth2'
417
+ )
418
+ const authUrl =
419
+ oauthScheme?.flows?.authorizationUrl ??
420
+ 'https://example.com/oauth2/authorize'
421
+ const tokenUrl =
422
+ oauthScheme?.flows?.tokenUrl ?? 'https://example.com/oauth2/token'
423
+ const scopes = oauthScheme?.flows?.scopes
424
+ ? Object.keys(oauthScheme.flows.scopes)
425
+ : ['read', 'write']
426
+
427
+ lines.push(`export const ${screamingName}_OAUTH2_CONFIG = {`)
428
+ lines.push(` tokenSecretId: '${screamingName}_TOKENS',`)
429
+ lines.push(` authorizationUrl: ${JSON.stringify(authUrl)},`)
430
+ lines.push(` tokenUrl: ${JSON.stringify(tokenUrl)},`)
431
+ lines.push(` scopes: ${JSON.stringify(scopes)},`)
432
+ lines.push('}')
433
+ lines.push('')
434
+ }
435
+
436
+ // Generate route map from parsed operations
437
+ const routes: Record<string, RouteInfo> = {}
438
+ for (const { parsed } of opPairs) {
439
+ const key = `${parsed.method.toUpperCase()} ${parsed.path}`
440
+ const route: RouteInfo = {
441
+ path: parsed.pathParams.map((p) => p.name),
442
+ query: parsed.queryParams.map((p) => p.name),
443
+ headers: parsed.headerParams.map((p) => p.name),
444
+ }
445
+ if (parsed.errorResponses.length > 0) {
446
+ route.errors = {}
447
+ for (const err of parsed.errorResponses) {
448
+ route.errors[err.statusCode] = err.description
449
+ }
450
+ }
451
+ routes[key] = route
452
+ }
453
+
454
+ lines.push(
455
+ `const ROUTES: Record<string, { path: string[], query: string[], headers: string[], errors?: Record<number, string> }> = ${JSON.stringify(routes, null, 2)}`
456
+ )
457
+ lines.push('')
458
+
459
+ // Class declaration
460
+ lines.push(`export class ${pascalName}Service {`)
461
+ lines.push(' private baseUrl: string')
462
+
463
+ if (flags.oauth) {
464
+ lines.push(' private oauth: OAuth2Client')
465
+ lines.push('')
466
+ lines.push(
467
+ ` constructor(secrets: TypedSecretService, variables: TypedVariablesService) {`
468
+ )
469
+ lines.push(
470
+ ` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`
471
+ )
472
+ lines.push(' this.oauth = new OAuth2Client(')
473
+ lines.push(` ${screamingName}_OAUTH2_CONFIG,`)
474
+ lines.push(` '${screamingName}_APP_CREDENTIALS',`)
475
+ lines.push(' secrets')
476
+ lines.push(' )')
477
+ lines.push(' }')
478
+ } else if (flags.secret) {
479
+ lines.push('')
480
+ lines.push(
481
+ ` constructor(private creds: ${pascalName}Secrets, variables: TypedVariablesService) {`
482
+ )
483
+ lines.push(
484
+ ` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`
485
+ )
486
+ lines.push(' }')
487
+ } else {
488
+ lines.push('')
489
+ lines.push(` constructor(variables: TypedVariablesService) {`)
490
+ lines.push(
491
+ ` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`
492
+ )
493
+ lines.push(' }')
494
+ }
495
+
496
+ lines.push('')
497
+
498
+ // call() method — splits data into path/query/headers/body using route map
499
+ lines.push(' async call<T>(')
500
+ lines.push(" method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',")
501
+ lines.push(' path: string,')
502
+ lines.push(' data?: Record<string, unknown>')
503
+ lines.push(' ): Promise<T> {')
504
+ lines.push(' const route = ROUTES[`${method} ${path}`]')
505
+ lines.push(' let endpoint = path')
506
+ lines.push(' let body: Record<string, unknown> | undefined')
507
+ lines.push(' const query: Record<string, string> = {}')
508
+ lines.push(' const headers: Record<string, string> = {')
509
+ lines.push(" 'Content-Type': 'application/json',")
510
+ lines.push(' }')
511
+ lines.push('')
512
+ lines.push(' if (data && route) {')
513
+ lines.push(' // Interpolate path params')
514
+ lines.push(' for (const param of route.path) {')
515
+ lines.push(' if (data[param] !== undefined) {')
516
+ lines.push(
517
+ ' endpoint = endpoint.replace(`{${param}}`, String(data[param]))'
518
+ )
519
+ lines.push(' }')
520
+ lines.push(' }')
521
+ lines.push(' // Extract query params')
522
+ lines.push(' for (const param of route.query) {')
523
+ lines.push(' if (data[param] !== undefined) {')
524
+ lines.push(' query[param] = String(data[param])')
525
+ lines.push(' }')
526
+ lines.push(' }')
527
+ lines.push(' // Extract header params')
528
+ lines.push(' for (const param of route.headers) {')
529
+ lines.push(' if (data[param] !== undefined) {')
530
+ lines.push(' headers[param] = String(data[param])')
531
+ lines.push(' }')
532
+ lines.push(' }')
533
+ lines.push(' // Everything else goes into body')
534
+ lines.push(
535
+ ' const pathQueryHeaders = new Set([...route.path, ...route.query, ...route.headers])'
536
+ )
537
+ lines.push(' const remaining = Object.fromEntries(')
538
+ lines.push(
539
+ ' Object.entries(data).filter(([k]) => !pathQueryHeaders.has(k))'
540
+ )
541
+ lines.push(' )')
542
+ lines.push(' if (Object.keys(remaining).length > 0) {')
543
+ lines.push(' body = remaining')
544
+ lines.push(' }')
545
+ lines.push(' }')
546
+ lines.push('')
547
+ lines.push(' const url = new URL(`${this.baseUrl}${endpoint}`)')
548
+ lines.push(' for (const [key, value] of Object.entries(query)) {')
549
+ lines.push(' url.searchParams.set(key, value)')
550
+ lines.push(' }')
551
+ lines.push('')
552
+
553
+ if (flags.oauth) {
554
+ lines.push(
555
+ ' const response = await this.oauth.request(url.toString(), {'
556
+ )
557
+ lines.push(' method,')
558
+ lines.push(' headers,')
559
+ lines.push(' body: body ? JSON.stringify(body) : undefined,')
560
+ lines.push(' })')
561
+ } else if (flags.secret) {
562
+ // Use apiKey details from spec if available
563
+ const apiKeyScheme = Object.values(spec.securitySchemes).find(
564
+ (s) => s.type === 'apiKey'
565
+ )
566
+ if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
567
+ lines.push(
568
+ ` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`
569
+ )
570
+ } else {
571
+ lines.push(' headers.Authorization = `Bearer ${this.creds.apiKey}`')
572
+ }
573
+ lines.push('')
574
+ lines.push(' const response = await fetch(url.toString(), {')
575
+ lines.push(' method,')
576
+ lines.push(' headers,')
577
+ lines.push(' body: body ? JSON.stringify(body) : undefined,')
578
+ lines.push(' })')
579
+ } else {
580
+ lines.push(' const response = await fetch(url.toString(), {')
581
+ lines.push(' method,')
582
+ lines.push(' headers,')
583
+ lines.push(' body: body ? JSON.stringify(body) : undefined,')
584
+ lines.push(' })')
585
+ }
586
+
587
+ lines.push('')
588
+ lines.push(' if (!response.ok) {')
589
+ lines.push(' const errorText = await response.text()')
590
+ lines.push(
591
+ ' const errorMessage = route?.errors?.[response.status] ?? errorText'
592
+ )
593
+ lines.push(' switch (response.status) {')
594
+ lines.push(' case 400: throw new BadRequestError(errorMessage)')
595
+ lines.push(' case 401: throw new UnauthorizedError(errorMessage)')
596
+ lines.push(' case 403: throw new ForbiddenError(errorMessage)')
597
+ lines.push(' case 404: throw new NotFoundError(errorMessage)')
598
+ lines.push(' case 405: throw new MethodNotAllowedError(errorMessage)')
599
+ lines.push(' case 409: throw new ConflictError(errorMessage)')
600
+ lines.push(
601
+ ' case 422: throw new UnprocessableContentError(errorMessage)'
602
+ )
603
+ lines.push(' case 429: throw new TooManyRequestsError(errorMessage)')
604
+ lines.push(' case 500: throw new InternalServerError(errorMessage)')
605
+ lines.push(
606
+ ` default: throw new Error(\`${displayName} API error (\${response.status}): \${errorText}\`)`
607
+ )
608
+ lines.push(' }')
609
+ lines.push(' }')
610
+ lines.push('')
611
+ lines.push(' const text = await response.text()')
612
+ lines.push(' if (!text) return {} as T')
613
+ lines.push(' return JSON.parse(text) as T')
614
+ lines.push(' }')
615
+
616
+ lines.push('}')
617
+ lines.push('')
618
+
619
+ return lines.join('\n')
620
+ }
621
+
622
+ function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
623
+ const { camelName, screamingName, displayName } = vars
624
+ const serverUrls = spec.serverUrls.length > 0 ? spec.serverUrls : []
625
+ const defaultUrl = serverUrls[0]
626
+
627
+ const lines: string[] = []
628
+ lines.push("import { z } from 'zod'")
629
+ lines.push("import { wireVariable } from '@pikku/core/variable'")
630
+ lines.push('')
631
+
632
+ const schemaVarName = `${camelName}BaseUrlSchema`
633
+
634
+ const urlsLiteral = serverUrls.map((u) => JSON.stringify(u)).join(', ')
635
+ lines.push(
636
+ `export const ${schemaVarName} = z.enum([${urlsLiteral}]).default(${JSON.stringify(defaultUrl)})`
637
+ )
638
+
639
+ lines.push('')
640
+ lines.push(`wireVariable({`)
641
+ lines.push(` name: '${screamingName}_BASE_URL',`)
642
+ lines.push(` displayName: '${displayName} Base URL',`)
643
+ lines.push(` description: 'The base URL for the ${displayName} API.',`)
644
+ lines.push(` variableId: '${screamingName}_BASE_URL',`)
645
+ lines.push(` schema: ${schemaVarName},`)
646
+ lines.push(`})`)
647
+ lines.push('')
648
+
649
+ return lines.join('\n')
650
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ export {
2
+ parseOpenAPISpec,
3
+ computeContractHash,
4
+ type ParsedSpec,
5
+ type ParsedOperation,
6
+ type ParsedParam,
7
+ type ErrorResponse,
8
+ type SecuritySchemeInfo,
9
+ } from './parse-openapi.js'
10
+
11
+ export {
12
+ generateAddonFromOpenAPI,
13
+ } from './codegen.js'
14
+
15
+ export {
16
+ generateOperationNames,
17
+ detectCommonPrefix,
18
+ type NamedOperation,
19
+ } from './naming.js'
20
+
21
+ export {
22
+ type OpenAPISchema,
23
+ type ZodCodegenContext,
24
+ createContext,
25
+ schemaToZod,
26
+ schemaVarName,
27
+ sanitizeTypeName,
28
+ } from './openapi-to-zod-schema.js'