|
| 1 | +// schema-generator.ts |
| 2 | +import { camelCase, upperFirst } from 'lodash' |
| 3 | +import type { OpenAPIV3_1 } from 'openapi-types' |
| 4 | + |
| 5 | +function isReferenceObject(obj: any): obj is OpenAPIV3_1.ReferenceObject { |
| 6 | + return obj && '$ref' in obj |
| 7 | +} |
| 8 | + |
| 9 | +function toPascalCase(str: string): string { |
| 10 | + return upperFirst(camelCase(str)) |
| 11 | +} |
| 12 | + |
| 13 | +export class SchemaGenerator { |
| 14 | + private spec: OpenAPIV3_1.Document |
| 15 | + private processedSchemas: Map<string, string> = new Map() |
| 16 | + |
| 17 | + constructor(spec: OpenAPIV3_1.Document) { |
| 18 | + this.spec = spec |
| 19 | + } |
| 20 | + |
| 21 | + public generateZodSchema( |
| 22 | + schema: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject, |
| 23 | + nameHint: string = 'inline' |
| 24 | + ): string { |
| 25 | + // We pass a nameHint to satisfy the recursive function's signature |
| 26 | + return this.mapSchemaObjectToZod(nameHint, schema) |
| 27 | + } |
| 28 | + |
| 29 | + public generateModels(): string { |
| 30 | + if (!this.spec.components || !this.spec.components.schemas) { |
| 31 | + return '' |
| 32 | + } |
| 33 | + const schemas = Object.entries(this.spec.components.schemas) |
| 34 | + const modelStrings: string[] = [`import { z } from 'zod';`] |
| 35 | + |
| 36 | + for (const [name] of schemas) { |
| 37 | + this.mapSchemaObjectToZod(name) |
| 38 | + } |
| 39 | + |
| 40 | + for (const [name, zodSchema] of this.processedSchemas.entries()) { |
| 41 | + const pascalName = toPascalCase(name) |
| 42 | + modelStrings.push(`export const ${pascalName} = ${zodSchema};`) |
| 43 | + modelStrings.push( |
| 44 | + `export type ${pascalName}Model = z.infer<typeof ${pascalName}>;` |
| 45 | + ) |
| 46 | + } |
| 47 | + return modelStrings.join('\n\n') |
| 48 | + } |
| 49 | + |
| 50 | + private resolveRef(ref: string): { |
| 51 | + name: string |
| 52 | + schema: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject |
| 53 | + } { |
| 54 | + if (!ref.startsWith('#/components/schemas/')) { |
| 55 | + throw new Error( |
| 56 | + `Unsupported $ref format: ${ref}. Only local component schema references are supported.` |
| 57 | + ) |
| 58 | + } |
| 59 | + const name = ref.split('/').pop() |
| 60 | + if (!name) throw new Error(`Invalid $ref: ${ref}`) |
| 61 | + const schema = this.spec.components?.schemas?.[name] |
| 62 | + if (!schema) throw new Error(`Schema not found for $ref: ${ref}`) |
| 63 | + return { name, schema } |
| 64 | + } |
| 65 | + |
| 66 | + private mapSchemaObjectToZod( |
| 67 | + name: string, |
| 68 | + schemaObject?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject |
| 69 | + ): string { |
| 70 | + const schema = schemaObject || this.spec.components?.schemas?.[name] |
| 71 | + if (!schema) { |
| 72 | + throw new Error(`Schema with name ${name} not found.`) |
| 73 | + } |
| 74 | + |
| 75 | + if (this.processedSchemas.has(name) && !schemaObject) { |
| 76 | + return toPascalCase(name) |
| 77 | + } |
| 78 | + |
| 79 | + // Handle "rich references" (with $ref and other keys) FIRST by transforming them into `allOf`. |
| 80 | + if (isReferenceObject(schema) && Object.keys(schema).length > 1) { |
| 81 | + const { $ref, ...rest } = schema |
| 82 | + const allOfSchema: OpenAPIV3_1.SchemaObject = { |
| 83 | + allOf: [{ $ref }, rest as OpenAPIV3_1.SchemaObject], |
| 84 | + } |
| 85 | + return this.mapSchemaObjectToZod(name, allOfSchema) |
| 86 | + } |
| 87 | + |
| 88 | + // Handle "pure" reference objects (only a $ref). |
| 89 | + if (isReferenceObject(schema)) { |
| 90 | + const { name: refName } = this.resolveRef(schema.$ref) |
| 91 | + this.mapSchemaObjectToZod(refName) |
| 92 | + return toPascalCase(refName) |
| 93 | + } |
| 94 | + |
| 95 | + // Handle `allOf` composition |
| 96 | + if (schema.allOf) { |
| 97 | + const baseRef = schema.allOf[0] |
| 98 | + const overrides = schema.allOf[1] |
| 99 | + |
| 100 | + // Specifically handle the dynamic generic pattern after it's been transformed |
| 101 | + if (isReferenceObject(baseRef) && (overrides as any).$defs) { |
| 102 | + const baseSchemaName = this.resolveRef(baseRef.$ref).name |
| 103 | + const baseSchemaString = |
| 104 | + this.mapSchemaObjectToZod(baseSchemaName) |
| 105 | + |
| 106 | + const itemRef = (overrides as any).$defs.productItem.$ref |
| 107 | + const itemSchemaName = this.resolveRef(itemRef).name |
| 108 | + const itemSchemaString = |
| 109 | + this.mapSchemaObjectToZod(itemSchemaName) |
| 110 | + |
| 111 | + const zodSchema = baseSchemaString.replace( |
| 112 | + 'z.array(z.any())', |
| 113 | + `z.array(${itemSchemaString})` |
| 114 | + ) |
| 115 | + this.processedSchemas.set(name, zodSchema) |
| 116 | + return zodSchema |
| 117 | + } |
| 118 | + |
| 119 | + // Handle standard `allOf` |
| 120 | + const allOfSchemas = schema.allOf |
| 121 | + .map((s) => this.mapSchemaObjectToZod(name, s)) |
| 122 | + .join('.and(') |
| 123 | + const zodSchema = allOfSchemas + ')'.repeat(schema.allOf.length - 1) |
| 124 | + if (!schemaObject) this.processedSchemas.set(name, zodSchema) |
| 125 | + return zodSchema |
| 126 | + } |
| 127 | + |
| 128 | + // Handle `oneOf` polymorphism |
| 129 | + if (schema.oneOf && schema.discriminator) { |
| 130 | + const discriminator = schema.discriminator.propertyName |
| 131 | + const options = schema.oneOf.map((s) => { |
| 132 | + if (!isReferenceObject(s)) |
| 133 | + throw new Error( |
| 134 | + 'oneOf with discriminator must use $ref objects' |
| 135 | + ) |
| 136 | + return this.mapSchemaObjectToZod(name, s) |
| 137 | + }) |
| 138 | + const zodSchema = `z.discriminatedUnion('${discriminator}', [${options.join(', ')}])` |
| 139 | + if (!schemaObject) this.processedSchemas.set(name, zodSchema) |
| 140 | + return zodSchema |
| 141 | + } |
| 142 | + |
| 143 | + let zodString = 'z.any()' |
| 144 | + switch (schema.type) { |
| 145 | + case 'string': |
| 146 | + zodString = 'z.string()' |
| 147 | + if (schema.enum) { |
| 148 | + zodString = `z.enum([${schema.enum.map((e) => `'${e}'`).join(', ')}])` |
| 149 | + } |
| 150 | + if (schema.format === 'date-time') zodString += '.datetime()' |
| 151 | + else if (schema.format === 'email') zodString += '.email()' |
| 152 | + else if (schema.format === 'uri') zodString += '.url()' |
| 153 | + else if (schema.format === 'uuid') zodString += '.uuid()' |
| 154 | + if (schema.pattern) zodString += `.regex(/${schema.pattern}/)` |
| 155 | + break |
| 156 | + case 'number': |
| 157 | + zodString = 'z.number()' |
| 158 | + if (schema.minimum !== undefined) |
| 159 | + zodString += `.min(${schema.minimum})` |
| 160 | + if (schema.maximum !== undefined) |
| 161 | + zodString += `.max(${schema.maximum})` |
| 162 | + break |
| 163 | + case 'integer': |
| 164 | + zodString = 'z.number().int()' |
| 165 | + if (schema.minimum !== undefined) |
| 166 | + zodString += `.min(${schema.minimum})` |
| 167 | + if (schema.maximum !== undefined) |
| 168 | + zodString += `.max(${schema.maximum})` |
| 169 | + break |
| 170 | + case 'boolean': |
| 171 | + zodString = 'z.boolean()' |
| 172 | + break |
| 173 | + case 'array': |
| 174 | + if (!schema.items) |
| 175 | + throw new Error('Array schema must have items defined.') |
| 176 | + const itemSchema = this.mapSchemaObjectToZod(name, schema.items) |
| 177 | + zodString = `z.array(${itemSchema})` |
| 178 | + break |
| 179 | + case 'object': |
| 180 | + const properties = schema.properties |
| 181 | + ? Object.entries(schema.properties) |
| 182 | + .map(([key, value]) => { |
| 183 | + const isRequired = schema.required?.includes(key) |
| 184 | + const zodType = this.mapSchemaObjectToZod( |
| 185 | + key, |
| 186 | + value |
| 187 | + ) |
| 188 | + const finalType = isRequired |
| 189 | + ? zodType |
| 190 | + : `${zodType}.optional()` |
| 191 | + return `'${key}': ${finalType}` |
| 192 | + }) |
| 193 | + .join(',\n') |
| 194 | + : '' |
| 195 | + zodString = `z.object({\n${properties}\n})` |
| 196 | + break |
| 197 | + } |
| 198 | + |
| 199 | + if (!schemaObject) { |
| 200 | + this.processedSchemas.set(name, zodString) |
| 201 | + } |
| 202 | + return zodString |
| 203 | + } |
| 204 | +} |
0 commit comments