Skip to content

Commit 591da5b

Browse files
committed
feat(openapi-generator): introduce parser and generator
1 parent bce2421 commit 591da5b

File tree

3 files changed

+396
-0
lines changed

3 files changed

+396
-0
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// path-parser.ts
2+
import { readFileSync } from 'fs-extra'
3+
import type { OpenAPIV3_1 } from 'openapi-types'
4+
5+
export function parseOpenApiSpec(filePath: string): OpenAPIV3_1.Document {
6+
const fileContent = readFileSync(filePath, 'utf-8')
7+
return JSON.parse(fileContent)
8+
}
9+
10+
export function parsePaths(spec: OpenAPIV3_1.Document): Record<string, any> {
11+
const paths = spec.paths
12+
if (!paths) return {}
13+
const result: Record<string, any> = {}
14+
15+
for (const path in paths) {
16+
const pathItem = paths[path]
17+
if (!pathItem) continue
18+
19+
const pathParts = path.split('/').filter((p) => p)
20+
let current = result
21+
22+
for (let i = 0; i < pathParts.length; i++) {
23+
let part = pathParts[i]
24+
if (!part) continue
25+
26+
if (part.startsWith('{') && part.endsWith('}')) {
27+
part = `$${part.slice(1, -1)}`
28+
}
29+
30+
if (!current[part]) {
31+
current[part] = {}
32+
}
33+
34+
if (i === pathParts.length - 1) {
35+
current[part] = { ...current[part], ...pathItem }
36+
}
37+
38+
current = current[part]
39+
}
40+
}
41+
42+
return result
43+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { camelCase, upperFirst } from 'lodash'
2+
import type { OpenAPIV3_1 } from 'openapi-types'
3+
import { SchemaGenerator } from './schema_generator'
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+
function generateBuilder(
14+
operation: OpenAPIV3_1.OperationObject,
15+
pathParams: (OpenAPIV3_1.ParameterObject | OpenAPIV3_1.ReferenceObject)[],
16+
schemaGenerator: SchemaGenerator
17+
): string {
18+
let builder = 'f.builder().def_json()'
19+
20+
const allParameters = [...pathParams]
21+
if (operation.parameters) {
22+
const pathParamNames = new Set(
23+
pathParams.map((p) => (!isReferenceObject(p) ? p.name : null))
24+
)
25+
operation.parameters.forEach((opParam) => {
26+
if (
27+
isReferenceObject(opParam) ||
28+
!pathParamNames.has(opParam.name)
29+
) {
30+
allParameters.push(opParam)
31+
}
32+
})
33+
}
34+
35+
const queryParameters = allParameters.filter(
36+
(p) => !isReferenceObject(p) && p.in === 'query'
37+
) as OpenAPIV3_1.ParameterObject[]
38+
if (queryParameters.length > 0) {
39+
const queryParamsString = queryParameters
40+
.map((param) => {
41+
const zodType = schemaGenerator.generateZodSchema(
42+
param.schema as OpenAPIV3_1.SchemaObject,
43+
param.name
44+
)
45+
const finalType = param.required
46+
? zodType
47+
: `${zodType}.optional()`
48+
return `${param.name}: ${finalType}`
49+
})
50+
.join(', ')
51+
builder += `.def_searchparams(z.object({ ${queryParamsString} }).parse)`
52+
}
53+
54+
if (operation.requestBody && !isReferenceObject(operation.requestBody)) {
55+
const requestBody =
56+
operation.requestBody as OpenAPIV3_1.RequestBodyObject
57+
const jsonContent = requestBody.content?.['application/json']
58+
const formContent = requestBody.content?.['multipart/form-data']
59+
60+
if (jsonContent?.schema && isReferenceObject(jsonContent.schema)) {
61+
const modelName = toPascalCase(
62+
jsonContent.schema.$ref.split('/').pop() || ''
63+
)
64+
builder += `.def_body(Model.${modelName}.parse)`
65+
} else if (formContent) {
66+
builder += `.def_body(z.instanceof(FormData).parse)`
67+
}
68+
}
69+
70+
const response =
71+
operation.responses?.['200'] || operation.responses?.['201']
72+
if (response && !isReferenceObject(response)) {
73+
const mediaTypeObject = response.content?.['application/json']
74+
if (mediaTypeObject?.schema) {
75+
const schema = mediaTypeObject.schema
76+
if (isReferenceObject(schema)) {
77+
const modelName = toPascalCase(
78+
schema.$ref.split('/').pop() || ''
79+
)
80+
builder += `.def_response(async ({ json }) => Model.${modelName}.parse(await json()))`
81+
} else if (
82+
schema.type === 'array' &&
83+
schema.items &&
84+
isReferenceObject(schema.items)
85+
) {
86+
const modelName = toPascalCase(
87+
schema.items.$ref.split('/').pop() || ''
88+
)
89+
builder += `.def_response(async ({ json }) => z.array(Model.${modelName}).parse(await json()))`
90+
}
91+
}
92+
}
93+
return builder
94+
}
95+
96+
export function generateRouter(
97+
parsedPaths: Record<string, any>,
98+
spec: OpenAPIV3_1.Document
99+
): string {
100+
const httpMethods = new Set([
101+
'get',
102+
'put',
103+
'post',
104+
'delete',
105+
'options',
106+
'head',
107+
'patch',
108+
'trace',
109+
])
110+
const openApiMetadataKeys = new Set([
111+
'summary',
112+
'description',
113+
'parameters',
114+
'servers',
115+
'$ref',
116+
])
117+
118+
const schemaGenerator = new SchemaGenerator(spec)
119+
120+
function buildRouterObject(pathNode: Record<string, any>): string {
121+
const parts: string[] = []
122+
const pathLevelParams = pathNode.parameters || []
123+
124+
for (const key in pathNode) {
125+
const value = pathNode[key]
126+
if (httpMethods.has(key.toLowerCase())) {
127+
parts.push(
128+
`'${key.toUpperCase()}': ${generateBuilder(value, pathLevelParams, schemaGenerator)}`
129+
)
130+
} else if (
131+
typeof value === 'object' &&
132+
value !== null &&
133+
!openApiMetadataKeys.has(key)
134+
) {
135+
parts.push(`'${key}': {\n${buildRouterObject(value)}\n}`)
136+
}
137+
}
138+
return parts.join(',\n')
139+
}
140+
141+
const routerObject = `{\n${buildRouterObject(parsedPaths)}\n}`
142+
const baseUrl = spec.servers && spec.servers[0] ? spec.servers[0].url : ''
143+
144+
return `import { f } from '@metal-box/fetch';
145+
import { z } from 'zod';
146+
import * as Model from './models';
147+
148+
export const api = f.router('${baseUrl}', ${routerObject});`
149+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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

Comments
 (0)