@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/CHANGELOG.md +12 -0
- package/dist/codegen.js +1 -4
- package/package.json +1 -1
- package/src/codegen.ts +648 -0
- package/src/index.ts +28 -0
- package/src/naming.ts +214 -0
- package/src/openapi-to-zod-schema.ts +348 -0
- package/src/parse-openapi.test.ts +150 -0
- package/src/parse-openapi.ts +442 -0
- package/src/zod-codegen.ts +8 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
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
|
+
}
|