Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { isValidOpenapiSchema } from './utils/validation.js';
import { extractSchemas } from './utils/extract-schemas.js';
import { convertSchema } from './utils/json-schema-to-zod.js';
import { convertToTypedDict } from './utils/json-schema-to-typed-dict.js';
import { toCamelCase, toPascalCase } from './utils/case.js';
import { sortSchemas } from './utils/sort-schemas.js';
import Handlebars from 'handlebars';

Expand Down Expand Up @@ -95,12 +96,12 @@ program
const template = Handlebars.compile(templateSource);

const ordered = sortSchemas(schemas);
const schemaData = ordered.map((name) => {
const schema = schemas[name]!;
const schemaData = ordered.map((rawName) => {
const schema = schemas[rawName]!;
const { zodString } = convertSchema(schema);
const desc = schema.description as string | undefined;
return {
schemaName: name,
schemaName: `${toCamelCase(rawName)}Schema`,
zodString,
description: desc ? desc.replace(/\n/g, ' ') : undefined
};
Expand Down Expand Up @@ -148,10 +149,10 @@ program

const typingImports = new Set<string>();
const ordered = sortSchemas(schemas);
const definitions = ordered.map((name) => {
const res = convertToTypedDict(name, schemas[name]!);
const definitions = ordered.map((rawName) => {
const res = convertToTypedDict(rawName, schemas[rawName]!);
res.typingImports.forEach((i) => typingImports.add(i));
return { name, definition: res.definition };
return { name: toPascalCase(rawName), definition: res.definition };
});

const content = template({
Expand Down
13 changes: 13 additions & 0 deletions src/utils/case.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function toPascalCase(str: string): string {
return str
.replace(/[^a-zA-Z0-9]+/g, ' ')
.split(' ')
.filter(Boolean)
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join('');
}

export function toCamelCase(str: string): string {
const pascal = toPascalCase(str);
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
}
20 changes: 7 additions & 13 deletions src/utils/json-schema-to-typed-dict.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
import type { OpenAPIV3_1 as OpenAPI } from 'openapi-types';
import { toPascalCase } from './case.js';

export interface TypedDictResult {
definition: string;
typingImports: Set<string>;
}

export function convertToTypedDict(name: string, schema: OpenAPI.SchemaObject | OpenAPI.ReferenceObject): TypedDictResult {
name = toPascalCase(name);
const typingImports = new Set<string>(['TypedDict']);
const extraDefs: string[] = [];

function toPascal(str: string): string {
return str
.replace(/[^a-zA-Z0-9]+/g, ' ')
.split(' ')
.filter(Boolean)
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join('');
}

function flatten(s: OpenAPI.SchemaObject | OpenAPI.ReferenceObject): { bases: string[]; schema: OpenAPI.SchemaObject | null } {
if ('$ref' in s) {
const match = s.$ref.match(/^#\/components\/schemas\/(.+)$/);
return { bases: [match?.[1] ?? s.$ref], schema: null };
const refName = match?.[1] ?? s.$ref;
return { bases: [toPascalCase(refName)], schema: null };
}

if ('allOf' in s && Array.isArray(s.allOf)) {
Expand Down Expand Up @@ -51,7 +45,7 @@ export function convertToTypedDict(name: string, schema: OpenAPI.SchemaObject |
const fields: string[] = [];
const attrLines: string[] = [];
for (const [key, value] of Object.entries(props)) {
const typeStr = toType(value as any, `${className}${toPascal(key)}`);
const typeStr = toType(value as any, `${className}${toPascalCase(key)}`);
const desc = (value as any).description;
if (required.has(key)) {
typingImports.add('Required');
Expand Down Expand Up @@ -95,7 +89,7 @@ export function convertToTypedDict(name: string, schema: OpenAPI.SchemaObject |
if ('$ref' in s) {
const match = s.$ref.match(/^#\/components\/schemas\/(.+)$/);
const refName = match?.[1] ?? s.$ref;
return `${refName}`;
return `${toPascalCase(refName)}`;
}

if ('oneOf' in s && Array.isArray(s.oneOf)) {
Expand Down Expand Up @@ -169,7 +163,7 @@ export function convertToTypedDict(name: string, schema: OpenAPI.SchemaObject |
const fields: string[] = [];
const attrLines: string[] = [];
for (const [key, value] of Object.entries(props)) {
const typeStr = toType(value as any, `${name}${toPascal(key)}`);
const typeStr = toType(value as any, `${name}${toPascalCase(key)}`);
const desc = (value as any).description;
if (required.has(key)) {
typingImports.add('Required');
Expand Down
4 changes: 3 additions & 1 deletion src/utils/json-schema-to-zod.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { OpenAPIV3_1 as OpenAPI } from 'openapi-types';
import { toCamelCase } from './case.js';

export interface ZodResult {
zodString: string;
Expand All @@ -11,7 +12,8 @@ export function convertSchema(schema: OpenAPI.SchemaObject | OpenAPI.ReferenceOb
function walk(s: OpenAPI.SchemaObject | OpenAPI.ReferenceObject): string {
if ('$ref' in s) {
const match = s.$ref.match(/^#\/components\/schemas\/(.+)$/);
const name = match?.[1] ?? s.$ref;
const raw = match?.[1] ?? s.$ref;
const name = `${toCamelCase(raw)}Schema`;
imports.add(name);
return name;
}
Expand Down
4 changes: 2 additions & 2 deletions tests/__snapshots__/generate-zod.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ exports[`generate-zod > generates a simple object schema 1`] = `

import { z } from 'zod/v4';

export const User = z.object({
export const userSchema = z.object({
id: z.string()
});
"
Expand All @@ -16,6 +16,6 @@ exports[`generate-zod > generates enums and nested arrays 1`] = `

import { z } from 'zod/v4';

export const Wrapper = z.array(Status);
export const wrapperSchema = z.array(statusSchema);
"
`;
11 changes: 11 additions & 0 deletions tests/case.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest';
import { toPascalCase, toCamelCase } from '../src/utils/case';

describe('case utils', () => {
it('converts to PascalCase', () => {
expect(toPascalCase('my_schema-name')).toBe('MySchemaName');
});
it('converts to camelCase', () => {
expect(toCamelCase('my_schema-name')).toBe('mySchemaName');
});
});
4 changes: 2 additions & 2 deletions tests/generate-zod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('generate-zod', () => {
const schemas = extractSchemas(doc, null);
const { zodString } = convertSchema(schemas.User as OpenAPI.SchemaObject);
const content = schemaTemplate({
schemas: [{ schemaName: 'User', zodString }]
schemas: [{ schemaName: 'userSchema', zodString }]
});
const result = ts.transpileModule(content, { compilerOptions: { module: ts.ModuleKind.ESNext } });
expect(result.diagnostics?.length).toBe(0);
Expand All @@ -43,7 +43,7 @@ describe('generate-zod', () => {
const schemas = extractSchemas(doc, null);
const { zodString } = convertSchema(schemas.Wrapper as OpenAPI.SchemaObject);
const content = schemaTemplate({
schemas: [{ schemaName: 'Wrapper', zodString }]
schemas: [{ schemaName: 'wrapperSchema', zodString }]
});
const result = ts.transpileModule(content, { compilerOptions: { module: ts.ModuleKind.ESNext } });
expect(result.diagnostics?.length).toBe(0);
Expand Down
18 changes: 9 additions & 9 deletions tests/json-schema-to-zod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ describe('convertSchema', () => {
type: 'array',
items: { $ref: '#/components/schemas/User' }
} as OpenAPI.SchemaObject);
expect(zodString).toBe('z.array(User)');
expect(imports.has('User')).toBe(true);
expect(zodString).toBe('z.array(userSchema)');
expect(imports.has('userSchema')).toBe(true);
});

it('converts objects with optional fields', () => {
Expand Down Expand Up @@ -49,27 +49,27 @@ describe('convertSchema', () => {
allOf: [{ $ref: '#/components/schemas/Base' }, { type: 'object', properties: { extra: { type: 'string' } }, required: ['extra'] }]
} as OpenAPI.SchemaObject;
const { zodString, imports } = convertSchema(schema);
expect(zodString).toBe('z.intersection(Base, z.object({\n extra: z.string()\n}))');
expect(imports.has('Base')).toBe(true);
expect(zodString).toBe('z.intersection(baseSchema, z.object({\n extra: z.string()\n}))');
expect(imports.has('baseSchema')).toBe(true);
});

it('handles oneOf with refs', () => {
const schema = {
oneOf: [{ $ref: '#/components/schemas/A' }, { $ref: '#/components/schemas/B' }]
} as OpenAPI.SchemaObject;
const { zodString, imports } = convertSchema(schema);
expect(zodString).toBe('z.union([A, B])');
expect(imports.has('A')).toBe(true);
expect(imports.has('B')).toBe(true);
expect(zodString).toBe('z.union([aSchema, bSchema])');
expect(imports.has('aSchema')).toBe(true);
expect(imports.has('bSchema')).toBe(true);
});

it('handles oneOf with a single ref', () => {
const schema = {
oneOf: [{ $ref: '#/components/schemas/A' }]
} as OpenAPI.SchemaObject;
const { zodString, imports } = convertSchema(schema);
expect(zodString).toBe('A');
expect(imports.has('A')).toBe(true);
expect(zodString).toBe('aSchema');
expect(imports.has('aSchema')).toBe(true);
});

it('converts inline object properties', () => {
Expand Down