@pikku/openapi-parser 0.12.9 → 0.12.11
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 +22 -0
- package/dist/codegen.d.ts +1 -0
- package/dist/codegen.js +586 -66
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/naming.d.ts +7 -0
- package/dist/naming.js +136 -11
- package/dist/openapi-to-zod-schema.d.ts +20 -7
- package/dist/openapi-to-zod-schema.js +245 -33
- package/dist/parse-openapi.js +22 -9
- package/dist/zod-codegen.d.ts +1 -1
- package/dist/zod-codegen.js +1 -1
- package/package.json +2 -2
- package/src/codegen.test.ts +782 -0
- package/src/codegen.ts +726 -74
- package/src/index.ts +2 -3
- package/src/naming.test.ts +260 -0
- package/src/naming.ts +155 -14
- package/src/openapi-to-zod-schema.test.ts +664 -0
- package/src/openapi-to-zod-schema.ts +270 -35
- package/src/parse-openapi.test.ts +23 -8
- package/src/parse-openapi.ts +22 -9
- package/src/zod-codegen.ts +1 -0
- package/tsconfig.tsbuildinfo +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { parseOpenAPISpec, computeContractHash, type ParsedSpec, type ParsedOperation, type ParsedParam, type ErrorResponse, type SecuritySchemeInfo, } from './parse-openapi.js';
|
|
2
|
-
export { generateAddonFromOpenAPI
|
|
2
|
+
export { generateAddonFromOpenAPI } from './codegen.js';
|
|
3
3
|
export { generateOperationNames, detectCommonPrefix, type NamedOperation, } from './naming.js';
|
|
4
|
-
export { type OpenAPISchema, type ZodCodegenContext, createContext, schemaToZod, schemaVarName, sanitizeTypeName, } from './openapi-to-zod-schema.js';
|
|
4
|
+
export { type OpenAPISchema, type ZodCodegenContext, createContext, schemaToZod, schemaVarName, sanitizeTypeName, snakeToCamel, } from './openapi-to-zod-schema.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { parseOpenAPISpec, computeContractHash, } from './parse-openapi.js';
|
|
2
|
-
export { generateAddonFromOpenAPI
|
|
2
|
+
export { generateAddonFromOpenAPI } from './codegen.js';
|
|
3
3
|
export { generateOperationNames, detectCommonPrefix, } from './naming.js';
|
|
4
|
-
export { createContext, schemaToZod, schemaVarName, sanitizeTypeName, } from './openapi-to-zod-schema.js';
|
|
4
|
+
export { createContext, schemaToZod, schemaVarName, sanitizeTypeName, snakeToCamel, } from './openapi-to-zod-schema.js';
|
package/dist/naming.d.ts
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
* Derives function/method names from HTTP method + path.
|
|
3
3
|
* Uses operationId when available, otherwise generates from path segments.
|
|
4
4
|
*/
|
|
5
|
+
/**
|
|
6
|
+
* Ensure a name is a valid JS/TS identifier:
|
|
7
|
+
* - Not a reserved word
|
|
8
|
+
* - Does not start with a digit
|
|
9
|
+
* - Does not contain numeric separator patterns (e.g. 1_000)
|
|
10
|
+
*/
|
|
11
|
+
export declare function sanitizeIdentifier(name: string): string;
|
|
5
12
|
export declare function singularize(word: string): string;
|
|
6
13
|
export declare function toCamelCase(str: string): string;
|
|
7
14
|
export declare function toPascalCase(str: string): string;
|
package/dist/naming.js
CHANGED
|
@@ -2,6 +2,112 @@
|
|
|
2
2
|
* Derives function/method names from HTTP method + path.
|
|
3
3
|
* Uses operationId when available, otherwise generates from path segments.
|
|
4
4
|
*/
|
|
5
|
+
/** JavaScript/TypeScript reserved words that cannot be used as identifiers */
|
|
6
|
+
const JS_RESERVED_WORDS = new Set([
|
|
7
|
+
// ECMAScript reserved words
|
|
8
|
+
'abstract',
|
|
9
|
+
'arguments',
|
|
10
|
+
'await',
|
|
11
|
+
'boolean',
|
|
12
|
+
'break',
|
|
13
|
+
'byte',
|
|
14
|
+
'case',
|
|
15
|
+
'catch',
|
|
16
|
+
'char',
|
|
17
|
+
'class',
|
|
18
|
+
'const',
|
|
19
|
+
'continue',
|
|
20
|
+
'debugger',
|
|
21
|
+
'default',
|
|
22
|
+
'delete',
|
|
23
|
+
'do',
|
|
24
|
+
'double',
|
|
25
|
+
'else',
|
|
26
|
+
'enum',
|
|
27
|
+
'eval',
|
|
28
|
+
'export',
|
|
29
|
+
'extends',
|
|
30
|
+
'false',
|
|
31
|
+
'final',
|
|
32
|
+
'finally',
|
|
33
|
+
'float',
|
|
34
|
+
'for',
|
|
35
|
+
'function',
|
|
36
|
+
'goto',
|
|
37
|
+
'if',
|
|
38
|
+
'implements',
|
|
39
|
+
'import',
|
|
40
|
+
'in',
|
|
41
|
+
'instanceof',
|
|
42
|
+
'int',
|
|
43
|
+
'interface',
|
|
44
|
+
'let',
|
|
45
|
+
'long',
|
|
46
|
+
'native',
|
|
47
|
+
'new',
|
|
48
|
+
'null',
|
|
49
|
+
'package',
|
|
50
|
+
'private',
|
|
51
|
+
'protected',
|
|
52
|
+
'public',
|
|
53
|
+
'return',
|
|
54
|
+
'short',
|
|
55
|
+
'static',
|
|
56
|
+
'super',
|
|
57
|
+
'switch',
|
|
58
|
+
'synchronized',
|
|
59
|
+
'this',
|
|
60
|
+
'throw',
|
|
61
|
+
'throws',
|
|
62
|
+
'transient',
|
|
63
|
+
'true',
|
|
64
|
+
'try',
|
|
65
|
+
'typeof',
|
|
66
|
+
'undefined',
|
|
67
|
+
'var',
|
|
68
|
+
'void',
|
|
69
|
+
'volatile',
|
|
70
|
+
'while',
|
|
71
|
+
'with',
|
|
72
|
+
'yield',
|
|
73
|
+
// TypeScript additional reserved words in strict mode
|
|
74
|
+
'as',
|
|
75
|
+
'any',
|
|
76
|
+
'declare',
|
|
77
|
+
'get',
|
|
78
|
+
'module',
|
|
79
|
+
'require',
|
|
80
|
+
'number',
|
|
81
|
+
'object',
|
|
82
|
+
'set',
|
|
83
|
+
'string',
|
|
84
|
+
'symbol',
|
|
85
|
+
'type',
|
|
86
|
+
'from',
|
|
87
|
+
'of',
|
|
88
|
+
]);
|
|
89
|
+
/**
|
|
90
|
+
* Ensure a name is a valid JS/TS identifier:
|
|
91
|
+
* - Not a reserved word
|
|
92
|
+
* - Does not start with a digit
|
|
93
|
+
* - Does not contain numeric separator patterns (e.g. 1_000)
|
|
94
|
+
*/
|
|
95
|
+
export function sanitizeIdentifier(name) {
|
|
96
|
+
// Strip numeric separator patterns like 1_000 → 1000
|
|
97
|
+
name = name.replace(/(\d)_(\d)/g, '$1$2');
|
|
98
|
+
// Strip any remaining invalid characters
|
|
99
|
+
name = name.replace(/[^a-zA-Z0-9_$]/g, '');
|
|
100
|
+
// Prefix with 'n' if name starts with a digit (not underscore, since
|
|
101
|
+
// pikku inspector may strip leading underscores from type names)
|
|
102
|
+
if (/^[0-9]/.test(name)) {
|
|
103
|
+
name = 'n' + name;
|
|
104
|
+
}
|
|
105
|
+
// Prefix with underscore if name is a reserved word
|
|
106
|
+
if (JS_RESERVED_WORDS.has(name)) {
|
|
107
|
+
name = '_' + name;
|
|
108
|
+
}
|
|
109
|
+
return name || 'unnamed';
|
|
110
|
+
}
|
|
5
111
|
const IRREGULAR_PLURALS = {
|
|
6
112
|
addresses: 'address',
|
|
7
113
|
statuses: 'status',
|
|
@@ -25,11 +131,18 @@ export function singularize(word) {
|
|
|
25
131
|
return word.slice(0, -3) + 'y';
|
|
26
132
|
}
|
|
27
133
|
// -shes, -ches, -xes, -zes, -sses → drop "es"
|
|
28
|
-
if (lower.endsWith('shes') ||
|
|
134
|
+
if (lower.endsWith('shes') ||
|
|
135
|
+
lower.endsWith('ches') ||
|
|
136
|
+
lower.endsWith('xes') ||
|
|
137
|
+
lower.endsWith('zes') ||
|
|
138
|
+
lower.endsWith('sses')) {
|
|
29
139
|
return word.slice(0, -2);
|
|
30
140
|
}
|
|
31
141
|
// General: drop trailing "s" (covers courses→course, products→product, etc.)
|
|
32
|
-
if (lower.endsWith('s') &&
|
|
142
|
+
if (lower.endsWith('s') &&
|
|
143
|
+
!lower.endsWith('ss') &&
|
|
144
|
+
!lower.endsWith('us') &&
|
|
145
|
+
lower.length > 2) {
|
|
33
146
|
return word.slice(0, -1);
|
|
34
147
|
}
|
|
35
148
|
return word;
|
|
@@ -37,6 +150,7 @@ export function singularize(word) {
|
|
|
37
150
|
export function toCamelCase(str) {
|
|
38
151
|
return str
|
|
39
152
|
.replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
|
|
153
|
+
.replace(/[^a-zA-Z0-9]+$/g, '') // strip trailing non-alphanumeric chars
|
|
40
154
|
.replace(/^[A-Z]/, (c) => c.toLowerCase());
|
|
41
155
|
}
|
|
42
156
|
export function toPascalCase(str) {
|
|
@@ -75,10 +189,12 @@ export function detectCommonPrefix(paths) {
|
|
|
75
189
|
/** Convert operationId to camelCase function name */
|
|
76
190
|
function fromOperationId(operationId) {
|
|
77
191
|
// operationId might be snake_case, camelCase, PascalCase, or kebab-case
|
|
78
|
-
|
|
192
|
+
let name = toCamelCase(operationId
|
|
193
|
+
.replace(/[{}]/g, '') // strip curly braces from path parameters
|
|
79
194
|
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
80
|
-
.replace(/_+/g, '_')
|
|
81
|
-
.replace(/^_|_$/g, ''));
|
|
195
|
+
.replace(/[-_]+/g, '_')
|
|
196
|
+
.replace(/^_|_$/g, '')) || 'unnamed';
|
|
197
|
+
return sanitizeIdentifier(name);
|
|
82
198
|
}
|
|
83
199
|
const METHOD_PREFIXES = {
|
|
84
200
|
get: 'get',
|
|
@@ -94,6 +210,11 @@ const METHOD_PREFIXES = {
|
|
|
94
210
|
export function generateOperationNames(operations, commonPrefix) {
|
|
95
211
|
const results = [];
|
|
96
212
|
const usedNames = new Map();
|
|
213
|
+
// Track names case-insensitively to prevent file system collisions on
|
|
214
|
+
// case-insensitive systems (macOS, Windows). Two operationIds that differ
|
|
215
|
+
// only in casing (e.g. userGetOauth2Application vs userGetOAuth2Application)
|
|
216
|
+
// would produce files that collide on disk.
|
|
217
|
+
const usedNamesLower = new Map();
|
|
97
218
|
for (const op of operations) {
|
|
98
219
|
let name;
|
|
99
220
|
if (op.operationId) {
|
|
@@ -102,13 +223,20 @@ export function generateOperationNames(operations, commonPrefix) {
|
|
|
102
223
|
else {
|
|
103
224
|
name = deriveNameFromPath(op.method, op.path, commonPrefix);
|
|
104
225
|
}
|
|
105
|
-
//
|
|
106
|
-
|
|
226
|
+
// Ensure name is a valid JS/TS identifier
|
|
227
|
+
name = sanitizeIdentifier(name);
|
|
228
|
+
// Handle collisions (case-insensitive to avoid file system conflicts)
|
|
229
|
+
const nameLower = name.toLowerCase();
|
|
230
|
+
const existingExact = usedNames.get(name) ?? 0;
|
|
231
|
+
const existingCI = usedNamesLower.get(nameLower) ?? 0;
|
|
232
|
+
const existing = Math.max(existingExact, existingCI);
|
|
107
233
|
if (existing > 0) {
|
|
108
234
|
usedNames.set(name, existing + 1);
|
|
235
|
+
usedNamesLower.set(nameLower, existing + 1);
|
|
109
236
|
name = `${name}${existing + 1}`;
|
|
110
237
|
}
|
|
111
238
|
usedNames.set(name, (usedNames.get(name) ?? 0) || 1);
|
|
239
|
+
usedNamesLower.set(name.toLowerCase(), (usedNamesLower.get(name.toLowerCase()) ?? 0) || 1);
|
|
112
240
|
results.push({
|
|
113
241
|
method: op.method,
|
|
114
242
|
path: op.path,
|
|
@@ -124,10 +252,7 @@ function deriveNameFromPath(method, path, commonPrefix) {
|
|
|
124
252
|
if (commonPrefix && cleanPath.startsWith(commonPrefix)) {
|
|
125
253
|
cleanPath = '/' + cleanPath.slice(commonPrefix.length);
|
|
126
254
|
}
|
|
127
|
-
const segments = cleanPath
|
|
128
|
-
.replace(/^\//, '')
|
|
129
|
-
.split('/')
|
|
130
|
-
.filter(Boolean);
|
|
255
|
+
const segments = cleanPath.replace(/^\//, '').split('/').filter(Boolean);
|
|
131
256
|
const methodLower = method.toLowerCase();
|
|
132
257
|
const prefix = METHOD_PREFIXES[methodLower] || methodLower;
|
|
133
258
|
// Separate param and non-param segments
|
|
@@ -15,8 +15,8 @@ export interface OpenAPISchema {
|
|
|
15
15
|
default?: unknown;
|
|
16
16
|
minimum?: number;
|
|
17
17
|
maximum?: number;
|
|
18
|
-
exclusiveMinimum?: number;
|
|
19
|
-
exclusiveMaximum?: number;
|
|
18
|
+
exclusiveMinimum?: number | boolean;
|
|
19
|
+
exclusiveMaximum?: number | boolean;
|
|
20
20
|
minLength?: number;
|
|
21
21
|
maxLength?: number;
|
|
22
22
|
pattern?: string;
|
|
@@ -37,8 +37,25 @@ export interface ZodCodegenContext {
|
|
|
37
37
|
usedRefs: Set<string>;
|
|
38
38
|
/** Indent level for readability */
|
|
39
39
|
indent: number;
|
|
40
|
+
/** Track schemas currently being processed to detect cycles */
|
|
41
|
+
seen: Set<OpenAPISchema>;
|
|
42
|
+
/**
|
|
43
|
+
* Map from resolved schema object identity to its component name.
|
|
44
|
+
* After $ref resolution, many references lose their $ref string and
|
|
45
|
+
* become the same JS object as the component schema. This map lets
|
|
46
|
+
* schemaToZod detect those resolved references by identity.
|
|
47
|
+
*/
|
|
48
|
+
schemaIdentityMap: Map<object, string>;
|
|
49
|
+
/** Current nesting depth for preventing excessively deep types */
|
|
50
|
+
depth: number;
|
|
51
|
+
/** When true, convert snake_case property keys to camelCase in z.object() */
|
|
52
|
+
camelCase?: boolean;
|
|
40
53
|
}
|
|
41
|
-
export declare function createContext(schemaRefs?: Map<string, string
|
|
54
|
+
export declare function createContext(schemaRefs?: Map<string, string>, schemaIdentityMap?: Map<object, string>, opts?: {
|
|
55
|
+
camelCase?: boolean;
|
|
56
|
+
}): ZodCodegenContext;
|
|
57
|
+
/** Convert a snake_case or kebab-case string to camelCase */
|
|
58
|
+
export declare function snakeToCamel(s: string): string;
|
|
42
59
|
/**
|
|
43
60
|
* Convert a single OpenAPI schema to a Zod expression string.
|
|
44
61
|
* Does NOT include .optional() — that's handled at the property level based on `required`.
|
|
@@ -46,9 +63,5 @@ export declare function createContext(schemaRefs?: Map<string, string>): ZodCode
|
|
|
46
63
|
export declare function schemaToZod(schema: OpenAPISchema, ctx: ZodCodegenContext, opts?: {
|
|
47
64
|
optional?: boolean;
|
|
48
65
|
}): string;
|
|
49
|
-
/**
|
|
50
|
-
* Generate the Zod variable name for a component schema.
|
|
51
|
-
* e.g. "PaginatedResponse" → "PaginatedResponseSchema"
|
|
52
|
-
*/
|
|
53
66
|
export declare function sanitizeTypeName(name: string): string;
|
|
54
67
|
export declare function schemaVarName(name: string): string;
|
|
@@ -3,18 +3,39 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Chaining order: base → refinements → .nullable() → .optional() → .default() → .describe()
|
|
5
5
|
*/
|
|
6
|
-
export function createContext(schemaRefs) {
|
|
6
|
+
export function createContext(schemaRefs, schemaIdentityMap, opts) {
|
|
7
7
|
return {
|
|
8
8
|
schemaRefs: schemaRefs ?? new Map(),
|
|
9
9
|
usedRefs: new Set(),
|
|
10
10
|
indent: 0,
|
|
11
|
+
seen: new Set(),
|
|
12
|
+
schemaIdentityMap: schemaIdentityMap ?? new Map(),
|
|
13
|
+
depth: 0,
|
|
14
|
+
camelCase: opts?.camelCase,
|
|
11
15
|
};
|
|
12
16
|
}
|
|
17
|
+
/** Convert a snake_case or kebab-case string to camelCase */
|
|
18
|
+
export function snakeToCamel(s) {
|
|
19
|
+
return s.replace(/[-_]+(.)/g, (_, c) => c.toUpperCase());
|
|
20
|
+
}
|
|
13
21
|
/**
|
|
14
22
|
* Convert a single OpenAPI schema to a Zod expression string.
|
|
15
23
|
* Does NOT include .optional() — that's handled at the property level based on `required`.
|
|
16
24
|
*/
|
|
17
25
|
export function schemaToZod(schema, ctx, opts = {}) {
|
|
26
|
+
// Bug 5: Depth limit to prevent TS2589 "excessively deep" type errors
|
|
27
|
+
if (ctx.depth > 10) {
|
|
28
|
+
let code = 'z.any()';
|
|
29
|
+
if (schema.nullable)
|
|
30
|
+
code += '.nullable()';
|
|
31
|
+
if (opts.optional)
|
|
32
|
+
code += '.optional()';
|
|
33
|
+
if (schema.description) {
|
|
34
|
+
const safeDesc = schema.description.replace(/\*\//g, '* /');
|
|
35
|
+
code += `.describe(${JSON.stringify(safeDesc)})`;
|
|
36
|
+
}
|
|
37
|
+
return code;
|
|
38
|
+
}
|
|
18
39
|
let code = schemaToZodBase(schema, ctx);
|
|
19
40
|
// Refinements
|
|
20
41
|
code = applyRefinements(code, schema);
|
|
@@ -26,18 +47,105 @@ export function schemaToZod(schema, ctx, opts = {}) {
|
|
|
26
47
|
if (opts.optional) {
|
|
27
48
|
code += '.optional()';
|
|
28
49
|
}
|
|
29
|
-
// Default
|
|
50
|
+
// Default — validate type compatibility and coerce mismatches
|
|
30
51
|
if (schema.default !== undefined) {
|
|
31
|
-
|
|
52
|
+
let defaultValue = schema.default;
|
|
53
|
+
if (schema.enum && schema.enum.length > 0) {
|
|
54
|
+
// For enum schemas, the default must exactly match one of the enum values.
|
|
55
|
+
// Don't apply type coercion — the Zod schema uses the original enum types
|
|
56
|
+
// (e.g. z.literal(1) not z.literal("1")), so coercing would cause TS2769.
|
|
57
|
+
const enumValues = new Set(schema.enum.map((v) => JSON.stringify(v)));
|
|
58
|
+
if (enumValues.has(JSON.stringify(defaultValue))) {
|
|
59
|
+
code += `.default(${JSON.stringify(defaultValue)})`;
|
|
60
|
+
}
|
|
61
|
+
// else: default not in enum — skip it entirely
|
|
62
|
+
}
|
|
63
|
+
else if (defaultValue === null && !schema.nullable) {
|
|
64
|
+
// skip null defaults unless nullable
|
|
65
|
+
}
|
|
66
|
+
else if (schema.type === 'object' && typeof defaultValue === 'string') {
|
|
67
|
+
// skip string defaults on object types (often a stray description)
|
|
68
|
+
}
|
|
69
|
+
else if (schema.properties &&
|
|
70
|
+
typeof defaultValue === 'object' &&
|
|
71
|
+
defaultValue !== null &&
|
|
72
|
+
!Array.isArray(defaultValue)) {
|
|
73
|
+
// For object schemas with properties, validate default keys match schema properties.
|
|
74
|
+
// Extra keys in the default cause TS2769 because z.object() rejects unknown keys.
|
|
75
|
+
const schemaKeys = new Set(Object.keys(schema.properties));
|
|
76
|
+
const defaultKeys = Object.keys(defaultValue);
|
|
77
|
+
const hasExtraKeys = defaultKeys.some((k) => !schemaKeys.has(k));
|
|
78
|
+
if (!hasExtraKeys) {
|
|
79
|
+
code += `.default(${JSON.stringify(defaultValue)})`;
|
|
80
|
+
}
|
|
81
|
+
// else: default has keys not in schema — skip it
|
|
82
|
+
}
|
|
83
|
+
else if (schema.type === 'string' &&
|
|
84
|
+
typeof defaultValue === 'object' &&
|
|
85
|
+
defaultValue !== null) {
|
|
86
|
+
// skip object/array defaults on string types
|
|
87
|
+
}
|
|
88
|
+
else if ((!schema.type || schema.type === 'object') &&
|
|
89
|
+
schema.properties &&
|
|
90
|
+
typeof defaultValue === 'string') {
|
|
91
|
+
// skip string defaults on implicit object types (has properties but no explicit type)
|
|
92
|
+
}
|
|
93
|
+
else if (schema.type === 'array' && !Array.isArray(defaultValue)) {
|
|
94
|
+
// skip type-mismatched defaults (non-array default on array type)
|
|
95
|
+
}
|
|
96
|
+
else if (schema.type !== 'array' && Array.isArray(defaultValue)) {
|
|
97
|
+
// skip array defaults on non-array types
|
|
98
|
+
}
|
|
99
|
+
else if (schema.type === 'boolean' && typeof defaultValue === 'string') {
|
|
100
|
+
defaultValue = defaultValue === 'true';
|
|
101
|
+
code += `.default(${JSON.stringify(defaultValue)})`;
|
|
102
|
+
}
|
|
103
|
+
else if ((schema.type === 'number' || schema.type === 'integer') &&
|
|
104
|
+
typeof defaultValue === 'string') {
|
|
105
|
+
const parsed = Number(defaultValue);
|
|
106
|
+
if (!isNaN(parsed))
|
|
107
|
+
code += `.default(${JSON.stringify(parsed)})`;
|
|
108
|
+
}
|
|
109
|
+
else if (schema.type === 'string' && typeof defaultValue === 'number') {
|
|
110
|
+
code += `.default(${JSON.stringify(String(defaultValue))})`;
|
|
111
|
+
}
|
|
112
|
+
else if (schema.type === 'string' && typeof defaultValue === 'boolean') {
|
|
113
|
+
code += `.default(${JSON.stringify(String(defaultValue))})`;
|
|
114
|
+
}
|
|
115
|
+
else if (schema.type === 'boolean' && typeof defaultValue !== 'boolean') {
|
|
116
|
+
code += `.default(${defaultValue === 'true' || defaultValue === 1})`;
|
|
117
|
+
}
|
|
118
|
+
else if ((schema.type === 'number' || schema.type === 'integer') &&
|
|
119
|
+
typeof defaultValue === 'boolean') {
|
|
120
|
+
code += `.default(${defaultValue ? 1 : 0})`;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
code += `.default(${JSON.stringify(defaultValue)})`;
|
|
124
|
+
}
|
|
32
125
|
}
|
|
33
|
-
// Description
|
|
126
|
+
// Description — sanitize */ to prevent breaking JSDoc comments downstream
|
|
34
127
|
if (schema.description) {
|
|
35
|
-
|
|
128
|
+
const safeDesc = schema.description.replace(/\*\//g, '* /');
|
|
129
|
+
code += `.describe(${JSON.stringify(safeDesc)})`;
|
|
36
130
|
}
|
|
37
131
|
return code;
|
|
38
132
|
}
|
|
39
133
|
function schemaToZodBase(schema, ctx) {
|
|
40
|
-
//
|
|
134
|
+
// Cycle detection: if we've already started processing this exact schema
|
|
135
|
+
// object, return z.any() to break infinite recursion
|
|
136
|
+
if (ctx.seen.has(schema)) {
|
|
137
|
+
return 'z.any()';
|
|
138
|
+
}
|
|
139
|
+
ctx.seen.add(schema);
|
|
140
|
+
try {
|
|
141
|
+
return schemaToZodBaseInner(schema, ctx);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
ctx.seen.delete(schema);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function schemaToZodBaseInner(schema, ctx) {
|
|
148
|
+
// Handle $ref (surviving after resolution)
|
|
41
149
|
if (schema.$ref) {
|
|
42
150
|
const refName = schema.$ref.split('/').pop();
|
|
43
151
|
const zodName = ctx.schemaRefs.get(refName);
|
|
@@ -48,6 +156,18 @@ function schemaToZodBase(schema, ctx) {
|
|
|
48
156
|
// Unknown ref — fallback to z.unknown()
|
|
49
157
|
return 'z.unknown()';
|
|
50
158
|
}
|
|
159
|
+
// Handle resolved $ref by object identity: if this schema object IS a
|
|
160
|
+
// known component schema, reference the variable instead of inlining.
|
|
161
|
+
if (ctx.schemaIdentityMap.size > 0) {
|
|
162
|
+
const identityName = ctx.schemaIdentityMap.get(schema);
|
|
163
|
+
if (identityName) {
|
|
164
|
+
const zodName = ctx.schemaRefs.get(identityName);
|
|
165
|
+
if (zodName) {
|
|
166
|
+
ctx.usedRefs.add(identityName);
|
|
167
|
+
return zodName;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
51
171
|
// Handle allOf — merge into single object
|
|
52
172
|
if (schema.allOf && schema.allOf.length > 0) {
|
|
53
173
|
return handleAllOf(schema.allOf, ctx);
|
|
@@ -82,6 +202,13 @@ function schemaToZodBase(schema, ctx) {
|
|
|
82
202
|
if (schema.properties) {
|
|
83
203
|
return handleObject(schema, ctx);
|
|
84
204
|
}
|
|
205
|
+
// Bug 4: Infer number type when minimum/maximum present but no type
|
|
206
|
+
if (schema.minimum !== undefined ||
|
|
207
|
+
schema.maximum !== undefined ||
|
|
208
|
+
schema.exclusiveMinimum !== undefined ||
|
|
209
|
+
schema.exclusiveMaximum !== undefined) {
|
|
210
|
+
return 'z.number()';
|
|
211
|
+
}
|
|
85
212
|
return 'z.unknown()';
|
|
86
213
|
}
|
|
87
214
|
}
|
|
@@ -103,24 +230,28 @@ function handleString(schema) {
|
|
|
103
230
|
}
|
|
104
231
|
}
|
|
105
232
|
function handleEnum(values) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
233
|
+
const primitives = values.filter((v) => v === null ||
|
|
234
|
+
typeof v === 'string' ||
|
|
235
|
+
typeof v === 'number' ||
|
|
236
|
+
typeof v === 'boolean');
|
|
237
|
+
if (primitives.length === 0)
|
|
238
|
+
return 'z.unknown()';
|
|
239
|
+
if (primitives.length === 1) {
|
|
240
|
+
return `z.literal(${JSON.stringify(primitives[0])})`;
|
|
109
241
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const enumValues = values.map((v) => JSON.stringify(v)).join(', ');
|
|
242
|
+
if (primitives.every((v) => typeof v === 'string')) {
|
|
243
|
+
const enumValues = primitives.map((v) => JSON.stringify(v)).join(', ');
|
|
113
244
|
return `z.enum([${enumValues}])`;
|
|
114
245
|
}
|
|
115
|
-
|
|
116
|
-
const literals = values
|
|
246
|
+
const literals = primitives
|
|
117
247
|
.map((v) => `z.literal(${JSON.stringify(v)})`)
|
|
118
248
|
.join(', ');
|
|
119
249
|
return `z.union([${literals}])`;
|
|
120
250
|
}
|
|
121
251
|
function handleArray(schema, ctx) {
|
|
252
|
+
const innerCtx = { ...ctx, depth: ctx.depth + 1 };
|
|
122
253
|
const itemsZod = schema.items
|
|
123
|
-
? schemaToZodBase(schema.items,
|
|
254
|
+
? schemaToZodBase(schema.items, innerCtx)
|
|
124
255
|
: 'z.unknown()';
|
|
125
256
|
return `z.array(${itemsZod})`;
|
|
126
257
|
}
|
|
@@ -138,7 +269,11 @@ function handleObject(schema, ctx) {
|
|
|
138
269
|
}
|
|
139
270
|
return 'z.record(z.string(), z.unknown())';
|
|
140
271
|
}
|
|
141
|
-
const inner = {
|
|
272
|
+
const inner = {
|
|
273
|
+
...ctx,
|
|
274
|
+
indent: ctx.indent + 1,
|
|
275
|
+
depth: ctx.depth + 1,
|
|
276
|
+
};
|
|
142
277
|
const pad = indent(inner);
|
|
143
278
|
const closePad = indent(ctx);
|
|
144
279
|
const requiredSet = new Set(schema.required ?? []);
|
|
@@ -146,22 +281,34 @@ function handleObject(schema, ctx) {
|
|
|
146
281
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
147
282
|
const isOptional = !requiredSet.has(key);
|
|
148
283
|
const propZod = schemaToZod(propSchema, inner, { optional: isOptional });
|
|
149
|
-
|
|
284
|
+
const outputKey = ctx.camelCase ? snakeToCamel(key) : key;
|
|
285
|
+
entries.push(`${pad}${safeKey(outputKey)}: ${propZod},`);
|
|
150
286
|
}
|
|
151
287
|
return `z.object({\n${entries.join('\n')}\n${closePad}})`;
|
|
152
288
|
}
|
|
153
289
|
function handleAllOf(schemas, ctx) {
|
|
290
|
+
// Helper to check if a sub-schema is a reference (by $ref or identity map)
|
|
291
|
+
function getRefName(sub) {
|
|
292
|
+
if (sub.$ref) {
|
|
293
|
+
return sub.$ref.split('/').pop();
|
|
294
|
+
}
|
|
295
|
+
if (ctx.schemaIdentityMap.size > 0) {
|
|
296
|
+
const identityName = ctx.schemaIdentityMap.get(sub);
|
|
297
|
+
if (identityName && ctx.schemaRefs.has(identityName)) {
|
|
298
|
+
return identityName;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
154
303
|
// Collect all properties from all schemas
|
|
155
304
|
const mergedProps = {};
|
|
156
305
|
const mergedRequired = [];
|
|
157
306
|
for (const sub of schemas) {
|
|
158
|
-
|
|
159
|
-
|
|
307
|
+
const refName = getRefName(sub);
|
|
308
|
+
if (refName) {
|
|
160
309
|
const zodName = ctx.schemaRefs.get(refName);
|
|
161
310
|
if (zodName) {
|
|
162
311
|
ctx.usedRefs.add(refName);
|
|
163
|
-
// If it's a pure ref in an allOf, we'd need to merge/extend
|
|
164
|
-
// For simplicity, if we have refs mixed with objects, use .merge()
|
|
165
312
|
}
|
|
166
313
|
}
|
|
167
314
|
if (sub.properties) {
|
|
@@ -172,11 +319,11 @@ function handleAllOf(schemas, ctx) {
|
|
|
172
319
|
}
|
|
173
320
|
}
|
|
174
321
|
// If we have only refs, use .merge()
|
|
175
|
-
const refSchemas = schemas.filter((s) => s
|
|
176
|
-
const objSchemas = schemas.filter((s) =>
|
|
322
|
+
const refSchemas = schemas.filter((s) => getRefName(s) !== undefined);
|
|
323
|
+
const objSchemas = schemas.filter((s) => getRefName(s) === undefined && (s.properties || s.type === 'object'));
|
|
177
324
|
if (refSchemas.length > 0 && objSchemas.length === 0) {
|
|
178
325
|
const parts = refSchemas.map((s) => {
|
|
179
|
-
const refName = s
|
|
326
|
+
const refName = getRefName(s);
|
|
180
327
|
ctx.usedRefs.add(refName);
|
|
181
328
|
return ctx.schemaRefs.get(refName) || 'z.object({})';
|
|
182
329
|
});
|
|
@@ -202,9 +349,23 @@ function handleUnion(schemas, ctx) {
|
|
|
202
349
|
return `z.union([${members.join(', ')}])`;
|
|
203
350
|
}
|
|
204
351
|
function applyRefinements(code, schema) {
|
|
352
|
+
// Don't apply refinements to types that don't support .min()/.max()/.regex() etc.
|
|
353
|
+
// This includes z.any(), z.unknown(), z.union(...), z.literal(...), z.enum(...),
|
|
354
|
+
// z.lazy(...), z.record(...), and schema references (variable names without z. prefix).
|
|
355
|
+
if (code === 'z.any()' ||
|
|
356
|
+
code === 'z.unknown()' ||
|
|
357
|
+
code.startsWith('z.union(') ||
|
|
358
|
+
code.startsWith('z.literal(') ||
|
|
359
|
+
code.startsWith('z.enum(') ||
|
|
360
|
+
code.startsWith('z.lazy(') ||
|
|
361
|
+
code.startsWith('z.record(') ||
|
|
362
|
+
// Schema variable references (e.g. FooSchema) — no z. prefix
|
|
363
|
+
(!code.startsWith('z.') && /^[a-zA-Z_$]/.test(code))) {
|
|
364
|
+
return code;
|
|
365
|
+
}
|
|
205
366
|
let result = code;
|
|
206
|
-
// String refinements
|
|
207
|
-
if (schema.type === 'string'
|
|
367
|
+
// String refinements — only apply to explicit string types
|
|
368
|
+
if (schema.type === 'string' && !schema.enum) {
|
|
208
369
|
if (schema.minLength !== undefined) {
|
|
209
370
|
result += `.min(${schema.minLength})`;
|
|
210
371
|
}
|
|
@@ -215,18 +376,38 @@ function applyRefinements(code, schema) {
|
|
|
215
376
|
result += `.regex(new RegExp(${JSON.stringify(schema.pattern)}))`;
|
|
216
377
|
}
|
|
217
378
|
}
|
|
218
|
-
// Number refinements
|
|
219
|
-
|
|
379
|
+
// Number refinements — also apply when no type but min/max present (inferred number)
|
|
380
|
+
const isNumberType = schema.type === 'number' ||
|
|
381
|
+
schema.type === 'integer' ||
|
|
382
|
+
(!schema.type &&
|
|
383
|
+
!schema.properties &&
|
|
384
|
+
(schema.minimum !== undefined ||
|
|
385
|
+
schema.maximum !== undefined ||
|
|
386
|
+
schema.exclusiveMinimum !== undefined ||
|
|
387
|
+
schema.exclusiveMaximum !== undefined));
|
|
388
|
+
if (isNumberType && !schema.enum) {
|
|
389
|
+
const exMinIsBoolean = typeof schema.exclusiveMinimum === 'boolean';
|
|
390
|
+
const exMaxIsBoolean = typeof schema.exclusiveMaximum === 'boolean';
|
|
220
391
|
if (schema.minimum !== undefined) {
|
|
221
|
-
|
|
392
|
+
if (exMinIsBoolean && schema.exclusiveMinimum) {
|
|
393
|
+
result += `.gt(${schema.minimum})`;
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
result += `.min(${schema.minimum})`;
|
|
397
|
+
}
|
|
222
398
|
}
|
|
223
399
|
if (schema.maximum !== undefined) {
|
|
224
|
-
|
|
400
|
+
if (exMaxIsBoolean && schema.exclusiveMaximum) {
|
|
401
|
+
result += `.lt(${schema.maximum})`;
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
result += `.max(${schema.maximum})`;
|
|
405
|
+
}
|
|
225
406
|
}
|
|
226
|
-
if (schema.exclusiveMinimum !== undefined) {
|
|
407
|
+
if (!exMinIsBoolean && schema.exclusiveMinimum !== undefined) {
|
|
227
408
|
result += `.gt(${schema.exclusiveMinimum})`;
|
|
228
409
|
}
|
|
229
|
-
if (schema.exclusiveMaximum !== undefined) {
|
|
410
|
+
if (!exMaxIsBoolean && schema.exclusiveMaximum !== undefined) {
|
|
230
411
|
result += `.lt(${schema.exclusiveMaximum})`;
|
|
231
412
|
}
|
|
232
413
|
}
|
|
@@ -252,8 +433,39 @@ function safeKey(key) {
|
|
|
252
433
|
* Generate the Zod variable name for a component schema.
|
|
253
434
|
* e.g. "PaginatedResponse" → "PaginatedResponseSchema"
|
|
254
435
|
*/
|
|
436
|
+
const RESERVED_TYPE_NAMES = new Set([
|
|
437
|
+
'object',
|
|
438
|
+
'string',
|
|
439
|
+
'number',
|
|
440
|
+
'boolean',
|
|
441
|
+
'symbol',
|
|
442
|
+
'undefined',
|
|
443
|
+
'null',
|
|
444
|
+
'void',
|
|
445
|
+
'never',
|
|
446
|
+
'any',
|
|
447
|
+
'unknown',
|
|
448
|
+
'bigint',
|
|
449
|
+
'function',
|
|
450
|
+
'class',
|
|
451
|
+
'enum',
|
|
452
|
+
'interface',
|
|
453
|
+
'type',
|
|
454
|
+
'import',
|
|
455
|
+
'export',
|
|
456
|
+
]);
|
|
255
457
|
export function sanitizeTypeName(name) {
|
|
256
|
-
|
|
458
|
+
// Strip numeric separator patterns (e.g. 1_000 → 1000)
|
|
459
|
+
let cleaned = name.replace(/(\d)_(\d)/g, '$1$2');
|
|
460
|
+
cleaned = cleaned.replace(/[^a-zA-Z0-9_$]/g, '');
|
|
461
|
+
// Prefix with 'n' if starts with a digit
|
|
462
|
+
if (/^[0-9]/.test(cleaned)) {
|
|
463
|
+
cleaned = 'n' + cleaned;
|
|
464
|
+
}
|
|
465
|
+
if (RESERVED_TYPE_NAMES.has(cleaned.toLowerCase())) {
|
|
466
|
+
return `${cleaned}Type`;
|
|
467
|
+
}
|
|
468
|
+
return cleaned;
|
|
257
469
|
}
|
|
258
470
|
export function schemaVarName(name) {
|
|
259
471
|
const sanitized = sanitizeTypeName(name);
|