hey-api-builders
Version:
A custom plugin for @hey-api/openapi-ts that generates mock data builders with a lightweight custom runtime, Zod integration, or static mock generation.
720 lines (599 loc) • 20.7 kB
text/typescript
import type { Schema } from '../types';
import type { ExtendedSchema, JsonValue } from '../types';
export interface ZodGeneratorOptions {
useOptional?: boolean;
useNullable?: boolean;
}
export interface ZodMockOptions {
useDefault?: boolean;
useExamples?: boolean;
alwaysIncludeOptionals?: boolean;
optionalsProbability?: number | false;
omitNulls?: boolean;
}
/**
* Generates a Zod schema string from a JSON schema
*/
export function generateZodSchema(schema: Schema, options: ZodGeneratorOptions = {}): string {
return generateZodSchemaInternal(schema as ExtendedSchema, options);
}
/**
* Generates mock data from a Zod schema definition
*/
export function generateMockFromZodSchema(
zodSchemaString: string,
overrides: Record<string, unknown> = {},
options: ZodMockOptions = {}
): unknown {
const mockGenerator = new ZodMockGenerator(options);
return mockGenerator.generateFromSchemaString(zodSchemaString, overrides);
}
class ZodMockGenerator {
private static readonly EMAIL_DOMAINS = ['example.com', 'test.org', 'demo.net', 'sample.io'];
private static readonly FIRST_NAMES = [
'Alice',
'Bob',
'Charlie',
'Diana',
'Eve',
'Frank',
'Grace',
'Henry',
];
private static readonly LAST_NAMES = [
'Smith',
'Johnson',
'Williams',
'Brown',
'Jones',
'Garcia',
'Miller',
'Davis',
];
private options: ZodMockOptions;
constructor(options: ZodMockOptions = {}) {
this.options = {
useDefault: options.useDefault ?? false,
useExamples: options.useExamples ?? false,
alwaysIncludeOptionals: options.alwaysIncludeOptionals ?? false,
optionalsProbability: options.optionalsProbability ?? 0.8,
omitNulls: options.omitNulls ?? false,
};
}
generateFromSchemaString(
zodSchemaString: string,
overrides: Record<string, unknown> = {}
): unknown {
if (zodSchemaString.includes('z.object(')) {
return this.generateObjectMock(zodSchemaString, overrides);
} else if (zodSchemaString.includes('z.array(')) {
return this.generateArrayMock(zodSchemaString, overrides);
} else if (zodSchemaString.includes('z.enum(')) {
return this.generateEnumMock(zodSchemaString);
} else if (zodSchemaString.includes('z.string()')) {
return this.generateStringMock(zodSchemaString);
} else if (zodSchemaString.includes('z.number()')) {
return this.generateNumberMock(zodSchemaString);
} else if (zodSchemaString.includes('z.boolean()')) {
return this.generateBooleanMock();
} else if (zodSchemaString.includes('z.union(')) {
return this.generateUnionMock(zodSchemaString, overrides);
} else if (zodSchemaString.includes('z.null()')) {
return this.options.omitNulls ? undefined : null;
}
return null;
}
private generateObjectMock(
zodSchemaString: string,
overrides: Record<string, unknown>
): Record<string, unknown> {
const mock: Record<string, unknown> = {};
const propertiesString = this.extractObjectProperties(zodSchemaString);
if (!propertiesString) {
return mock;
}
const properties = this.parseObjectProperties(propertiesString);
for (const [key, propSchema] of properties) {
if (overrides[key] !== undefined) {
mock[key] = overrides[key];
} else {
const isOptional = propSchema.includes('.optional()');
if (isOptional && !this.options.alwaysIncludeOptionals) {
const probability =
this.options.optionalsProbability === false ? 0.8 : this.options.optionalsProbability;
if (Math.random() > (probability ?? 0.8)) {
continue;
}
}
const value = this.generateFromSchemaString(propSchema);
if (value === null && this.options.omitNulls) {
continue;
}
mock[key] = value;
}
}
return mock;
}
private extractObjectProperties(zodSchemaString: string): string | null {
const objectStart = zodSchemaString.indexOf('z.object({');
if (objectStart === -1) {
return null;
}
const contentStart = objectStart + 'z.object({'.length;
let depth = 1;
let i = contentStart;
while (i < zodSchemaString.length && depth > 0) {
const char = zodSchemaString[i];
if (char === '{') {
depth++;
} else if (char === '}') {
depth--;
}
i++;
}
if (depth === 0) {
return zodSchemaString.substring(contentStart, i - 1);
}
return null;
}
private parseObjectProperties(propertiesString: string): Array<[string, string]> {
const properties: Array<[string, string]> = [];
const cleanString = propertiesString.trim();
const propertyStrings = this.splitObjectProperties(cleanString);
for (const propString of propertyStrings) {
const trimmed = propString.trim();
if (!trimmed) {
continue;
}
const match = trimmed.match(/^(\w+|"[^"]+"|'[^']+'):\s*(.+)$/s);
if (match && match[1] && match[2]) {
const key = match[1].replace(/['"]/g, '');
const schema = match[2].trim();
properties.push([key, schema]);
}
}
return properties;
}
private splitObjectProperties(str: string): string[] {
const result: string[] = [];
let current = '';
let depth = 0;
let inString = false;
let stringChar = '';
let i = 0;
while (i < str.length) {
const char = str[i];
if (!inString && (char === '"' || char === "'")) {
inString = true;
stringChar = char;
} else if (inString && char === stringChar && str[i - 1] !== '\\') {
inString = false;
stringChar = '';
} else if (!inString) {
if (char === '(' || char === '[' || char === '{') {
depth++;
} else if (char === ')' || char === ']' || char === '}') {
depth--;
} else if (char === ',' && depth === 0) {
const beforeComma = current.trim();
if (beforeComma && this.isCompleteProperty(beforeComma)) {
result.push(current.trim());
current = '';
i++;
continue;
}
}
}
current += char;
i++;
}
if (current.trim()) {
result.push(current.trim());
}
return result;
}
private isCompleteProperty(str: string): boolean {
const colonIndex = str.indexOf(':');
if (colonIndex === -1) {
return false;
}
const key = str.substring(0, colonIndex).trim();
const value = str.substring(colonIndex + 1).trim();
const keyValid = /^(\w+|"[^"]+"|'[^']+')$/.test(key);
const valueValid = value.length > 0 && this.hasBalancedBrackets(value);
return keyValid && valueValid;
}
private hasBalancedBrackets(str: string): boolean {
let depth = 0;
let inString = false;
let stringChar = '';
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (!inString && (char === '"' || char === "'")) {
inString = true;
stringChar = char;
} else if (inString && char === stringChar && str[i - 1] !== '\\') {
inString = false;
stringChar = '';
} else if (!inString) {
if (char === '(' || char === '[' || char === '{') {
depth++;
} else if (char === ')' || char === ']' || char === '}') {
depth--;
if (depth < 0) {
return false;
}
}
}
}
return depth === 0;
}
private generateArrayMock(
zodSchemaString: string,
overrides: Record<string, unknown>
): unknown[] {
const itemTypeMatch = zodSchemaString.match(/z\.array\(([^)]+)\)/);
if (!itemTypeMatch || !itemTypeMatch[1]) {
return [];
}
const itemSchema = itemTypeMatch[1];
if (Array.isArray(overrides.items)) {
return overrides.items.map((item) => {
if (item !== null && typeof item === 'object') {
return this.generateFromSchemaString(itemSchema, item as Record<string, unknown>);
} else {
return item;
}
});
}
const minMatch = zodSchemaString.match(/\.min\((\d+)\)/);
const maxMatch = zodSchemaString.match(/\.max\((\d+)\)/);
const minLength = minMatch?.[1] ? parseInt(minMatch[1]) : 1;
const maxLength = maxMatch?.[1] ? parseInt(maxMatch[1]) : 3;
const length = Math.floor(Math.random() * (maxLength - minLength + 1)) + minLength;
return Array.from({ length }, () => {
return this.generateFromSchemaString(itemSchema, {});
});
}
private generateEnumMock(zodSchemaString: string): unknown {
const enumMatch = zodSchemaString.match(/z\.enum\(\[([^\]]+)\]\)/);
if (!enumMatch || !enumMatch[1]) {
return null;
}
const enumValues = enumMatch[1].split(',').map((val) => {
const trimmed = val.trim();
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed.slice(1, -1);
}
if (trimmed === 'true' || trimmed === 'false') {
return trimmed === 'true';
}
if (!isNaN(Number(trimmed))) {
return Number(trimmed);
}
return trimmed;
});
return enumValues[Math.floor(Math.random() * enumValues.length)];
}
private generateStringMock(zodSchemaString: string): string {
if (zodSchemaString.includes('.uuid()')) {
return this.generateUUID();
} else if (zodSchemaString.includes('.email()')) {
return this.generateEmail();
} else if (zodSchemaString.includes('.url()')) {
return this.generateURL();
} else if (zodSchemaString.includes('.date()')) {
return this.generateDate();
} else if (zodSchemaString.includes('.datetime()')) {
return this.generateDateTime();
} else if (zodSchemaString.includes('.regex(')) {
return this.generateFromRegex(zodSchemaString);
}
const minMatch = zodSchemaString.match(/\.min\((\d+)\)/);
const maxMatch = zodSchemaString.match(/\.max\((\d+)\)/);
const minLength = minMatch?.[1] ? parseInt(minMatch[1]) : 5;
const maxLength = maxMatch?.[1] ? parseInt(maxMatch[1]) : 15;
const targetLength = Math.floor(Math.random() * (maxLength - minLength + 1)) + minLength;
return this.generateRandomString(targetLength);
}
private generateNumberMock(zodSchemaString: string): number {
const isInt = zodSchemaString.includes('.int()');
const minMatch = zodSchemaString.match(/\.min\(([^)]+)\)/);
const maxMatch = zodSchemaString.match(/\.max\(([^)]+)\)/);
const gtMatch = zodSchemaString.match(/\.gt\(([^)]+)\)/);
const ltMatch = zodSchemaString.match(/\.lt\(([^)]+)\)/);
let min = minMatch && minMatch[1] ? parseFloat(minMatch[1]) : isInt ? 0 : 0.0;
let max = maxMatch && maxMatch[1] ? parseFloat(maxMatch[1]) : isInt ? 100 : 100.0;
if (gtMatch && gtMatch[1]) {
min = parseFloat(gtMatch[1]) + (isInt ? 1 : 0.001);
}
if (ltMatch && ltMatch[1]) {
max = parseFloat(ltMatch[1]) - (isInt ? 1 : 0.001);
}
const value = Math.random() * (max - min) + min;
return isInt ? Math.floor(value) : Math.round(value * 100) / 100;
}
private generateBooleanMock(): boolean {
return Math.random() > 0.5;
}
private generateUnionMock(zodSchemaString: string, overrides: Record<string, unknown>): unknown {
const unionMatch = zodSchemaString.match(/z\.union\(\[([^\]]+)\]\)/);
if (!unionMatch || !unionMatch[1]) {
return null;
}
const unionTypes = this.parseUnionTypes(unionMatch[1]);
if (unionTypes.length === 0) {
return null;
}
const randomType = unionTypes[Math.floor(Math.random() * unionTypes.length)];
if (!randomType) {
return null;
}
return this.generateFromSchemaString(randomType, overrides);
}
private parseUnionTypes(unionString: string): string[] {
const types: string[] = [];
let depth = 0;
let current = '';
for (let i = 0; i < unionString.length; i++) {
const char = unionString[i];
if (char === '(') {
depth++;
} else if (char === ')') {
depth--;
} else if (char === ',' && depth === 0) {
types.push(current.trim());
current = '';
continue;
}
current += char;
}
if (current.trim()) {
types.push(current.trim());
}
return types;
}
private generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
private generateEmail(): string {
const firstNames = ZodMockGenerator.FIRST_NAMES;
const lastNames = ZodMockGenerator.LAST_NAMES;
const domains = ZodMockGenerator.EMAIL_DOMAINS;
const firstName = firstNames[Math.floor(Math.random() * firstNames.length)] || 'user';
const lastName = lastNames[Math.floor(Math.random() * lastNames.length)] || 'example';
const domain = domains[Math.floor(Math.random() * domains.length)] || 'example.com';
return `${firstName.toLowerCase()}.${lastName.toLowerCase()}@${domain}`;
}
private generateURL(): string {
const protocols = ['https', 'http'];
const domains = ['example.com', 'test.org', 'demo.net', 'api.sample.io'];
const protocol = protocols[Math.floor(Math.random() * protocols.length)];
const domain = domains[Math.floor(Math.random() * domains.length)];
return `${protocol}://${domain}`;
}
private generateDate(): string {
const start = new Date(2020, 0, 1);
const end = new Date();
const randomDate = new Date(
start.getTime() + Math.random() * (end.getTime() - start.getTime())
);
return randomDate.toISOString().split('T')[0] as string;
}
private generateDateTime(): string {
const start = new Date(2020, 0, 1);
const end = new Date();
const randomDate = new Date(
start.getTime() + Math.random() * (end.getTime() - start.getTime())
);
return randomDate.toISOString();
}
private generateFromRegex(zodSchemaString: string): string {
const regexMatch = zodSchemaString.match(/\.regex\(\/([^/]+)\/\)/);
if (!regexMatch) {
return this.generateRandomString(10);
}
const pattern = regexMatch[1];
if (pattern?.includes('\\+?[1-9]\\d{1,14}')) {
return `+1${Math.floor(Math.random() * 9000000000) + 1000000000}`;
}
return this.generateRandomString(10);
}
private generateRandomString(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
}
function generateZodSchemaInternal(schema: ExtendedSchema, options: ZodGeneratorOptions): string {
if (!schema || typeof schema !== 'object') {
return 'z.unknown()';
}
if (schema.anyOf && Array.isArray(schema.anyOf)) {
const enumValues = schema.anyOf
.filter((item) => item && typeof item === 'object' && 'const' in item)
.map((item) => (item as { const: JsonValue }).const);
if (enumValues.length > 0) {
const zodEnumValues = enumValues.map((val) => JSON.stringify(val)).join(', ');
return `z.enum([${zodEnumValues}])`;
}
}
if (schema.enum && Array.isArray(schema.enum)) {
const zodEnumValues = schema.enum.map((val) => JSON.stringify(val)).join(', ');
return `z.enum([${zodEnumValues}])`;
}
if (Array.isArray(schema.type)) {
const types = schema.type.filter((t) => t !== 'null');
const isNullable = schema.type.includes('null');
if (types.length > 0 && types[0]) {
let zodType = generateZodForSingleType(types[0], schema, options);
if (isNullable) {
zodType += '.nullable()';
}
return zodType;
} else if (types.length > 1) {
const unionTypes = types.map((t) => generateZodForSingleType(t, schema, options));
let zodType = `z.union([${unionTypes.join(', ')}])`;
if (isNullable) {
zodType += '.nullable()';
}
return zodType;
}
}
if (typeof schema.type === 'string') {
return generateZodForSingleType(schema.type, schema, options);
}
if (schema.properties && !schema.type) {
return generateZodObject(schema, options);
}
if (schema.items && !schema.type) {
return generateZodArray(schema, options);
}
return 'z.unknown()';
}
function generateZodForSingleType(
type: string,
schema: ExtendedSchema,
options: ZodGeneratorOptions
): string {
switch (type) {
case 'string':
return generateZodString(schema);
case 'number':
return generateZodNumber(schema);
case 'integer':
return generateZodInteger(schema);
case 'boolean':
return 'z.boolean()';
case 'null':
return 'z.null()';
case 'array':
return generateZodArray(schema, options);
case 'object':
return generateZodObject(schema, options);
default:
return 'z.unknown()';
}
}
function generateZodString(schema: ExtendedSchema): string {
let zodType = 'z.string()';
if (schema.format) {
switch (schema.format) {
case 'uuid':
zodType += '.uuid()';
break;
case 'email':
zodType += '.email()';
break;
case 'uri':
case 'url':
zodType += '.url()';
break;
case 'date':
zodType += '.date()';
break;
case 'date-time':
zodType += '.datetime()';
break;
case 'phone':
zodType += '.regex(/^\\+?[1-9]\\d{1,14}$/)';
break;
}
}
if (schema.pattern) {
zodType += `.regex(/${schema.pattern}/)`;
}
if (typeof schema.minLength === 'number') {
zodType += `.min(${schema.minLength})`;
}
if (typeof schema.maxLength === 'number') {
zodType += `.max(${schema.maxLength})`;
}
return zodType;
}
function generateZodNumber(schema: ExtendedSchema): string {
let zodType = 'z.number()';
if (typeof schema.minimum === 'number') {
zodType += `.min(${schema.minimum})`;
}
if (typeof schema.maximum === 'number') {
zodType += `.max(${schema.maximum})`;
}
if (typeof schema.exclusiveMinimum === 'number') {
zodType += `.gt(${schema.exclusiveMinimum})`;
}
if (typeof schema.exclusiveMaximum === 'number') {
zodType += `.lt(${schema.exclusiveMaximum})`;
}
return zodType;
}
function generateZodInteger(schema: ExtendedSchema): string {
let zodType = 'z.number().int()';
if (typeof schema.minimum === 'number') {
zodType += `.min(${schema.minimum})`;
}
if (typeof schema.maximum === 'number') {
zodType += `.max(${schema.maximum})`;
}
if (typeof schema.exclusiveMinimum === 'number') {
zodType += `.gt(${schema.exclusiveMinimum})`;
}
if (typeof schema.exclusiveMaximum === 'number') {
zodType += `.lt(${schema.exclusiveMaximum})`;
}
return zodType;
}
function generateZodArray(schema: ExtendedSchema, options: ZodGeneratorOptions): string {
let itemType = 'z.unknown()';
if (schema.items) {
if (Array.isArray(schema.items)) {
const tupleTypes = schema.items.map((item) =>
generateZodSchemaInternal(item as ExtendedSchema, options)
);
return `z.tuple([${tupleTypes.join(', ')}])`;
} else {
itemType = generateZodSchemaInternal(schema.items as ExtendedSchema, options);
}
}
let zodType = `z.array(${itemType})`;
if (typeof schema.minItems === 'number') {
zodType += `.min(${schema.minItems})`;
}
if (typeof schema.maxItems === 'number') {
zodType += `.max(${schema.maxItems})`;
}
return zodType;
}
function generateZodObject(schema: ExtendedSchema, options: ZodGeneratorOptions): string {
if (!schema.properties) {
return 'z.object({})';
}
const properties: string[] = [];
const required = new Set(schema.required || []);
for (const [key, propSchema] of Object.entries(schema.properties)) {
const safePropName = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key) ? key : `"${key}"`;
let propType = generateZodSchemaInternal(propSchema as ExtendedSchema, options);
if (!required.has(key)) {
propType += '.optional()';
}
properties.push(`${safePropName}: ${propType}`);
}
let zodType = `z.object({\n ${properties.join(',\n ')}\n})`;
if (schema.additionalProperties === false) {
zodType += '.strict()';
} else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
const additionalType = generateZodSchemaInternal(
schema.additionalProperties as ExtendedSchema,
options
);
zodType += `.catchall(${additionalType})`;
}
return zodType;
}