@pikku/openapi-parser 0.12.3 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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'
package/src/naming.ts ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Derives function/method names from HTTP method + path.
3
+ * Uses operationId when available, otherwise generates from path segments.
4
+ */
5
+
6
+ const IRREGULAR_PLURALS: Record<string, string> = {
7
+ addresses: 'address',
8
+ statuses: 'status',
9
+ indices: 'index',
10
+ analyses: 'analysis',
11
+ quizzes: 'quiz',
12
+ matrices: 'matrix',
13
+ vertices: 'vertex',
14
+ aliases: 'alias',
15
+ buses: 'bus',
16
+ }
17
+
18
+ export function singularize(word: string): string {
19
+ const lower = word.toLowerCase()
20
+ if (IRREGULAR_PLURALS[lower]) {
21
+ const singular = IRREGULAR_PLURALS[lower]
22
+ return word[0] === word[0].toUpperCase()
23
+ ? singular.charAt(0).toUpperCase() + singular.slice(1)
24
+ : singular
25
+ }
26
+ if (lower.endsWith('ies') && lower.length > 4) {
27
+ return word.slice(0, -3) + 'y'
28
+ }
29
+ // -shes, -ches, -xes, -zes, -sses → drop "es"
30
+ if (lower.endsWith('shes') || lower.endsWith('ches') || lower.endsWith('xes') || lower.endsWith('zes') || lower.endsWith('sses')) {
31
+ return word.slice(0, -2)
32
+ }
33
+ // General: drop trailing "s" (covers courses→course, products→product, etc.)
34
+ if (lower.endsWith('s') && !lower.endsWith('ss') && !lower.endsWith('us') && lower.length > 2) {
35
+ return word.slice(0, -1)
36
+ }
37
+ return word
38
+ }
39
+
40
+ export function toCamelCase(str: string): string {
41
+ return str
42
+ .replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
43
+ .replace(/^[A-Z]/, (c) => c.toLowerCase())
44
+ }
45
+
46
+ export function toPascalCase(str: string): string {
47
+ const camel = toCamelCase(str)
48
+ return camel.charAt(0).toUpperCase() + camel.slice(1)
49
+ }
50
+
51
+ /**
52
+ * Strip common API prefix from paths (e.g. /api/v2/).
53
+ * Uses majority voting — a prefix shared by >75% of paths is stripped,
54
+ * so a few outlier paths (e.g. /oauth/...) don't break detection.
55
+ */
56
+ export function detectCommonPrefix(paths: string[]): string {
57
+ if (paths.length === 0) return ''
58
+
59
+ const segments = paths.map((p) => p.replace(/^\//, '').split('/'))
60
+ const threshold = Math.ceil(paths.length * 0.75)
61
+
62
+ // Find the longest prefix shared by at least `threshold` paths
63
+ let prefixLen = 0
64
+ for (let i = 0; i < 10; i++) {
65
+ // Count how many paths have the same segment at position i as the first path
66
+ const candidateSeg = segments[0][i]
67
+ if (!candidateSeg || candidateSeg.startsWith('{')) break
68
+
69
+ const count = segments.filter(
70
+ (s) => s.length > i + 1 && s[i] === candidateSeg
71
+ ).length
72
+
73
+ if (count >= threshold) {
74
+ prefixLen = i + 1
75
+ } else {
76
+ break
77
+ }
78
+ }
79
+
80
+ if (prefixLen === 0) return ''
81
+ return '/' + segments[0].slice(0, prefixLen).join('/') + '/'
82
+ }
83
+
84
+ /** Convert operationId to camelCase function name */
85
+ function fromOperationId(operationId: string): string {
86
+ // operationId might be snake_case, camelCase, PascalCase, or kebab-case
87
+ return toCamelCase(
88
+ operationId
89
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
90
+ .replace(/_+/g, '_')
91
+ .replace(/^_|_$/g, '')
92
+ )
93
+ }
94
+
95
+ const METHOD_PREFIXES: Record<string, string> = {
96
+ get: 'get',
97
+ post: 'create',
98
+ put: 'update',
99
+ patch: 'update',
100
+ delete: 'delete',
101
+ }
102
+
103
+ interface OperationForNaming {
104
+ method: string
105
+ path: string
106
+ operationId?: string
107
+ }
108
+
109
+ export interface NamedOperation {
110
+ method: string
111
+ path: string
112
+ functionName: string
113
+ methodName: string
114
+ }
115
+
116
+ /**
117
+ * Generate function names for a list of operations.
118
+ * Handles collision detection and auto-deduplication.
119
+ */
120
+ export function generateOperationNames(
121
+ operations: OperationForNaming[],
122
+ commonPrefix: string
123
+ ): NamedOperation[] {
124
+ const results: NamedOperation[] = []
125
+ const usedNames = new Map<string, number>()
126
+
127
+ for (const op of operations) {
128
+ let name: string
129
+
130
+ if (op.operationId) {
131
+ name = fromOperationId(op.operationId)
132
+ } else {
133
+ name = deriveNameFromPath(op.method, op.path, commonPrefix)
134
+ }
135
+
136
+ // Handle collisions
137
+ const existing = usedNames.get(name) ?? 0
138
+ if (existing > 0) {
139
+ usedNames.set(name, existing + 1)
140
+ name = `${name}${existing + 1}`
141
+ }
142
+ usedNames.set(name, (usedNames.get(name) ?? 0) || 1)
143
+
144
+ results.push({
145
+ method: op.method,
146
+ path: op.path,
147
+ functionName: name,
148
+ methodName: name,
149
+ })
150
+ }
151
+
152
+ return results
153
+ }
154
+
155
+ function deriveNameFromPath(
156
+ method: string,
157
+ path: string,
158
+ commonPrefix: string
159
+ ): string {
160
+ // Strip common prefix
161
+ let cleanPath = path
162
+ if (commonPrefix && cleanPath.startsWith(commonPrefix)) {
163
+ cleanPath = '/' + cleanPath.slice(commonPrefix.length)
164
+ }
165
+
166
+ const segments = cleanPath
167
+ .replace(/^\//, '')
168
+ .split('/')
169
+ .filter(Boolean)
170
+
171
+ const methodLower = method.toLowerCase()
172
+ const prefix = METHOD_PREFIXES[methodLower] || methodLower
173
+
174
+ // Separate param and non-param segments
175
+ const nonParams: string[] = []
176
+ let hasTrailingParam = false
177
+
178
+ for (let i = 0; i < segments.length; i++) {
179
+ if (segments[i].startsWith('{')) {
180
+ if (i === segments.length - 1) {
181
+ hasTrailingParam = true
182
+ }
183
+ } else {
184
+ nonParams.push(segments[i])
185
+ }
186
+ }
187
+
188
+ if (nonParams.length === 0) {
189
+ return prefix
190
+ }
191
+
192
+ // For GET with trailing param: singularize the last non-param segment
193
+ // For GET without trailing param: keep plural (list)
194
+ // For POST without trailing param: use prefix (create)
195
+ const parts = nonParams.map((seg, i) => {
196
+ const isLast = i === nonParams.length - 1
197
+ let word = seg
198
+
199
+ if (isLast && hasTrailingParam) {
200
+ word = singularize(word)
201
+ }
202
+
203
+ // For GET on collection (no trailing param), use "list" prefix instead of "get"
204
+ return toPascalCase(word)
205
+ })
206
+
207
+ // For GET on collection, use "list" instead of "get"
208
+ let finalPrefix = prefix
209
+ if (methodLower === 'get' && !hasTrailingParam) {
210
+ finalPrefix = 'list'
211
+ }
212
+
213
+ return finalPrefix + parts.join('')
214
+ }
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Converts OpenAPI schemas into Zod code strings.
3
+ *
4
+ * Chaining order: base → refinements → .nullable() → .optional() → .default() → .describe()
5
+ */
6
+
7
+ export interface OpenAPISchema {
8
+ type?: string
9
+ format?: string
10
+ description?: string
11
+ enum?: unknown[]
12
+ items?: OpenAPISchema
13
+ properties?: Record<string, OpenAPISchema>
14
+ required?: string[]
15
+ nullable?: boolean
16
+ default?: unknown
17
+ minimum?: number
18
+ maximum?: number
19
+ exclusiveMinimum?: number
20
+ exclusiveMaximum?: number
21
+ minLength?: number
22
+ maxLength?: number
23
+ pattern?: string
24
+ minItems?: number
25
+ maxItems?: number
26
+ oneOf?: OpenAPISchema[]
27
+ anyOf?: OpenAPISchema[]
28
+ allOf?: OpenAPISchema[]
29
+ additionalProperties?: boolean | OpenAPISchema
30
+ $ref?: string
31
+ // Zod-relevant extensions
32
+ readOnly?: boolean
33
+ writeOnly?: boolean
34
+ }
35
+
36
+ export interface ZodCodegenContext {
37
+ /** Map from component schema name to its Zod variable name */
38
+ schemaRefs: Map<string, string>
39
+ /** Track which refs are actually used */
40
+ usedRefs: Set<string>
41
+ /** Indent level for readability */
42
+ indent: number
43
+ }
44
+
45
+ export function createContext(
46
+ schemaRefs?: Map<string, string>
47
+ ): ZodCodegenContext {
48
+ return {
49
+ schemaRefs: schemaRefs ?? new Map(),
50
+ usedRefs: new Set(),
51
+ indent: 0,
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Convert a single OpenAPI schema to a Zod expression string.
57
+ * Does NOT include .optional() — that's handled at the property level based on `required`.
58
+ */
59
+ export function schemaToZod(
60
+ schema: OpenAPISchema,
61
+ ctx: ZodCodegenContext,
62
+ opts: { optional?: boolean } = {}
63
+ ): string {
64
+ let code = schemaToZodBase(schema, ctx)
65
+
66
+ // Refinements
67
+ code = applyRefinements(code, schema)
68
+
69
+ // Nullable
70
+ if (schema.nullable) {
71
+ code += '.nullable()'
72
+ }
73
+
74
+ // Optional
75
+ if (opts.optional) {
76
+ code += '.optional()'
77
+ }
78
+
79
+ // Default
80
+ if (schema.default !== undefined) {
81
+ code += `.default(${JSON.stringify(schema.default)})`
82
+ }
83
+
84
+ // Description
85
+ if (schema.description) {
86
+ code += `.describe(${JSON.stringify(schema.description)})`
87
+ }
88
+
89
+ return code
90
+ }
91
+
92
+ function schemaToZodBase(
93
+ schema: OpenAPISchema,
94
+ ctx: ZodCodegenContext
95
+ ): string {
96
+ // Handle $ref
97
+ if (schema.$ref) {
98
+ const refName = schema.$ref.split('/').pop()!
99
+ const zodName = ctx.schemaRefs.get(refName)
100
+ if (zodName) {
101
+ ctx.usedRefs.add(refName)
102
+ return zodName
103
+ }
104
+ // Unknown ref — fallback to z.unknown()
105
+ return 'z.unknown()'
106
+ }
107
+
108
+ // Handle allOf — merge into single object
109
+ if (schema.allOf && schema.allOf.length > 0) {
110
+ return handleAllOf(schema.allOf, ctx)
111
+ }
112
+
113
+ // Handle oneOf/anyOf — union
114
+ if (schema.oneOf && schema.oneOf.length > 0) {
115
+ return handleUnion(schema.oneOf, ctx)
116
+ }
117
+ if (schema.anyOf && schema.anyOf.length > 0) {
118
+ return handleUnion(schema.anyOf, ctx)
119
+ }
120
+
121
+ // Handle enum
122
+ if (schema.enum && schema.enum.length > 0) {
123
+ return handleEnum(schema.enum)
124
+ }
125
+
126
+ // Handle by type
127
+ switch (schema.type) {
128
+ case 'string':
129
+ return handleString(schema)
130
+ case 'integer':
131
+ return 'z.number().int()'
132
+ case 'number':
133
+ return 'z.number()'
134
+ case 'boolean':
135
+ return 'z.boolean()'
136
+ case 'array':
137
+ return handleArray(schema, ctx)
138
+ case 'object':
139
+ return handleObject(schema, ctx)
140
+ default:
141
+ // No type specified but has properties — treat as object
142
+ if (schema.properties) {
143
+ return handleObject(schema, ctx)
144
+ }
145
+ return 'z.unknown()'
146
+ }
147
+ }
148
+
149
+ function handleString(schema: OpenAPISchema): string {
150
+ switch (schema.format) {
151
+ case 'uuid':
152
+ return 'z.string().uuid()'
153
+ case 'date-time':
154
+ return 'z.string().datetime()'
155
+ case 'date':
156
+ return 'z.string().date()'
157
+ case 'email':
158
+ return 'z.string().email()'
159
+ case 'uri':
160
+ case 'url':
161
+ return 'z.string().url()'
162
+ default:
163
+ return 'z.string()'
164
+ }
165
+ }
166
+
167
+ function handleEnum(values: unknown[]): string {
168
+ // Single value — use z.literal
169
+ if (values.length === 1) {
170
+ return `z.literal(${JSON.stringify(values[0])})`
171
+ }
172
+ // If all values are strings, use z.enum
173
+ if (values.every((v) => typeof v === 'string')) {
174
+ const enumValues = values.map((v) => JSON.stringify(v)).join(', ')
175
+ return `z.enum([${enumValues}])`
176
+ }
177
+ // Mixed types — use z.union of z.literal
178
+ const literals = values
179
+ .map((v) => `z.literal(${JSON.stringify(v)})`)
180
+ .join(', ')
181
+ return `z.union([${literals}])`
182
+ }
183
+
184
+ function handleArray(schema: OpenAPISchema, ctx: ZodCodegenContext): string {
185
+ const itemsZod = schema.items
186
+ ? schemaToZodBase(schema.items, ctx)
187
+ : 'z.unknown()'
188
+ return `z.array(${itemsZod})`
189
+ }
190
+
191
+ function indent(ctx: ZodCodegenContext): string {
192
+ return ' '.repeat(ctx.indent)
193
+ }
194
+
195
+ function handleObject(schema: OpenAPISchema, ctx: ZodCodegenContext): string {
196
+ if (!schema.properties || Object.keys(schema.properties).length === 0) {
197
+ // Object with no defined properties
198
+ if (schema.additionalProperties) {
199
+ const valueSchema =
200
+ typeof schema.additionalProperties === 'object'
201
+ ? schemaToZod(schema.additionalProperties, ctx)
202
+ : 'z.unknown()'
203
+ return `z.record(z.string(), ${valueSchema})`
204
+ }
205
+ return 'z.record(z.string(), z.unknown())'
206
+ }
207
+
208
+ const inner: ZodCodegenContext = { ...ctx, indent: ctx.indent + 1 }
209
+ const pad = indent(inner)
210
+ const closePad = indent(ctx)
211
+ const requiredSet = new Set(schema.required ?? [])
212
+ const entries: string[] = []
213
+
214
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
215
+ const isOptional = !requiredSet.has(key)
216
+ const propZod = schemaToZod(propSchema, inner, { optional: isOptional })
217
+ entries.push(`${pad}${safeKey(key)}: ${propZod},`)
218
+ }
219
+
220
+ return `z.object({\n${entries.join('\n')}\n${closePad}})`
221
+ }
222
+
223
+ function handleAllOf(schemas: OpenAPISchema[], ctx: ZodCodegenContext): string {
224
+ // Collect all properties from all schemas
225
+ const mergedProps: Record<string, OpenAPISchema> = {}
226
+ const mergedRequired: string[] = []
227
+
228
+ for (const sub of schemas) {
229
+ if (sub.$ref) {
230
+ const refName = sub.$ref.split('/').pop()!
231
+ const zodName = ctx.schemaRefs.get(refName)
232
+ if (zodName) {
233
+ ctx.usedRefs.add(refName)
234
+ // If it's a pure ref in an allOf, we'd need to merge/extend
235
+ // For simplicity, if we have refs mixed with objects, use .merge()
236
+ }
237
+ }
238
+ if (sub.properties) {
239
+ Object.assign(mergedProps, sub.properties)
240
+ }
241
+ if (sub.required) {
242
+ mergedRequired.push(...sub.required)
243
+ }
244
+ }
245
+
246
+ // If we have only refs, use .merge()
247
+ const refSchemas = schemas.filter((s) => s.$ref)
248
+ const objSchemas = schemas.filter(
249
+ (s) => !s.$ref && (s.properties || s.type === 'object')
250
+ )
251
+
252
+ if (refSchemas.length > 0 && objSchemas.length === 0) {
253
+ const parts = refSchemas.map((s) => {
254
+ const refName = s.$ref!.split('/').pop()!
255
+ ctx.usedRefs.add(refName)
256
+ return ctx.schemaRefs.get(refName) || 'z.object({})'
257
+ })
258
+ if (parts.length === 1) return parts[0]
259
+ return parts.reduce((acc, part) => `${acc}.merge(${part})`)
260
+ }
261
+
262
+ // Otherwise, merge into a single object
263
+ if (Object.keys(mergedProps).length > 0) {
264
+ return handleObject(
265
+ {
266
+ type: 'object',
267
+ properties: mergedProps,
268
+ required: [...new Set(mergedRequired)],
269
+ },
270
+ ctx
271
+ )
272
+ }
273
+
274
+ // Fallback
275
+ return 'z.unknown()'
276
+ }
277
+
278
+ function handleUnion(schemas: OpenAPISchema[], ctx: ZodCodegenContext): string {
279
+ const members = schemas.map((s) => schemaToZodBase(s, ctx))
280
+ if (members.length === 1) return members[0]
281
+ return `z.union([${members.join(', ')}])`
282
+ }
283
+
284
+ function applyRefinements(code: string, schema: OpenAPISchema): string {
285
+ let result = code
286
+
287
+ // String refinements
288
+ if (schema.type === 'string' || (!schema.type && !schema.format)) {
289
+ if (schema.minLength !== undefined) {
290
+ result += `.min(${schema.minLength})`
291
+ }
292
+ if (schema.maxLength !== undefined) {
293
+ result += `.max(${schema.maxLength})`
294
+ }
295
+ if (schema.pattern) {
296
+ result += `.regex(new RegExp(${JSON.stringify(schema.pattern)}))`
297
+ }
298
+ }
299
+
300
+ // Number refinements
301
+ if (schema.type === 'number' || schema.type === 'integer') {
302
+ if (schema.minimum !== undefined) {
303
+ result += `.min(${schema.minimum})`
304
+ }
305
+ if (schema.maximum !== undefined) {
306
+ result += `.max(${schema.maximum})`
307
+ }
308
+ if (schema.exclusiveMinimum !== undefined) {
309
+ result += `.gt(${schema.exclusiveMinimum})`
310
+ }
311
+ if (schema.exclusiveMaximum !== undefined) {
312
+ result += `.lt(${schema.exclusiveMaximum})`
313
+ }
314
+ }
315
+
316
+ // Array refinements
317
+ if (schema.type === 'array') {
318
+ if (schema.minItems !== undefined) {
319
+ result += `.min(${schema.minItems})`
320
+ }
321
+ if (schema.maxItems !== undefined) {
322
+ result += `.max(${schema.maxItems})`
323
+ }
324
+ }
325
+
326
+ return result
327
+ }
328
+
329
+ /** Ensure property keys are safe identifiers, or quote them */
330
+ function safeKey(key: string): string {
331
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
332
+ return key
333
+ }
334
+ return JSON.stringify(key)
335
+ }
336
+
337
+ /**
338
+ * Generate the Zod variable name for a component schema.
339
+ * e.g. "PaginatedResponse" → "PaginatedResponseSchema"
340
+ */
341
+ export function sanitizeTypeName(name: string): string {
342
+ return name.replace(/[^a-zA-Z0-9_$]/g, '')
343
+ }
344
+
345
+ export function schemaVarName(name: string): string {
346
+ const sanitized = sanitizeTypeName(name)
347
+ return sanitized.endsWith('Schema') ? sanitized : `${sanitized}Schema`
348
+ }