673 lines
18 KiB
TypeScript
673 lines
18 KiB
TypeScript
/**
|
|
* JSON Schema to Effect Schema Converter
|
|
*
|
|
* Converts JSON Schema to Effect Schema TypeScript code with proper
|
|
* handling of recursive types using S.suspend() and interface definitions.
|
|
*/
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
type JsonSchema = {
|
|
$ref?: string;
|
|
$defs?: Record<string, JsonSchema>;
|
|
definitions?: Record<string, JsonSchema>;
|
|
type?: string | Array<string>;
|
|
properties?: Record<string, JsonSchema>;
|
|
required?: Array<string>;
|
|
items?: JsonSchema;
|
|
enum?: Array<unknown>;
|
|
const?: unknown;
|
|
oneOf?: Array<JsonSchema>;
|
|
anyOf?: Array<JsonSchema>;
|
|
allOf?: Array<JsonSchema>;
|
|
additionalProperties?: boolean | JsonSchema;
|
|
title?: string;
|
|
description?: string;
|
|
default?: unknown;
|
|
nullable?: boolean;
|
|
};
|
|
|
|
type TypeDefinition = {
|
|
name: string;
|
|
schema: JsonSchema;
|
|
isRecursive: boolean;
|
|
dependencies: Set<string>;
|
|
};
|
|
|
|
type ConversionContext = {
|
|
definitions: Map<string, TypeDefinition>;
|
|
recursiveTypes: Set<string>;
|
|
currentPath: Array<string>;
|
|
};
|
|
|
|
// ============================================================================
|
|
// Utilities
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Convert a string to PascalCase for type names
|
|
*/
|
|
function toPascalCase(str: string): string {
|
|
return str
|
|
.split(/[-_\s]+/)
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join('');
|
|
}
|
|
|
|
/**
|
|
* Extract the definition name from a $ref
|
|
*/
|
|
function getRefName(ref: string): string {
|
|
const parts = ref.split('/');
|
|
return parts.at(-1) ?? '';
|
|
}
|
|
|
|
/**
|
|
* Check if a schema is nullable (has null in type array, nullable: true, or null in oneOf/anyOf)
|
|
*/
|
|
function isNullable(schema: JsonSchema): boolean {
|
|
if (schema.nullable) return true;
|
|
if (Array.isArray(schema.type) && schema.type.includes('null')) return true;
|
|
// Check for null in oneOf/anyOf
|
|
const items = schema.oneOf || schema.anyOf;
|
|
if (items) {
|
|
return items.some(
|
|
(s) => s.type === 'null' || ('const' in s && s.const === null),
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get the non-null type from a schema
|
|
*/
|
|
function getNonNullType(schema: JsonSchema): string | undefined {
|
|
if (Array.isArray(schema.type)) {
|
|
const nonNull = schema.type.filter((t) => t !== 'null');
|
|
return nonNull.length === 1 ? nonNull[0] : undefined;
|
|
}
|
|
return schema.type;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Schema Analysis
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Collect all definitions from the schema
|
|
*/
|
|
function collectDefinitions(schema: JsonSchema): Map<string, TypeDefinition> {
|
|
const definitions = new Map<string, TypeDefinition>();
|
|
const defs = schema.$defs || schema.definitions || {};
|
|
|
|
for (const [name, defSchema] of Object.entries(defs)) {
|
|
definitions.set(name, {
|
|
name: toPascalCase(name),
|
|
schema: defSchema,
|
|
isRecursive: false,
|
|
dependencies: new Set(),
|
|
});
|
|
}
|
|
|
|
return definitions;
|
|
}
|
|
|
|
/**
|
|
* Find all $ref dependencies in a schema
|
|
*/
|
|
function findDependencies(schema: JsonSchema): Set<string> {
|
|
const deps = new Set<string>();
|
|
|
|
function traverse(s: JsonSchema): void {
|
|
if (s.$ref) {
|
|
deps.add(getRefName(s.$ref));
|
|
}
|
|
|
|
if (s.properties) {
|
|
for (const propSchema of Object.values(s.properties)) {
|
|
traverse(propSchema);
|
|
}
|
|
}
|
|
|
|
if (s.items) {
|
|
traverse(s.items);
|
|
}
|
|
|
|
if (s.oneOf) {
|
|
for (const item of s.oneOf) {
|
|
traverse(item);
|
|
}
|
|
}
|
|
|
|
if (s.anyOf) {
|
|
for (const item of s.anyOf) {
|
|
traverse(item);
|
|
}
|
|
}
|
|
|
|
if (s.allOf) {
|
|
for (const item of s.allOf) {
|
|
traverse(item);
|
|
}
|
|
}
|
|
|
|
if (typeof s.additionalProperties === 'object' && s.additionalProperties) {
|
|
traverse(s.additionalProperties);
|
|
}
|
|
}
|
|
|
|
traverse(schema);
|
|
return deps;
|
|
}
|
|
|
|
/**
|
|
* Detect recursive types using DFS cycle detection
|
|
*/
|
|
function detectRecursiveTypes(
|
|
definitions: Map<string, TypeDefinition>,
|
|
): Set<string> {
|
|
const recursiveTypes = new Set<string>();
|
|
|
|
// Build dependency graph
|
|
for (const [, def] of definitions) {
|
|
def.dependencies = findDependencies(def.schema);
|
|
}
|
|
|
|
// DFS to detect cycles
|
|
function hasCycle(
|
|
name: string,
|
|
visiting: Set<string>,
|
|
visited: Set<string>,
|
|
): boolean {
|
|
if (visiting.has(name)) {
|
|
return true;
|
|
}
|
|
if (visited.has(name)) {
|
|
return false;
|
|
}
|
|
|
|
visiting.add(name);
|
|
|
|
const def = definitions.get(name);
|
|
if (def) {
|
|
for (const dep of def.dependencies) {
|
|
if (hasCycle(dep, visiting, visited)) {
|
|
recursiveTypes.add(name);
|
|
recursiveTypes.add(dep);
|
|
}
|
|
}
|
|
}
|
|
|
|
visiting.delete(name);
|
|
visited.add(name);
|
|
return false;
|
|
}
|
|
|
|
// Check each definition for cycles
|
|
for (const name of definitions.keys()) {
|
|
hasCycle(name, new Set(), new Set());
|
|
}
|
|
|
|
// Also check for direct self-references
|
|
for (const [name, def] of definitions) {
|
|
if (def.dependencies.has(name)) {
|
|
recursiveTypes.add(name);
|
|
def.isRecursive = true;
|
|
}
|
|
}
|
|
|
|
// Mark all recursive types
|
|
for (const name of recursiveTypes) {
|
|
const def = definitions.get(name);
|
|
if (def) {
|
|
def.isRecursive = true;
|
|
}
|
|
}
|
|
|
|
return recursiveTypes;
|
|
}
|
|
|
|
/**
|
|
* Topologically sort definitions (dependencies first)
|
|
*/
|
|
function sortDefinitions(
|
|
definitions: Map<string, TypeDefinition>,
|
|
recursiveTypes: Set<string>,
|
|
): Array<TypeDefinition> {
|
|
const sorted: Array<TypeDefinition> = [];
|
|
const visited = new Set<string>();
|
|
|
|
function visit(name: string): void {
|
|
if (visited.has(name)) return;
|
|
visited.add(name);
|
|
|
|
const def = definitions.get(name);
|
|
if (!def) return;
|
|
|
|
// Visit non-recursive dependencies first
|
|
for (const dep of def.dependencies) {
|
|
if (!(recursiveTypes.has(dep) && recursiveTypes.has(name))) {
|
|
visit(dep);
|
|
}
|
|
}
|
|
|
|
sorted.push(def);
|
|
}
|
|
|
|
for (const name of definitions.keys()) {
|
|
visit(name);
|
|
}
|
|
|
|
return sorted;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Code Generation
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Generate TypeScript type for a JSON Schema (used for recursive type declarations)
|
|
*/
|
|
function generateTsType(schema: JsonSchema, _ctx: ConversionContext): string {
|
|
if (schema.$ref) {
|
|
const refName = getRefName(schema.$ref);
|
|
const pascalName = toPascalCase(refName);
|
|
return pascalName;
|
|
}
|
|
|
|
const type = getNonNullType(schema);
|
|
|
|
if (schema.enum) {
|
|
return schema.enum.map((v) => JSON.stringify(v)).join(' | ');
|
|
}
|
|
|
|
if (schema.const !== undefined) {
|
|
return JSON.stringify(schema.const);
|
|
}
|
|
|
|
if (schema.oneOf || schema.anyOf) {
|
|
const items = schema.oneOf || schema.anyOf || [];
|
|
const types = items
|
|
.filter((s) => s.type !== 'null' && !('const' in s && s.const === null))
|
|
.map((s) => generateTsType(s, _ctx));
|
|
return types.join(' | ');
|
|
}
|
|
|
|
switch (type) {
|
|
case 'string':
|
|
return 'string';
|
|
case 'number':
|
|
case 'integer':
|
|
return 'number';
|
|
case 'boolean':
|
|
return 'boolean';
|
|
case 'null':
|
|
return 'null';
|
|
case 'array':
|
|
if (schema.items) {
|
|
return `ReadonlyArray<${generateTsType(schema.items, _ctx)}>`;
|
|
}
|
|
return 'ReadonlyArray<unknown>';
|
|
case 'object':
|
|
if (schema.properties) {
|
|
const props = Object.entries(schema.properties)
|
|
.map(([key, propSchema]) => {
|
|
const isRequired = schema.required?.includes(key) ?? false;
|
|
const nullable = isNullable(propSchema);
|
|
const tsType = generateTsType(propSchema, _ctx);
|
|
const nullSuffix = nullable ? ' | null' : '';
|
|
const optionalMark = isRequired ? '' : '?';
|
|
return ` readonly "${key}"${optionalMark}: ${tsType}${nullSuffix}`;
|
|
})
|
|
.join(';\n');
|
|
return `{\n${props}\n}`;
|
|
}
|
|
if (
|
|
schema.additionalProperties === true ||
|
|
typeof schema.additionalProperties === 'object'
|
|
) {
|
|
const valueType =
|
|
typeof schema.additionalProperties === 'object'
|
|
? generateTsType(schema.additionalProperties, _ctx)
|
|
: 'unknown';
|
|
return `Record<string, ${valueType}>`;
|
|
}
|
|
return 'Record<string, unknown>';
|
|
default:
|
|
return 'unknown';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate TypeScript interface/type declaration for a recursive type
|
|
*/
|
|
function generateTypeDeclaration(
|
|
name: string,
|
|
schema: JsonSchema,
|
|
ctx: ConversionContext,
|
|
): string {
|
|
const tsType = generateTsType(schema, ctx);
|
|
if (tsType.startsWith('{')) {
|
|
return `interface ${name} ${tsType}`;
|
|
}
|
|
return `type ${name} = ${tsType}`;
|
|
}
|
|
|
|
/**
|
|
* Generate Effect Schema code for a JSON Schema
|
|
*/
|
|
function generateEffectSchema(
|
|
schema: JsonSchema,
|
|
ctx: ConversionContext,
|
|
): string {
|
|
// Handle $ref
|
|
if (schema.$ref) {
|
|
const refName = getRefName(schema.$ref);
|
|
const pascalName = toPascalCase(refName);
|
|
|
|
// Check if this is a recursive reference - use S.suspend with explicit return type
|
|
if (ctx.recursiveTypes.has(refName)) {
|
|
return `S.suspend((): S.Schema<${pascalName}> => ${pascalName})`;
|
|
}
|
|
return pascalName;
|
|
}
|
|
|
|
// Handle nullable types
|
|
const nullable = isNullable(schema);
|
|
const type = getNonNullType(schema);
|
|
|
|
// Handle enum
|
|
if (schema.enum) {
|
|
const literals = schema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
const result = `S.Literal(${literals})`;
|
|
return nullable ? `S.NullOr(${result})` : result;
|
|
}
|
|
|
|
// Handle const
|
|
if (schema.const !== undefined) {
|
|
const result = `S.Literal(${JSON.stringify(schema.const)})`;
|
|
return nullable ? `S.NullOr(${result})` : result;
|
|
}
|
|
|
|
// Handle oneOf / anyOf (union)
|
|
if (schema.oneOf || schema.anyOf) {
|
|
const items = schema.oneOf || schema.anyOf || [];
|
|
const nonNullItems = items.filter(
|
|
(s) => s.type !== 'null' && !('const' in s && s.const === null),
|
|
);
|
|
|
|
if (nonNullItems.length === 0) {
|
|
return 'S.Null';
|
|
}
|
|
|
|
if (nonNullItems.length === 1) {
|
|
const firstItem = nonNullItems[0];
|
|
if (!firstItem) return 'S.Unknown';
|
|
const innerSchema = generateEffectSchema(firstItem, ctx);
|
|
const hasNull = items.some(
|
|
(s) => s.type === 'null' || ('const' in s && s.const === null),
|
|
);
|
|
return hasNull ? `S.NullOr(${innerSchema})` : innerSchema;
|
|
}
|
|
|
|
const members = nonNullItems
|
|
.map((s) => generateEffectSchema(s, ctx))
|
|
.join(', ');
|
|
const hasNull = items.some(
|
|
(s) => s.type === 'null' || ('const' in s && s.const === null),
|
|
);
|
|
return hasNull ? `S.Union(${members}, S.Null)` : `S.Union(${members})`;
|
|
}
|
|
|
|
// Handle allOf (intersection - we merge properties)
|
|
if (schema.allOf) {
|
|
// For simplicity, merge all schemas' properties
|
|
const merged: JsonSchema = { type: 'object', properties: {}, required: [] };
|
|
for (const item of schema.allOf) {
|
|
if (item.properties) {
|
|
merged.properties = { ...merged.properties, ...item.properties };
|
|
}
|
|
if (item.required) {
|
|
merged.required = [...(merged.required || []), ...item.required];
|
|
}
|
|
}
|
|
return generateEffectSchema(merged, ctx);
|
|
}
|
|
|
|
// Handle by type
|
|
switch (type) {
|
|
case 'string': {
|
|
const result = 'S.String';
|
|
return nullable ? `S.NullOr(${result})` : result;
|
|
}
|
|
case 'number':
|
|
case 'integer': {
|
|
const result = 'S.Number';
|
|
return nullable ? `S.NullOr(${result})` : result;
|
|
}
|
|
case 'boolean': {
|
|
const result = 'S.Boolean';
|
|
return nullable ? `S.NullOr(${result})` : result;
|
|
}
|
|
case 'null':
|
|
return 'S.Null';
|
|
case 'array': {
|
|
const itemSchema = schema.items
|
|
? generateEffectSchema(schema.items, ctx)
|
|
: 'S.Unknown';
|
|
const result = `S.Array(${itemSchema})`;
|
|
return nullable ? `S.NullOr(${result})` : result;
|
|
}
|
|
case 'object': {
|
|
if (schema.properties) {
|
|
const props = Object.entries(schema.properties)
|
|
.map(([key, propSchema]) => {
|
|
const isRequired = schema.required?.includes(key) ?? false;
|
|
const propNullable = isNullable(propSchema);
|
|
let propCode = generateEffectSchema(propSchema, ctx);
|
|
|
|
// Wrap in optional if not required
|
|
if (!isRequired) {
|
|
if (propNullable && !propCode.startsWith('S.NullOr(')) {
|
|
propCode = `S.NullOr(${propCode})`;
|
|
}
|
|
propCode = `S.optional(${propCode})`;
|
|
}
|
|
|
|
return ` "${key}": ${propCode}`;
|
|
})
|
|
.join(',\n');
|
|
const result = `S.Struct({\n${props}\n})`;
|
|
return nullable ? `S.NullOr(${result})` : result;
|
|
}
|
|
if (
|
|
schema.additionalProperties === true ||
|
|
typeof schema.additionalProperties === 'object'
|
|
) {
|
|
const valueSchema =
|
|
typeof schema.additionalProperties === 'object'
|
|
? generateEffectSchema(schema.additionalProperties, ctx)
|
|
: 'S.Unknown';
|
|
const result = `S.Record({ key: S.String, value: ${valueSchema} })`;
|
|
return nullable ? `S.NullOr(${result})` : result;
|
|
}
|
|
// Empty object or any object
|
|
const result = 'S.Record({ key: S.String, value: S.Unknown })';
|
|
return nullable ? `S.NullOr(${result})` : result;
|
|
}
|
|
default:
|
|
// Unknown or any type
|
|
return nullable ? 'S.NullOr(S.Unknown)' : 'S.Unknown';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate non-recursive schema definition (schema + derived type)
|
|
*/
|
|
function generateNonRecursiveDefinition(
|
|
def: TypeDefinition,
|
|
ctx: ConversionContext,
|
|
): string {
|
|
const lines: Array<string> = [];
|
|
const { name, schema } = def;
|
|
|
|
// Add description as JSDoc comment
|
|
if (schema.description) {
|
|
lines.push(`/** ${schema.description} */`);
|
|
}
|
|
|
|
// Check if it's an enum type
|
|
if (schema.enum) {
|
|
const literals = schema.enum.map((v) => JSON.stringify(v)).join(',\n ');
|
|
lines.push(`export const ${name} = S.Literal(\n ${literals}\n);`);
|
|
lines.push(`export type ${name} = S.Schema.Type<typeof ${name}>;`);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// Generate the schema
|
|
const schemaCode = generateEffectSchema(schema, ctx);
|
|
lines.push(`export const ${name} = ${schemaCode};`);
|
|
lines.push(`export type ${name} = S.Schema.Type<typeof ${name}>;`);
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Generate recursive schema definition (schema with type assertion to bypass structural checks)
|
|
*/
|
|
function generateRecursiveSchemaDefinition(
|
|
def: TypeDefinition,
|
|
ctx: ConversionContext,
|
|
): string {
|
|
const lines: Array<string> = [];
|
|
const { name, schema } = def;
|
|
|
|
// Add description as JSDoc comment
|
|
if (schema.description) {
|
|
lines.push(`/** ${schema.description} */`);
|
|
}
|
|
|
|
// Generate the schema with double type assertion to bypass structural checking
|
|
const schemaCode = generateEffectSchema(schema, ctx);
|
|
lines.push(
|
|
`export const ${name} = ${schemaCode} as unknown as S.Schema<${name}>;`,
|
|
);
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main Conversion Function
|
|
// ============================================================================
|
|
|
|
export type ConvertOptions = {
|
|
/** The JSON Schema to convert */
|
|
schema: JsonSchema;
|
|
/** The name for the root type (if the schema has no $ref) */
|
|
rootName?: string;
|
|
};
|
|
|
|
export type ConvertResult = {
|
|
/** The generated TypeScript code */
|
|
code: string;
|
|
/** Names of all generated types */
|
|
typeNames: Array<string>;
|
|
/** Names of recursive types */
|
|
recursiveTypes: Array<string>;
|
|
};
|
|
|
|
/**
|
|
* Convert a JSON Schema to Effect Schema TypeScript code
|
|
*/
|
|
export function convert(options: ConvertOptions): ConvertResult {
|
|
const { schema, rootName = 'Root' } = options;
|
|
|
|
// Collect all definitions
|
|
const definitions = collectDefinitions(schema);
|
|
|
|
// If the root schema is not a $ref, add it as a definition
|
|
if (!schema.$ref && (schema.type === 'object' || schema.properties)) {
|
|
definitions.set(rootName, {
|
|
name: toPascalCase(rootName),
|
|
schema,
|
|
isRecursive: false,
|
|
dependencies: new Set(),
|
|
});
|
|
}
|
|
|
|
// Detect recursive types
|
|
const recursiveTypes = detectRecursiveTypes(definitions);
|
|
|
|
// Create conversion context
|
|
const ctx: ConversionContext = {
|
|
definitions,
|
|
recursiveTypes,
|
|
currentPath: [],
|
|
};
|
|
|
|
// Sort definitions topologically
|
|
const sorted = sortDefinitions(definitions, recursiveTypes);
|
|
|
|
// Split into non-recursive and recursive definitions
|
|
const nonRecursive = sorted.filter((d) => !recursiveTypes.has(d.name));
|
|
const recursive = sorted.filter((d) => recursiveTypes.has(d.name));
|
|
|
|
// Generate code
|
|
const lines: Array<string> = [
|
|
'// This file is auto-generated. Do not edit manually.',
|
|
"import * as S from 'effect/Schema';",
|
|
'',
|
|
];
|
|
|
|
// 1. Generate non-recursive definitions (schema + type)
|
|
for (const def of nonRecursive) {
|
|
lines.push(generateNonRecursiveDefinition(def, ctx));
|
|
lines.push('');
|
|
}
|
|
|
|
// 2. Generate type declarations for recursive types (before schemas)
|
|
if (recursive.length > 0) {
|
|
lines.push('// Recursive type declarations');
|
|
for (const def of recursive) {
|
|
lines.push(generateTypeDeclaration(def.name, def.schema, ctx));
|
|
}
|
|
lines.push('');
|
|
|
|
// 3. Generate schema definitions for recursive types
|
|
lines.push('// Recursive schema definitions');
|
|
for (const def of recursive) {
|
|
lines.push(generateRecursiveSchemaDefinition(def, ctx));
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
// Handle root $ref
|
|
if (schema.$ref) {
|
|
const refName = toPascalCase(getRefName(schema.$ref));
|
|
const exportName = toPascalCase(rootName);
|
|
if (refName !== exportName) {
|
|
lines.push(`export { ${refName} as ${exportName} };`);
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
return {
|
|
code: lines.join('\n'),
|
|
typeNames: sorted.map((d) => d.name),
|
|
recursiveTypes: Array.from(recursiveTypes).map(toPascalCase),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert a JSON Schema string to Effect Schema TypeScript code
|
|
*/
|
|
export function convertFromString(
|
|
jsonSchemaString: string,
|
|
rootName = 'Root',
|
|
): ConvertResult {
|
|
const schema = JSON.parse(jsonSchemaString) as JsonSchema;
|
|
return convert({ schema, rootName });
|
|
}
|