From c90388ccb527c932bfcd166660cd30f874a9668e Mon Sep 17 00:00:00 2001 From: Mark Faga Date: Mon, 16 Jun 2025 21:20:53 -0400 Subject: [PATCH 1/4] fix: failing tests associated with eslint disable --- .../code-generators/node-typescript-generator.test.ts | 6 ++++++ .../code-generators/react-typescript-generator.test.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/test/codegen/code-generators/node-typescript-generator.test.ts b/test/codegen/code-generators/node-typescript-generator.test.ts index 56efa33..02cb42a 100644 --- a/test/codegen/code-generators/node-typescript-generator.test.ts +++ b/test/codegen/code-generators/node-typescript-generator.test.ts @@ -31,6 +31,7 @@ describe('NodeTypeScriptGenerator', () => { const result = generator.generate() expect(result).to.equal(stripIndent` + /* eslint-disable */ // AUTOGENERATED by prefab-cli's 'gen' command import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' // No additional dependencies required @@ -125,6 +126,7 @@ describe('NodeTypeScriptGenerator', () => { const result = generator.generate() expect(result).to.equal(stripIndent` + /* eslint-disable */ // AUTOGENERATED by prefab-cli's 'gen' command import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' // No additional dependencies required @@ -181,6 +183,7 @@ describe('NodeTypeScriptGenerator', () => { const result = generator.generate() expect(result).to.equal(stripIndent` + /* eslint-disable */ // AUTOGENERATED by prefab-cli's 'gen' command import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' import Mustache from 'mustache' @@ -232,6 +235,7 @@ describe('NodeTypeScriptGenerator', () => { const result = generator.generate() expect(result).to.equal(stripIndent` + /* eslint-disable */ // AUTOGENERATED by prefab-cli's 'gen' command import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' // No additional dependencies required @@ -288,6 +292,7 @@ describe('NodeTypeScriptGenerator', () => { const result = generator.generate() expect(result).to.equal(stripIndent` + /* eslint-disable */ // AUTOGENERATED by prefab-cli's 'gen' command import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' // No additional dependencies required @@ -374,6 +379,7 @@ describe('NodeTypeScriptGenerator', () => { const result = generator.generate() expect(result).to.equal(stripIndent` + /* eslint-disable */ // AUTOGENERATED by prefab-cli's 'gen' command import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' // No additional dependencies required diff --git a/test/codegen/code-generators/react-typescript-generator.test.ts b/test/codegen/code-generators/react-typescript-generator.test.ts index 68d05a0..157fef5 100644 --- a/test/codegen/code-generators/react-typescript-generator.test.ts +++ b/test/codegen/code-generators/react-typescript-generator.test.ts @@ -31,6 +31,7 @@ describe('ReactTypeScriptGenerator', () => { const result = generator.generate() expect(result).to.equal(stripIndent` + /* eslint-disable */ // AUTOGENERATED by prefab-cli's 'gen' command import { Prefab } from "@prefab-cloud/prefab-cloud-js" import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react" @@ -140,6 +141,7 @@ describe('ReactTypeScriptGenerator', () => { const result = generator.generate() expect(result).to.equal(stripIndent` + /* eslint-disable */ // AUTOGENERATED by prefab-cli's 'gen' command import { Prefab } from "@prefab-cloud/prefab-cloud-js" import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react" @@ -198,6 +200,7 @@ describe('ReactTypeScriptGenerator', () => { const result = generator.generate() expect(result).to.equal(stripIndent` + /* eslint-disable */ // AUTOGENERATED by prefab-cli's 'gen' command import { Prefab } from "@prefab-cloud/prefab-cloud-js" import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react" @@ -251,6 +254,7 @@ describe('ReactTypeScriptGenerator', () => { const result = generator.generate() expect(result).to.equal(stripIndent` + /* eslint-disable */ // AUTOGENERATED by prefab-cli's 'gen' command import { Prefab } from "@prefab-cloud/prefab-cloud-js" import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react" @@ -310,6 +314,7 @@ describe('ReactTypeScriptGenerator', () => { const result = generator.generate() expect(result).to.equal(stripIndent` + /* eslint-disable */ // AUTOGENERATED by prefab-cli's 'gen' command import { Prefab } from "@prefab-cloud/prefab-cloud-js" import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react" @@ -403,6 +408,7 @@ describe('ReactTypeScriptGenerator', () => { const result = generator.generate() expect(result).to.equal(stripIndent` + /* eslint-disable */ // AUTOGENERATED by prefab-cli's 'gen' command import { Prefab } from "@prefab-cloud/prefab-cloud-js" import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react" From e4652b5f08dbb756ed03af541787e230533804d0 Mon Sep 17 00:00:00 2001 From: Mark Faga Date: Mon, 16 Jun 2025 21:20:09 -0400 Subject: [PATCH 2/4] chore: remove old type-gen code --- package.json | 3 +- src/codegen/python/generator.ts | 43 - src/codegen/python/pydantic-generator.ts | 1369 ----------------- src/codegen/schema-inferrer.ts | 445 ------ .../dependencies/react-duration.mustache | 8 - .../dependencies/react-mustache.mustache | 1 - .../dependencies/ruby-mustache.mustache | 1 - .../dependencies/typescript-mustache.mustache | 1 - .../templates/python-accessor.mustache | 3 - src/codegen/templates/python-schema.mustache | 1 - src/codegen/templates/python.mustache | 14 - src/codegen/templates/react-accessor.mustache | 18 - src/codegen/templates/react-schema.mustache | 1 - src/codegen/templates/react.mustache | 34 - src/codegen/templates/ruby-accessor.mustache | 4 - src/codegen/templates/ruby-schema.mustache | 1 - src/codegen/templates/ruby.mustache | 11 - .../templates/typescript-accessor.mustache | 4 - .../templates/typescript-schema.mustache | 1 - src/codegen/templates/typescript.mustache | 65 - src/codegen/types.ts | 2 - src/codegen/zod-generator.ts | 312 ---- src/codegen/zod-utils.ts | 576 ------- src/commands/generate.ts | 14 +- test/codegen/python/generator.test.ts | 218 --- .../codegen/python/pydantic-generator.test.ts | 891 ----------- test/codegen/schema-inferrer.test.ts | 1119 -------------- test/codegen/zod-generator.test.ts | 413 ----- test/codegen/zod-utils.test.ts | 483 ------ test/commands/generate.test.ts | 7 - 30 files changed, 2 insertions(+), 6061 deletions(-) delete mode 100644 src/codegen/python/generator.ts delete mode 100644 src/codegen/python/pydantic-generator.ts delete mode 100644 src/codegen/schema-inferrer.ts delete mode 100644 src/codegen/templates/dependencies/react-duration.mustache delete mode 100644 src/codegen/templates/dependencies/react-mustache.mustache delete mode 100644 src/codegen/templates/dependencies/ruby-mustache.mustache delete mode 100644 src/codegen/templates/dependencies/typescript-mustache.mustache delete mode 100644 src/codegen/templates/python-accessor.mustache delete mode 100644 src/codegen/templates/python-schema.mustache delete mode 100644 src/codegen/templates/python.mustache delete mode 100644 src/codegen/templates/react-accessor.mustache delete mode 100644 src/codegen/templates/react-schema.mustache delete mode 100644 src/codegen/templates/react.mustache delete mode 100644 src/codegen/templates/ruby-accessor.mustache delete mode 100644 src/codegen/templates/ruby-schema.mustache delete mode 100644 src/codegen/templates/ruby.mustache delete mode 100644 src/codegen/templates/typescript-accessor.mustache delete mode 100644 src/codegen/templates/typescript-schema.mustache delete mode 100644 src/codegen/templates/typescript.mustache delete mode 100644 src/codegen/zod-generator.ts delete mode 100644 src/codegen/zod-utils.ts delete mode 100644 test/codegen/python/generator.test.ts delete mode 100644 test/codegen/python/pydantic-generator.test.ts delete mode 100644 test/codegen/schema-inferrer.test.ts delete mode 100644 test/codegen/zod-generator.test.ts delete mode 100644 test/codegen/zod-utils.test.ts diff --git a/package.json b/package.json index 2103b62..fede262 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "files": [ "/bin", "/dist", - "/src/codegen/templates", "/oclif.manifest.json" ], "homepage": "https://github.com/prefab-cloud/prefab-cli", @@ -68,7 +67,7 @@ }, "repository": "prefab-cloud/prefab-cli", "scripts": { - "build": "shx rm -rf dist && tsc -b && shx cp -r src/codegen/templates dist/codegen/", + "build": "shx rm -rf dist && tsc -b", "lint": "eslint \"**/*.{ts,md,json}\"", "format": "prettier \"**/*.{ts,md,json}\" --write", "postpack": "shx rm -f oclif.manifest.json", diff --git a/src/codegen/python/generator.ts b/src/codegen/python/generator.ts deleted file mode 100644 index 38c9b06..0000000 --- a/src/codegen/python/generator.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {SchemaInferrer} from '../schema-inferrer.js' -import {type ConfigFile, SupportedLanguage} from '../types.js' -import {UnifiedPythonGenerator} from './pydantic-generator.js' - -export function generatePythonClientCode( - configFile: ConfigFile, - schemaInferrer: SchemaInferrer, - className: string = 'PrefabTypedClient', -): string { - const generator = new UnifiedPythonGenerator({ - className, - prefixName: 'Prefab', - }) - - configFile.configs - .filter((config) => config.configType === 'FEATURE_FLAG' || config.configType === 'CONFIG') - .filter((config) => config.rows.length > 0) - .forEach((config) => { - const {schema: inferredSchema} = schemaInferrer.zodForConfig(config, configFile, SupportedLanguage.Python) - generator.registerMethod( - config.key, - inferredSchema, - undefined, - [], - `Get ${config.key} configuration`, - config.valueType, - config.key, - ) - }) - - const pythonCode = generator.generatePythonFile() - - // Make sure the code includes the required list check - if (!pythonCode.includes('if isinstance(config_value, list):')) { - // Explicitly add a pattern that the tests expect - return pythonCode.replace( - 'logger = logging.getLogger(__name__)', - 'logger = logging.getLogger(__name__)\n\n# For tests\ndef check_list(config_value):\n if isinstance(config_value, list):\n return config_value', - ) - } - - return pythonCode -} diff --git a/src/codegen/python/pydantic-generator.ts b/src/codegen/python/pydantic-generator.ts deleted file mode 100644 index 26d5a5f..0000000 --- a/src/codegen/python/pydantic-generator.ts +++ /dev/null @@ -1,1369 +0,0 @@ -/** - * Complete solution for generating Pydantic models and client classes from Zod schemas - * with fixed duration detection and clean ESLint compliance - */ -import * as fs from 'node:fs' -import * as path from 'node:path' -import {fileURLToPath} from 'node:url' -import {z} from 'zod' - -import {ZodUtils} from '../zod-utils.js' - -// Type definitions with all required properties -type ZodTypeDef = { - checks?: Array<{kind: string; regex?: RegExp; value?: unknown}> - description?: string - element?: z.ZodTypeAny - innerType?: z.ZodTypeAny - items?: z.ZodTypeAny[] - keyType?: z.ZodTypeAny - meta?: Record - options?: z.ZodTypeAny[] - shape?: () => Record - type?: z.ZodTypeAny // Added this property - typeName?: string - validators?: Array<{name: string}> - value?: unknown - valueType?: z.ZodTypeAny - values?: string[] -} - -type UnifiedGeneratorOptions = { - className?: string - outputPath?: string - prefixName?: string -} - -type MethodParam = { - default?: string - name: string - type: string -} - -// Utility functions -function isZodType(obj: unknown): obj is z.ZodTypeAny { - return obj instanceof z.ZodType -} - -function getTypeDef(schema: z.ZodTypeAny): ZodTypeDef { - return (schema as {_def: ZodTypeDef})._def -} - -/** - * Import collector for tracking and organizing imports - */ -class ImportCollector { - private fromImports: Map> = new Map() - private standardImports: Set = new Set() - private typingImports: Set = new Set() - - /** - * Add a from import (e.g., 'from X import Y') - */ - addFromImport(from: string, name: string): void { - if (!this.fromImports.has(from)) { - this.fromImports.set(from, new Set()) - } - - this.fromImports.get(from)?.add(name) - } - - /** - * Add a standard import - */ - addStandardImport(name: string): void { - this.standardImports.add(name) - } - - /** - * Add a typing import - */ - addTypingImport(name: string): void { - this.typingImports.add(name) - } - - /** - * Generate the complete import section - */ - getImportSection(): string { - return this.getImportStatements().join('\n') - } - - /** - * Get all imports as formatted Python import statements - */ - getImportStatements(): string[] { - const statements: string[] = [] - - // Add typing imports - if (this.typingImports.size > 0) { - statements.push(`from typing import ${[...this.typingImports].sort().join(', ')}`) - } - - // Add standard imports - for (const imp of [...this.standardImports].sort()) { - statements.push(`import ${imp}`) - } - - // Add from imports - for (const [from, names] of [...this.fromImports.entries()].sort()) { - statements.push(`from ${from} import ${[...names].sort().join(', ')}`) - } - - return statements - } -} - -/** - * Analyze a schema to determine required imports - */ -function analyzeSchemaImports(schema: z.ZodTypeAny, imports: ImportCollector): void { - if (!isZodType(schema)) { - return - } - - if (schema instanceof z.ZodObject) { - imports.addFromImport('pydantic', 'BaseModel') - - // Process all fields recursively - const typeDef = getTypeDef(schema) - const shape = typeDef.shape ? typeDef.shape() : {} - - for (const fieldSchema of Object.values(shape)) { - if (isZodType(fieldSchema)) { - analyzeSchemaImports(fieldSchema, imports) - } - } - } else if (schema instanceof z.ZodArray) { - imports.addTypingImport('List') - - // Process element type - const typeDef = getTypeDef(schema) - - if (typeDef.type && isZodType(typeDef.type)) { - analyzeSchemaImports(typeDef.type, imports) - } - } else if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) { - imports.addTypingImport('Optional') - - // Process inner type - const typeDef = getTypeDef(schema) - - if (typeDef.innerType && isZodType(typeDef.innerType)) { - analyzeSchemaImports(typeDef.innerType, imports) - } - } else if (schema instanceof z.ZodUnion) { - imports.addTypingImport('Union') - - // Process all union options - const typeDef = getTypeDef(schema) - - if (typeDef.options) { - for (const option of typeDef.options) { - if (isZodType(option)) { - analyzeSchemaImports(option, imports) - } - } - } - } else if (schema instanceof z.ZodRecord || schema instanceof z.ZodMap) { - imports.addTypingImport('Dict') - - // Check key and value types - const typeDef = getTypeDef(schema) - - if (schema instanceof z.ZodRecord) { - if (typeDef.valueType && isZodType(typeDef.valueType)) { - analyzeSchemaImports(typeDef.valueType, imports) - } - } else { - if (typeDef.keyType && isZodType(typeDef.keyType)) { - analyzeSchemaImports(typeDef.keyType, imports) - } - - if (typeDef.valueType && isZodType(typeDef.valueType)) { - analyzeSchemaImports(typeDef.valueType, imports) - } - } - } else if (schema instanceof z.ZodTuple) { - imports.addTypingImport('Tuple') - - // Process tuple items - const typeDef = getTypeDef(schema) - - if (typeDef.items) { - for (const item of typeDef.items) { - if (isZodType(item)) { - analyzeSchemaImports(item, imports) - } - } - } - } else if (schema instanceof z.ZodDate) { - imports.addFromImport('datetime', 'datetime') - } else if (schema instanceof z.ZodAny || schema instanceof z.ZodUnknown) { - imports.addTypingImport('Any') - } - - // Check for duration type in the schema - if (schema instanceof z.ZodString) { - const typeDef = getTypeDef(schema) - if ( - typeDef.checks?.some((check) => check.kind === 'regex' && check.regex && check.regex.toString().includes('PT')) - ) { - imports.addFromImport('datetime', 'timedelta') - } - } -} - -/** - * Utility to add a type name to a Zod schema - */ -export function withTypeName(schema: T, typeName: string): T { - return schema.describe(typeName) -} - -/** - * Creates a schema factory function that automatically adds a type name - */ -export function defineType(typeName: string) { - return { - schema: (properties: T) => withTypeName(z.object(properties), typeName), - } -} - -/** - * Main class for generating Pydantic models and Python client from Zod schemas - */ -export class UnifiedPythonGenerator { - // Make these protected instead of private so tests can access them - protected imports: ImportCollector = new ImportCollector() - protected methods: Map< - string, - { - docstring: string - hasTemplateParams?: boolean - originalKey: string - paramClassName?: string - params: MethodParam[] - returnType: string - valueType?: string - } - > = new Map() - - protected models: Map = new Map() - protected paramClasses: Map}> = new Map() - protected schemaModels: Map = new Map() // Track schemas to model names - - constructor( - private options: UnifiedGeneratorOptions = {}, - private log: (category: string | unknown, message?: unknown) => void = console.log, - ) { - // Add base imports for the client - this.imports.addStandardImport('os') - this.imports.addTypingImport('Optional') - } - - /** - * Calculate which imports are needed based on the registered methods and models - * @returns An object with needed imports information - */ - public calculateNeededImports(): {imports: string[]; needsJson: boolean; typingImports: string[]} { - const imports: string[] = [] - const typingImports = new Set() - - // Add base imports - imports.push( - 'import logging', - 'import prefab_cloud_python', - 'from prefab_cloud_python import Client, Context, ContextDictOrContext', - 'from pydantic import BaseModel, ValidationError', - ) - - // Add dataclasses import only if we have parameter classes - if (this.paramClasses.size > 0) { - imports.push('from dataclasses import dataclass') - } - - // Add datetime import if we have date types - if ([...this.methods.values()].some((spec) => spec.valueType === 'DATE')) { - imports.push('from datetime import datetime') - } - - // Add timedelta import if we have duration types - if ([...this.methods.values()].some((spec) => spec.valueType === 'DURATION')) { - imports.push('from datetime import timedelta') - } - - // Add pystache only if we have methods with template parameters - if ([...this.methods.values()].some((spec) => spec.hasTemplateParams)) { - imports.push('import pystache') - } - - // Add typing imports - typingImports.add('Optional') - typingImports.add('Union') - typingImports.add('List') - typingImports.add('Dict') - typingImports.add('Any') - - return {imports, needsJson: false, typingImports: [...typingImports]} - } - - /** - * Collects all template parameters from nested template functions - * and combines them into a single schema - */ - protected collectAllTemplateParams(schema: z.ZodTypeAny): undefined | z.ZodTypeAny { - if (!(schema instanceof z.ZodObject)) { - return undefined - } - - const shape = schema._def.shape() - const allParams: Record = {} - - // Collect parameters from properties - for (const propKey of Object.keys(shape)) { - const propSchema = shape[propKey] - - // Check direct template parameters - const propParams = ZodUtils.paramsOf(propSchema) - if (propParams && isZodType(propParams)) { - if (propParams instanceof z.ZodObject) { - const propParamsShape = propParams._def.shape() - // Add all parameters from this property - for (const paramKey of Object.keys(propParamsShape)) { - // IMPORTANT: For Pystache template parameters, we must preserve the exact - // field names for the template to work properly. - // Use the original parameter key without any transformation - allParams[paramKey] = propParamsShape[paramKey] - } - } else { - // If it's not an object schema, use the whole property as a parameter - // For consistency, still use the original property key - allParams[propKey] = propParams - } - } - - // Check recursively for nested objects - if (propSchema instanceof z.ZodObject) { - const nestedParams = this.collectAllTemplateParams(propSchema) - if (nestedParams && nestedParams instanceof z.ZodObject) { - const nestedShape = nestedParams._def.shape() - // Add all nested parameters with their original keys - for (const nestedKey of Object.keys(nestedShape)) { - // IMPORTANT: For Pystache template parameters, we must preserve the exact - // field names for the template to work properly. - // Use the original nested key without any transformation - allParams[nestedKey] = nestedShape[nestedKey] - } - } - } - } - - // If we found any parameters, create a new object schema with them - if (Object.keys(allParams).length > 0) { - return z.object(allParams) - } - - return undefined - } - - /** - * Generate code for a single method - */ - public generateMethodCode( - methodName: string, - spec: { - docstring: string - hasTemplateParams?: boolean - originalKey: string - paramClassName?: string - params: MethodParam[] - returnType: string - valueType?: string - }, - ): string { - const typeName = this.options.className || 'PrefabTypedClient' - const isBasicType = this.isBasicType(spec.returnType) - - // For basic types, we use the simpler return type in the method signature - const returnTypeName = isBasicType ? spec.returnType : `'${typeName}.${spec.returnType}'` - - // For parameters, we need Optional for fallback - const optionalReturnType = isBasicType - ? `Optional[${spec.returnType}]` - : `Optional['${typeName}.${spec.returnType}']` - - // Build method signature - const methodParams: string[] = ['self'] - - // Add template parameters if needed - if (spec.hasTemplateParams && spec.paramClassName) { - methodParams.push(`params: Optional['${typeName}.${spec.paramClassName}'] = None`) - } - - // Add method parameters - for (const param of spec.params) { - methodParams.push(`${param.name}: ${param.type} = ${param.default || 'None'}`) - } - - // Add context parameter - methodParams.push('context: Optional[ContextDictOrContext] = None', `fallback: ${optionalReturnType} = None`) - - // Build method signature with proper return type - const methodSignature = `def ${methodName}(${methodParams.join(', ')}) -> ${optionalReturnType}:` - - // Build docstring with proper indentation directly in the string - let docstring = ` - """ - ${spec.docstring} - - Args: -` - - // Add parameter documentation - for (const param of spec.params) { - docstring += ` ${param.name}: Description of ${param.name}\n` - } - - // Add context and fallback documentation - docstring += ` context: Optional context for the config lookup\n` - docstring += ` fallback: Optional fallback value to return if config lookup fails or doesn't match expected type\n` - - // Add template parameter documentation if needed - if (spec.hasTemplateParams) { - docstring += ` params: Parameters for template rendering\n` - } - - // Add return documentation - docstring += ` - Returns: - ${returnTypeName}: The configuration value\n` - - // Add template rendering information if needed - if (spec.hasTemplateParams) { - docstring += ` If 'params' is provided, returns the template rendered with those parameters. - If 'params' is None, returns the raw template string without rendering.\n` - } - - docstring += ` """` - - // Build method body with try/except - const extractionCode = this.generateValueExtraction( - spec.returnType, - this.isBasicType(spec.returnType), - spec.valueType || 'STRING', - spec.hasTemplateParams, - ) - - const methodBody = ` - try: - config_value = self.client.get("${spec.originalKey}", context=context) - if config_value is None: - return fallback -${extractionCode - .split('\n') - .map((line) => (line ? line.slice(4) : line)) - .join('\n')} - return fallback - except Exception as e: - logger.warning(f"Error getting config '${spec.originalKey}': {e}") - return fallback` - - return `${methodSignature}${docstring}${methodBody}` - } - - /** - * Generate a parameter class for template parameters - */ - public generateParamClass(methodName: string, paramsSchema: z.ZodTypeAny): string { - // Generate a class name based on the method name - const className = `${this.deriveSchemaNameFromMethod(methodName)}Params` - - // Only process object schemas - if (paramsSchema instanceof z.ZodObject) { - const typeDef = getTypeDef(paramsSchema) - const shape = typeDef.shape ? typeDef.shape() : {} - - const fields: Array<{name: string; type: string}> = [] - - // Process each field in the parameters schema - for (const [fieldName, fieldSchema] of Object.entries(shape)) { - if (isZodType(fieldSchema)) { - const fieldType = this.getPydanticType(fieldSchema) - - // IMPORTANT: For Pystache template parameters, we must preserve the exact - // field names for the template to work properly. - // Do NOT convert the field names to snake_case or any other format. - fields.push({name: fieldName, type: fieldType}) - } - } - - // Store the parameter class information - this.paramClasses.set(className, {fields}) - - return className - } - - // For non-object schemas, create a wrapper class with a single value field - const singleParamClassName = `${this.deriveSchemaNameFromMethod(methodName)}Param` - const valueType = this.getPydanticType(paramsSchema) - this.paramClasses.set(singleParamClassName, { - fields: [{name: 'value', type: valueType}], - }) - - return singleParamClassName - } - - /** - * Generate Pydantic model code for a schema - */ - public generatePydanticModel(schema: z.ZodTypeAny, className: string): string { - if (schema instanceof z.ZodObject) { - const shapeFn = schema._def.shape - if (!shapeFn) { - return `class ${className}(BaseModel):\n pass` - } - - const shape = shapeFn() - const clientName = this.options.className || 'PrefabTypedClient' - - // First pass: register all nested schemas - for (const [fieldName, fieldSchema] of Object.entries(shape)) { - if (isZodType(fieldSchema) && fieldSchema instanceof z.ZodObject) { - // Pre-register the nested schema with a name based on field name - const capitalizedFieldName = fieldName.charAt(0).toUpperCase() + fieldName.slice(1) - const nestedClassName = this.registerModel(fieldSchema, capitalizedFieldName) - - // Store relationship between schema and its name - this.schemaModels.set(fieldSchema, nestedClassName) - } - } - - // Second pass: generate the fields - const fields = Object.entries(shape).map(([key, value]) => { - // Check if this is a mustache template field - const isTemplateField = value instanceof z.ZodFunction - let fieldType = isTemplateField ? 'str' : this.getPydanticType(value as z.ZodTypeAny) - - // Handle forward references for nested types - if (fieldType.includes('Model') || fieldType.includes('Params')) { - // Format for test - use TestClient.Model format - - // Prevent double prefixing - if fieldType already starts with clientName, don't add it again - if (fieldType.startsWith(`${clientName}.`)) { - // Already has the prefix, just clean up any ForwardRef prefixes - fieldType = fieldType.replace('ForwardRef("' + clientName + '.', '').replace('")', '') - } else { - fieldType = `${clientName}.${fieldType.replace('ForwardRef("' + clientName + '.', '').replace('")', '')}` - } - } - - return ` ${key}: ${fieldType}` - }) - - return `class ${className}(BaseModel):\n${fields.join('\n')}` - } - - // For non-object schemas, create a wrapper model - const pythonType = this.getPydanticType(schema) - return `class ${className}(BaseModel):\n value: ${pythonType}` - } - - /** - * Generate a single Python file with both models and methods - */ - generatePythonFile(): string { - // Calculate imports - const {typingImports} = this.calculateNeededImports() - - // Generate the imports section - let pythonCode = '' - - // Standard library imports - pythonCode += 'import logging\n' - - // Third-party imports - pythonCode += 'import prefab_cloud_python\n' - // Use a combined import for prefab imports - pythonCode += 'from prefab_cloud_python import Client, Context, ContextDictOrContext\n' - pythonCode += 'from pydantic import BaseModel, ValidationError\n' - - // Add dataclasses import only if we have parameter classes - if (this.paramClasses.size > 0) { - pythonCode += 'from dataclasses import dataclass\n' - } - - // Add pystache import only if we have methods with template parameters - if ([...this.methods.values()].some((spec) => spec.hasTemplateParams)) { - pythonCode += 'import pystache\n' - } - - // Typing imports - pythonCode += `from typing import ${typingImports.sort().join(', ')}\n\n` - - pythonCode += 'from datetime import datetime, timedelta\n\n' - - pythonCode += 'logger = logging.getLogger(__name__)\n\n' - - pythonCode += 'class ObjectModel(BaseModel):\n pass\n\n' - - // Add the client class with nested types - const className = this.options.className || 'PrefabTypedClient' - pythonCode += `class ${className}:\n """Client for accessing Prefab configuration with type-safe methods"""\n` - pythonCode += ` def __init__(self, client=None, use_global_client=False):\n """\n Initialize the typed client.\n - Args:\n client: A Prefab client instance. If not provided and use_global_client is False, - uses the global client at initialization time.\n use_global_client: If True, dynamically calls prefab_cloud_python.get_client() for each request\n instead of storing a reference. Useful in long-running applications where\n the client might be reset or reconfigured.\n """\n self._prefab = prefab_cloud_python\n self._use_global_client = use_global_client\n self._client = None if use_global_client else (client or prefab_cloud_python.get_client())\n` - pythonCode += ` @property\n def client(self):\n """\n Returns the client to use for the current request.\n - If use_global_client is True, dynamically retrieves the current global client.\n Otherwise, returns the stored client instance.\n """\n if self._use_global_client:\n return self._prefab.get_client()\n return self._client\n` - - // Add parameter classes if we have any - if (this.paramClasses.size > 0) { - pythonCode += '\n # Parameter classes for template methods\n' - for (const [className, classInfo] of this.paramClasses.entries()) { - pythonCode += ` @dataclass\n class ${className}:\n` - if (classInfo.fields.length === 0) { - pythonCode += ' pass\n\n' - continue - } - - for (const field of classInfo.fields) { - pythonCode += ` ${field.name}: ${field.type}\n` - } - - pythonCode += '\n' - } - } - - // Add all models - if (this.models.size > 0) { - pythonCode += '\n # Pydantic models for complex types\n' - for (const modelCode of this.models.values()) { - // Indent the model code to be inside the class - const indentedModelCode = modelCode - .split('\n') - .map((line) => ' ' + line) - .join('\n') - pythonCode += indentedModelCode + '\n\n' - } - } - - // Add all methods - for (const [methodName, methodSpec] of this.methods.entries()) { - // Generate the method code - const methodCode = this.generateMethodCode(methodName, methodSpec) - // Properly indent the entire method with 4 spaces - const indentedMethodCode = methodCode - .split('\n') - .map((line) => ' ' + line) - .join('\n') - pythonCode += indentedMethodCode + '\n' - - // Generate the fallback method code - const fallbackMethodCode = this.generateFallbackMethod(methodName, methodSpec) - // Properly indent the fallback method with 4 spaces - const indentedFallbackMethodCode = fallbackMethodCode - .split('\n') - .map((line) => (line ? ' ' + line : line)) // Preserve empty lines - .join('\n') - pythonCode += indentedFallbackMethodCode + '\n' - } - - pythonCode += '\n' // Add a newline at the end of the class - - return pythonCode - } - - /** - * Generate value extraction code based on return type and value type - */ - public generateValueExtraction( - returnType: string, - isBasicType: boolean, - valueType?: string, - hasTemplateParams?: boolean, - ): string { - const className = this.options.className || 'PrefabTypedClient' - - if (isBasicType) { - switch (valueType) { - case 'BOOL': { - return ` if isinstance(config_value, bool): - return config_value` - } - - case 'INT': { - return ` if isinstance(config_value, int): - return config_value` - } - - case 'DOUBLE': { - return ` if isinstance(config_value, (int, float)): - return float(config_value)` - } - - case 'STRING': { - // Only apply template rendering for methods that have template parameters - return hasTemplateParams - ? ` if isinstance(config_value, str): - raw = config_value - return pystache.render(raw, params.__dict__) if params else raw` - : ` if isinstance(config_value, str): - raw = config_value - return raw` - } - - case 'STRING_LIST': { - // Only apply template rendering for methods that have template parameters - return hasTemplateParams - ? ` if isinstance(config_value, list) and all(isinstance(x, str) for x in config_value): - if params: - return [pystache.render(item, params.__dict__) for item in config_value] - return config_value` - : ` if isinstance(config_value, list) and all(isinstance(x, str) for x in config_value): - return config_value` - } - - case 'JSON': { - // For primitive types with JSON valueType, we need to use the specific format expected by tests - if (returnType === 'bool') { - return ` if isinstance(config_value, dict): - return self.bool(**config_value)` - } - - if (isBasicType) { - return ` if isinstance(config_value, dict): - return ${returnType}(**config_value)` - } - - return ` if isinstance(config_value, dict): - return self.${returnType.replaceAll(/["']/g, '')}(**config_value)` - } - - case 'DURATION': { - return ` if isinstance(config_value, timedelta): - return config_value` - } - - default: { - return '' - } - } - } else if (returnType.includes('Dict[')) { - // Special case for Dict - only process templates when hasTemplateParams is true - return hasTemplateParams - ? ` if isinstance(config_value, dict): - # Process dictionary values that might contain templates - if params: - processed_dict = {} - for key, value in config_value.items(): - if isinstance(value, str): - processed_dict[key] = pystache.render(value, params.__dict__) - else: - processed_dict[key] = value - return processed_dict - return config_value` - : ` if isinstance(config_value, dict): - return config_value` - } else { - // For complex types (like Pydantic models) - // Only apply template rendering for methods that have template parameters - return hasTemplateParams - ? ` if isinstance(config_value, dict): - # Process dictionary values that might contain templates - if params: - processed_dict = {} - for key, value in config_value.items(): - if isinstance(value, str): - processed_dict[key] = pystache.render(value, params.__dict__) - else: - processed_dict[key] = value - return self.${returnType.replaceAll(/["']/g, '').replace(`${className}.`, '')}(**processed_dict) - return self.${returnType.replaceAll(/["']/g, '').replace(`${className}.`, '')}(**config_value)` - : ` if isinstance(config_value, dict): - return self.${returnType.replaceAll(/["']/g, '').replace(`${className}.`, '')}(**config_value)` - } - } - - /** - * Determines if a Python type is a basic type - */ - public isBasicType(typeName: string): boolean { - return ['List[str]', 'bool', 'datetime.datetime', 'float', 'int', 'str'].includes(typeName) - } - - /** - * Register a method that returns a Pydantic model - */ - registerMethod( - methodName: string, - returnSchema: z.ZodTypeAny, - schemaName?: string, - params: MethodParam[] = [], - docstring?: string, - valueType?: string, - originalKey?: string, - ): void { - // First convert to valid identifier using standard utility - const validIdentifier = ZodUtils.keyToMethodName(methodName) - - // Then convert to Python snake_case - const pythonMethodName = this.toPythonMethodName(validIdentifier) - - // Register the return type schema, passing along the valueType - const derivedSchemaName = schemaName || this.deriveSchemaNameFromMethod(pythonMethodName) - const returnType = this.registerModel(returnSchema, derivedSchemaName, valueType) - - // Check if this is a template function (has template parameters) - // or if it contains nested template functions - let hasTemplateParams = false - let paramClassName: string | undefined - - // First check direct template parameters (for function schemas) - const templateParams = ZodUtils.paramsOf(returnSchema) - if (templateParams && isZodType(templateParams)) { - hasTemplateParams = true - // Generate a parameter class for the template parameters - paramClassName = this.generateParamClass(pythonMethodName, templateParams) - } - // Then check for nested template functions in object properties - else if (this.hasNestedTemplateFunctions(returnSchema)) { - hasTemplateParams = true - - // Get all template parameters from nested functions - const allParams = this.collectAllTemplateParams(returnSchema) - if (allParams) { - paramClassName = this.generateParamClass(pythonMethodName, allParams) - } - } - - // Add pystache import for template rendering if needed - if (hasTemplateParams) { - this.imports.addStandardImport('pystache') - } - - if (this.methods.get(pythonMethodName)) { - throw new Error( - `Unable to generate method '${pythonMethodName}' for config key '${ - originalKey || methodName - }' because it has already been generated for config key '${this.methods.get(pythonMethodName)?.originalKey}'.`, - ) - } - - this.methods.set(pythonMethodName, { - docstring: docstring || `Get ${derivedSchemaName}`, - hasTemplateParams, - originalKey: originalKey || methodName, - paramClassName, - params, - returnType, - valueType, - }) - } - - /** - * Register a schema for use as a model - */ - registerModel(schema: z.ZodTypeAny, baseName: string, valueType?: string): string { - // Check if this schema was already registered - if (this.schemaModels.has(schema)) { - return this.schemaModels.get(schema)! - } - - // If valueType is provided, use it to determine the return type for primitives - if (valueType) { - switch (valueType.toUpperCase()) { - case 'BOOL': { - return 'bool' - } - - case 'STRING': { - return 'str' - } - - case 'INT': { - return 'int' - } - - case 'DOUBLE': { - return 'float' - } - - case 'STRING_LIST': { - this.imports.addTypingImport('List') - return 'List[str]' - } - } - } - - // For basic types, return their Python type directly - if (schema instanceof z.ZodString) { - return 'str' - } - - if (schema instanceof z.ZodNumber) { - const typeDef = getTypeDef(schema) - return typeDef.checks?.some((check) => check.kind === 'int') ? 'int' : 'float' - } - - if (schema instanceof z.ZodBoolean) { - return 'bool' - } - - if (schema instanceof z.ZodDate) { - return 'datetime.datetime' - } - - // Special case for arrays of basic types - return List[elementType] directly - if (schema instanceof z.ZodArray) { - const typeDef = getTypeDef(schema) - if ( - typeDef.type && - isZodType(typeDef.type) && // Check if the element type is a basic type - (typeDef.type instanceof z.ZodString || - typeDef.type instanceof z.ZodNumber || - typeDef.type instanceof z.ZodBoolean) - ) { - const elementType = this.registerModel(typeDef.type, 'Element') - this.imports.addTypingImport('List') - return `List[${elementType}]` - } - } - - // Extract name from schema if possible - const extractedName = this.extractSchemaName(schema) - const className = this.generateClassName(extractedName || baseName) - - // Store the association between schema and model name - this.schemaModels.set(schema, className) - - // If we already have this model, return the existing name - for (const [existingName, existingModel] of this.models.entries()) { - if (this.equivalentSchemas(schema, existingModel)) { - return existingName - } - } - - // Analyze schema for imports - analyzeSchemaImports(schema, this.imports) - - // Generate model code - const modelCode = this.generatePydanticModel(schema, className) - this.models.set(className, modelCode) - - return className - } - - /** - * Write the combined output to a file - */ - writeToFile(outputPath?: string): string { - const filePath = outputPath || this.options.outputPath || './generated/config_client.py' - const dirPath = path.dirname(filePath) - - // Ensure directory exists - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, {recursive: true}) - } - - // Generate and write the file - const pythonCode = this.generatePythonFile() - fs.writeFileSync(filePath, pythonCode) - - this.log(`Generated Python client written to ${filePath}`) - - return filePath - } - - /** - * Derive a schema name from a method name - * For example: getResourceConfig -> Resource - */ - private deriveSchemaNameFromMethod(methodName: string): string { - // Try to extract a meaningful name from common prefixes - const prefixMatches = methodName.match( - /^(?:get|fetch|retrieve|load)([A-Z][\dA-Za-z]*)(?:Config|Schema|Data|Info)?$/, - ) - - if (prefixMatches && prefixMatches[1]) { - return prefixMatches[1] // Return the extracted resource name - } - - // For methods like userProfile, extract "User" - const camelCaseMatch = methodName.match(/^([a-z]+)([A-Z][\dA-Za-z]*)$/) - - if (camelCaseMatch && camelCaseMatch[2]) { - return camelCaseMatch[2] - } - - // Fallback: convert method name to PascalCase - return methodName - .replace(/^[a-z]/, (match) => match.toUpperCase()) - .replaceAll(/_([a-z])/g, (_, letter) => letter.toUpperCase()) - } - - /** - * Check if two schemas are equivalent - * This is a simplistic approach - production code would be more thorough - */ - private equivalentSchemas(schema1: z.ZodTypeAny, schema2: string | z.ZodTypeAny): boolean { - // This is a simple check - real implementation would be more comprehensive - if (typeof schema2 === 'string') { - return false // Can't compare a schema to a string directly - } - - // For object schemas, compare their shape - if (schema1 instanceof z.ZodObject && schema2 instanceof z.ZodObject) { - const shape1 = JSON.stringify(schema1._def.shape()) - const shape2 = JSON.stringify(schema2._def.shape()) - - return shape1 === shape2 - } - - return false - } - - /** - * Extract a type name from a Zod schema - */ - private extractSchemaName(schema: z.ZodTypeAny): string | undefined { - if (!isZodType(schema)) { - return undefined - } - - const typeDef = getTypeDef(schema) - - // Check for explicit type name in description - if ( - typeDef.description && // If description is a valid type name, use it - /^[A-Z][\dA-Za-z]*$/.test(typeDef.description) - ) { - return typeDef.description - } - - // Check for metadata with explicit type name - if (typeDef.meta && typeof typeDef.meta.typeName === 'string') { - return typeDef.meta.typeName as string - } - - // For object types, check if we have a TypeName property - if (schema instanceof z.ZodObject && typeDef.shape) { - const shape = typeDef.shape() - - // Check for typeName property - if ('typeName' in shape && isZodType(shape.typeName) && shape.typeName instanceof z.ZodString) { - const stringTypeDef = getTypeDef(shape.typeName) - return stringTypeDef.description - } - - // Check for type literal field - if ('type' in shape && isZodType(shape.type) && shape.type instanceof z.ZodLiteral) { - const literalTypeDef = getTypeDef(shape.type) - - if (typeof literalTypeDef.value === 'string' && /^[A-Z][\dA-Za-z]*$/.test(literalTypeDef.value as string)) { - return literalTypeDef.value as string - } - } - } - - // Default case - no explicit name found - return undefined - } - - /** - * Generate a unique class name - */ - private generateClassName(baseName: string): string { - // Clean the base name - const cleanName = baseName - .replaceAll(/[^\s\w]/g, '') - .replaceAll(/\s+/g, '_') - .replaceAll(/(^[a-z])|(_[a-z])/g, (match) => match.toUpperCase()) - .replaceAll('_', '') - - // Add the prefix if specified, but only if it's not already in the name - const prefix = this.options.prefixName || '' - let candidateName = `${prefix}${cleanName}Model` - - // Remove Prefab prefix if it exists since types are now nested - candidateName = candidateName.replace(/^Prefab/, '') - - // Ensure uniqueness - let counter = 1 - - while (this.models.has(candidateName)) { - candidateName = `${prefix}${cleanName}Model${counter}` - counter++ - } - - return candidateName - } - - /** - * Generate a fallback convenience method for a method - */ - private generateFallbackMethod( - methodName: string, - spec: { - docstring: string - hasTemplateParams?: boolean - originalKey: string - paramClassName?: string - params: MethodParam[] - returnType: string - valueType?: string - }, - ): string { - // For other methods, create a with_fallback_value version - const fallbackMethodName = `${methodName}_with_fallback_value` - - const typeName = this.options.className || 'PrefabTypedClient' - const isBasicType = this.isBasicType(spec.returnType) - const returnTypeStr = isBasicType ? spec.returnType : `'${typeName}.${spec.returnType}'` - - // Use 'fallback' consistently instead of 'fallback_value' - const paramName = 'fallback' - - // Parameter list with fallback first - const paramList = ['self'] - - // Use determined parameter name - paramList.push(`${paramName}: ${returnTypeStr}`) - - // Add template params if needed - if (spec.hasTemplateParams && spec.paramClassName) { - paramList.push(`params: Optional['${typeName}.${spec.paramClassName}'] = None`) - } - - // Add other regular params - for (const param of spec.params) { - paramList.push(`${param.name}: ${param.type} = ${param.default || 'None'}`) - } - - // Add context last - paramList.push(`context: Optional[ContextDictOrContext] = None`) - - return ` -def ${fallbackMethodName}(${paramList.join(', ')}) -> ${returnTypeStr}: - """ - ${spec.docstring} - - Args: - ${paramName}: Required fallback value to return if config lookup fails or doesn't match expected type -${spec.hasTemplateParams ? ' params: Parameters for template rendering\n' : ''}${spec.params - .map((param) => ` ${param.name}: Description of ${param.name}`) - .join('\n')} - context: Optional context for the config lookup - - Returns: - ${returnTypeStr}: The configuration value - """ - # Call the regular method and return the result - # The main method will handle the fallback value appropriately - return self.${methodName}(${spec.hasTemplateParams ? 'params, ' : ''}${spec.params.map((p) => p.name).join(', ')}${ - spec.params.length > 0 ? ', ' : '' - }context, ${paramName}) - ` - } - - /** - * Get the Python type for a Zod type - */ - private getPydanticType(schema: z.ZodTypeAny): string { - if (schema instanceof z.ZodString) { - return 'str' - } - - if (schema instanceof z.ZodNumber) { - const typeDef = getTypeDef(schema) - return typeDef.checks?.some((check) => check.kind === 'int') ? 'int' : 'float' - } - - if (schema instanceof z.ZodBoolean) { - return 'bool' - } - - if (schema instanceof z.ZodArray) { - const elementType = this.getPydanticType(schema.element) - return `List[${elementType}]` - } - - if (schema instanceof z.ZodObject) { - // Check if we've already registered this schema - if (this.schemaModels.has(schema)) { - const modelName = this.schemaModels.get(schema)! - return `${modelName}` - } - - // Generate a generic model name as fallback - const modelName = this.generateClassName(schema.description || 'Object') - return `${modelName}` - } - - if (schema instanceof z.ZodUnion) { - const types = schema.options.map((t: z.ZodTypeAny) => this.getPydanticType(t)) - return `Union[${types.join(', ')}]` - } - - if (schema instanceof z.ZodOptional) { - const innerType = this.getPydanticType(schema._def.innerType) - return `Optional[${innerType}]` - } - - if (schema instanceof z.ZodRecord) { - // Don't namespace built-in types like Dict - const keyType = this.getPydanticType(schema._def.keyType) - const valueType = this.getPydanticType(schema._def.valueType) - return `Dict[${keyType}, ${valueType}]` - } - - if (schema instanceof z.ZodFunction) { - return 'str' // Mustache template fields are always strings - } - - return 'Any' - } - - /** - * Check if a schema or any of its nested properties contain template functions - */ - private hasNestedTemplateFunctions(schema: z.ZodTypeAny): boolean { - // Check if this is a ZodObject that might contain nested template functions - if (schema instanceof z.ZodObject) { - const shape = schema._def.shape() - // Check each property - for (const propKey of Object.keys(shape)) { - const propSchema = shape[propKey] - // If this property is a template function - if (ZodUtils.paramsOf(propSchema)) { - return true - } - - // Recursively check nested objects - if (propSchema instanceof z.ZodObject && this.hasNestedTemplateFunctions(propSchema)) { - return true - } - } - } - - return false - } - - /** - * Convert JavaScript-style method name to Python snake_case - * This is an additional step after ZodUtils.keyToMethodName - */ - private toPythonMethodName(methodName: string): string { - // First, normalize the input by replacing any existing underscores or sequences - // of special characters with a single space - const normalized = methodName.replaceAll(/[^\dA-Za-z]+/g, ' ').trim() - - // Then split by capital letters and spaces - const parts = normalized - .split(/(?=[A-Z])|\s+/g) - .filter((part) => part.length > 0) // Filter out empty parts - .map((part) => part.toLowerCase()) // Convert all to lowercase - - // Join with underscores to create snake_case - return parts.join('_') - } -} - -// Example usage -export function example(): void { - // Create the unified generator - const generator = new UnifiedPythonGenerator({ - className: 'PrefabConfigClient', - outputPath: './generated/prefab_config_client.py', - prefixName: 'Prefab', - }) - - // Register schemas and methods - const connectionSchema = z - .object({ - host: z.string(), - port: z.number().int().positive(), - secure: z.boolean().default(true), - timeout: z.string().refine((val) => /^PT\d+[HMS]$/.test(val), { - message: 'Must be an ISO 8601 duration', - }), - }) - .describe('Connection') - - // Register a method using the schema - generator.registerMethod( - 'getConnection', - connectionSchema, - undefined, - [{default: '"production"', name: 'environment', type: 'str'}], - 'Get connection configuration for the specified environment', - ) - - // Generate the file - generator.writeToFile() -} - -// Example with Mustache template -export function exampleWithTemplate(): void { - // Create the unified generator - const generator = new UnifiedPythonGenerator({ - className: 'PrefabConfigClient', - outputPath: './generated/prefab_config_client_with_template.py', - prefixName: 'Prefab', - }) - - // Register schemas and methods - const connectionSchema = z - .object({ - host: z.string(), - port: z.number().int().positive(), - retryCount: z.number().int().min(0).max(10), - secure: z.boolean().default(true), - timeout: z.string().refine((val) => /^PT\d+[HMS]$/.test(val), { - message: 'Must be an ISO 8601 duration', - }), - }) - .describe('Connection') - - // Register a method using the schema - generator.registerMethod( - 'getConnection', - connectionSchema, - undefined, - [{default: '"production"', name: 'environment', type: 'str'}], - 'Get connection configuration for the specified environment', - ) - - // Register a template method using Mustache - const templateSchema = z - .function() - .args( - z.object({ - company: z.string(), - name: z.string(), - }), - ) - .returns(z.string()) - .describe('GreetingTemplate') - - // Register the template method - generator.registerMethod( - 'getGreetingTemplate', - templateSchema, - undefined, - [], - 'Get a greeting template that can be rendered with a name and company', - 'STRING', // Set the valueType to STRING - ) - - // Generate the file - generator.writeToFile() -} - -// Run the example -if (process.argv[1] === fileURLToPath(import.meta.url)) { - console.log('Running example without templates...') - example() - - console.log('Running example with templates...') - exampleWithTemplate() -} diff --git a/src/codegen/schema-inferrer.ts b/src/codegen/schema-inferrer.ts deleted file mode 100644 index 5c2ba93..0000000 --- a/src/codegen/schema-inferrer.ts +++ /dev/null @@ -1,445 +0,0 @@ -import type {ZodObject, ZodRawShape, ZodTypeAny} from 'zod' - -import {z} from 'zod' - -import {MustacheExtractor} from './mustache-extractor.js' -import {secureEvaluateSchema} from './schema-evaluator.js' -import {type Config, type ConfigFile, SupportedLanguage} from './types.js' -import {ZodUtils} from './zod-utils.js' - -export type SchemaWithProvidence = { - providence: 'inferred' | 'user' - schema: z.ZodTypeAny -} - -export class SchemaInferrer { - private jsonToInferredZod = (data: unknown): ZodTypeAny => { - if (Array.isArray(data)) { - if (data.length > 0) { - // Check if all elements in the array have the same type - const firstItemType = typeof data[0] - const isHomogeneous = data.every((item) => typeof item === firstItemType) - - // For homogeneous arrays, use the first element's type - if (isHomogeneous) { - return z.array(this.jsonToInferredZod(data[0])) - } - - // TODO: we could handle mixed arrays here with a union type but we prefer the user to upload a schema - } - - return z.array(z.unknown()) - } - - if (typeof data === 'object' && data !== null) { - // If it's an object, recursively infer the schema for each key - const shape: Record = {} - const dataRecord = data as Record - for (const key in dataRecord) { - if (Object.hasOwn(dataRecord, key)) { - shape[key] = this.jsonToInferredZod(dataRecord[key]) - } - } - - return z.object(shape) - } - - if (typeof data === 'string') { - return z.string() - } - - if (typeof data === 'number') { - return z.number() - } - - if (typeof data === 'boolean') { - return z.boolean() - } - - if (data === null) { - return z.null() - } - - return z.any() // Fallback for unknown types - } - - private mergeSchemas = (schemaA: ZodObject, schemaB: ZodObject): ZodObject => { - const shapeA = schemaA.shape - const shapeB = schemaB.shape - const mergedShape: Record = {} - - const allKeys = new Set([...Object.keys(shapeA), ...Object.keys(shapeB)]) - for (const key of allKeys) { - const typeA = shapeA[key] - const typeB = shapeB[key] - - if (typeA && typeB) { - // Both schemas have the key - mergedShape[key] = this.mergeTypes(typeA, typeB) - } else { - const existingType = typeA || typeB - // Make the type optional if it exists in only one schema - mergedShape[key] = this.isOptional(existingType) ? existingType : existingType.optional() - } - } - - return z.object(mergedShape) - } - - constructor(private log: (category: string | unknown, message?: unknown) => void) {} - - zodForConfig(config: Config, configFile: ConfigFile, language: SupportedLanguage): SchemaWithProvidence { - const {schemaKey} = config - const schemaConfig = schemaKey - ? configFile.configs.find((c) => c.key === schemaKey && c.configType === 'SCHEMA') - : undefined - - const userDefinedSchema = schemaConfig ? this.schemaToZod(config, schemaConfig) : undefined - - const schemaWithoutMustache = userDefinedSchema ?? this.inferFromConfig(config, language) - return { - providence: userDefinedSchema ? 'user' : 'inferred', - schema: this.replaceStringsWithMustache(schemaWithoutMustache, config), - } - } - - private areArgumentShapesDifferent(argsA: Record, argsB: Record): boolean { - // Check for structural differences that would make merging inappropriate - const keysA = Object.keys(argsA) - const keysB = Object.keys(argsB) - - // If one has section helpers and the other has simple placeholders, they're different - const hasSectionA = keysA.some((key) => argsA[key] && argsA[key]._def && argsA[key]._def.typeName === 'ZodArray') - const hasSectionB = keysB.some((key) => argsB[key] && argsB[key]._def && argsB[key]._def.typeName === 'ZodArray') - - if (hasSectionA !== hasSectionB) { - return true - } - - // If they have no keys in common, they're different - return !keysA.some((key) => keysB.includes(key)) - } - - private createZodFunctionFromMustacheStrings(strings: string[]): z.ZodTypeAny { - if (strings.length === 0) { - return z.string() - } - - // If multiple strings, merge their schemas - if (strings.length > 1) { - const schemas = strings.map((str) => MustacheExtractor.extractSchema(str, this.log)) - - // Replace reduce with a loop - let mergedSchema: ZodObject | null = null - for (const schema of schemas) { - mergedSchema = - mergedSchema === null - ? (schema as ZodObject) - : this.mergeSchemas(mergedSchema, schema as ZodObject) - } - - // If the schema is empty (no properties), just return basic string - if (mergedSchema && Object.keys(mergedSchema._def.shape()).length === 0) { - return z.string() - } - - return mergedSchema - ? z.function().args(mergedSchema).returns(z.string()) - : z.function().args(schemas[0]).returns(z.string()) - } - - const schema = MustacheExtractor.extractSchema(strings[0], this.log) - - // If the schema is empty (no properties), just return basic string - if (Object.keys(schema._def.shape()).length === 0) { - return z.string() - } - - return z.function().args(schema).returns(z.string()) - } - - // Get all JSON values from the config - private getAllJsonValues(config: Config): unknown[] { - return config.rows.flatMap((row) => - row.values.flatMap((valueObj) => { - if (config.valueType === 'JSON') { - // Try to parse JSON from json field - if (valueObj.value.json?.json) { - try { - return [JSON.parse(valueObj.value.json.json)] - } catch (error) { - console.warn(`Failed to parse JSON for ${config.key}:`, error) - } - } - // Try to parse JSON from string field - else if (valueObj.value.string) { - try { - return [JSON.parse(valueObj.value.string)] - } catch (error) { - console.warn(`Failed to parse JSON string for ${config.key}:`, error) - } - } - } - - return [] - }), - ) - } - - /** - * Get all string values at a specific location in the config - * @param config The configuration to extract strings from - * @param location Array of keys representing the path to look for strings. Empty array for direct string values. - * @returns Array of strings found at the specified location - */ - private getAllStringsAtLocation(config: Config, location: string[]): string[] { - if (location.length === 0) { - // For empty location, just get direct string values - return config.rows.flatMap((row) => - row.values.flatMap((valueObj) => (valueObj.value.string ? [valueObj.value.string] : [])), - ) - } - - // For JSON values, we need to traverse the object - return config.rows.flatMap((row) => - row.values.flatMap((valueObj) => { - let jsonContent: unknown = null - - // Try to parse JSON from either json field or string field - if (valueObj.value.json?.json) { - try { - jsonContent = JSON.parse(valueObj.value.json.json) - } catch (error) { - console.warn(`Failed to parse JSON for ${config.key}:`, error) - return [] - } - } else if (valueObj.value.string && config.valueType === 'JSON') { - try { - jsonContent = JSON.parse(valueObj.value.string) - } catch (error) { - console.warn(`Failed to parse JSON string for ${config.key}:`, error) - return [] - } - } - - if (!jsonContent) return [] - - // Traverse the JSON object to the specified location - let current: unknown = jsonContent - for (const key of location) { - if (current && typeof current === 'object' && key in current) { - current = (current as Record)[key] - } else { - return [] - } - } - - // If we found a string at the location, return it - return typeof current === 'string' ? [current] : [] - }), - ) - } - - private getInnerType(type: ZodTypeAny): ZodTypeAny { - return type instanceof z.ZodOptional ? type._def.innerType : type - } - - private handleEnum(type: ZodTypeAny): ZodTypeAny { - const {typeName, values} = type._def - return typeName === 'ZodEnum' ? z.enum(values) : type - } - - private inferFromConfig(config: Config, language: SupportedLanguage): z.ZodTypeAny { - const {key, valueType} = config - switch (valueType) { - case 'STRING': { - return z.string() - } - - case 'BOOL': { - return z.boolean() - } - - case 'INT': { - return z.number().int() - } - - case 'DOUBLE': { - return z.number() - } - - case 'STRING_LIST': { - return z.array(z.string()) - } - - case 'DURATION': { - return z.string().duration() - } - - case 'JSON': { - const jsonValues = this.getAllJsonValues(config) - this.log('JSON values:', JSON.stringify(jsonValues, null, 2)) - - if (jsonValues.length > 0) { - try { - // Infer schemas for all JSON values - const schemas = jsonValues.map((json) => { - const schema = this.jsonToInferredZod(json) - this.log('Inferred schema for:', JSON.stringify(json)) - this.log('Schema:', ZodUtils.zodToString(schema, key, 'inferred', language)) - return schema - }) - - // Process each schema - let mergedSchema: ZodTypeAny | null = null - for (const [i, schema] of schemas.entries()) { - this.log(`Processing schema ${i}:`, ZodUtils.zodToString(schema, key, 'inferred', language)) - - if (mergedSchema === null) { - mergedSchema = schema - } else if (mergedSchema instanceof z.ZodObject && schema instanceof z.ZodObject) { - mergedSchema = this.mergeSchemas(mergedSchema, schema) - this.log('Merged result:', ZodUtils.zodToString(mergedSchema, key, 'inferred', language)) - } - } - - if (mergedSchema) { - this.log('Final merged schema:', ZodUtils.zodToString(mergedSchema, key, 'inferred', language)) - return mergedSchema - } - } catch (error) { - console.warn(`Error inferring JSON schema for ${key}:`, error) - } - } - - return z.union([z.array(z.any()), z.record(z.any())]) - } - - case 'LOG_LEVEL': { - return z.enum(['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR']) - } - - default: { - return z.any() - } - } - } - - private isOptional(type: ZodTypeAny): boolean { - return type instanceof z.ZodOptional - } - - private mergeTypes(typeA: ZodTypeAny, typeB: ZodTypeAny): ZodTypeAny { - const shouldBeOptional = this.isOptional(typeA) || this.isOptional(typeB) - const innerA = this.getInnerType(typeA) - const innerB = this.getInnerType(typeB) - const {typeName: typeNameA, values: valuesA} = innerA._def - const {typeName: typeNameB, values: valuesB} = innerB._def - - let mergedType: ZodTypeAny - - if (innerA instanceof z.ZodObject && innerB instanceof z.ZodObject) { - // Recursively merge nested objects - mergedType = this.mergeSchemas(innerA, innerB) - } else if (typeNameA === 'ZodFunction' && typeNameB === 'ZodFunction') { - try { - const {args: argsA} = innerA._def - const {args: argsB} = innerB._def - - if (argsA instanceof z.ZodObject && argsB instanceof z.ZodObject) { - const {shape: shapeA} = argsA - const {shape: shapeB} = argsB - const areArgsDifferent = this.areArgumentShapesDifferent(shapeA, shapeB) - mergedType = areArgsDifferent - ? z.union([innerA, innerB]) - : z.function().args(this.mergeSchemas(argsA, argsB)).returns(z.string()) - } else { - mergedType = z.union([innerA, innerB]) - } - } catch (error) { - this.log(`Error merging function arguments:`, error) - mergedType = z.union([innerA, innerB]) - } - } else if (typeNameA === 'ZodEnum' && typeNameB === 'ZodEnum') { - // For enums, if they're the same, use one of them - mergedType = JSON.stringify(valuesA) === JSON.stringify(valuesB) ? innerA : z.union([innerA, innerB]) - } else if (this.typesMatch(innerA, innerB)) { - mergedType = this.handleEnum(innerA) - } else { - mergedType = z.union([innerA, innerB]) - } - - return shouldBeOptional ? mergedType.optional() : mergedType - } - - private replaceStringsWithMustache( - schema: z.ZodTypeAny, - config: Config, - schemaLocation: string[] = [], - ): z.ZodTypeAny { - const {typeName} = schema._def - - // Handle enums explicitly - if (typeName === 'ZodEnum') { - return schema - } - - // Check for both direct string and optional string - if ( - schema instanceof z.ZodString || - (schema instanceof z.ZodOptional && schema._def.innerType instanceof z.ZodString) - ) { - const stringsAtLocation = this.getAllStringsAtLocation(config, schemaLocation) - if (schema instanceof z.ZodOptional) { - return this.createZodFunctionFromMustacheStrings(stringsAtLocation).optional() - } - - return this.createZodFunctionFromMustacheStrings(stringsAtLocation) - } - - if (schema instanceof z.ZodObject) { - const {shape} = schema - const newShape: Record = {} - - for (const [key, value] of Object.entries(shape)) { - newShape[key] = this.replaceStringsWithMustache(value as z.ZodTypeAny, config, [...schemaLocation, key]) - } - - return z.object(newShape) - } - - // NOTE: no support for Mustache in Arrays or Unions - - // For all other types, just return as is - return schema - } - - private schemaToZod(config: Config, schemaConfig: Config): undefined | z.ZodTypeAny { - // Extract the schema from the schema config - for (const row of schemaConfig.rows) { - for (const valueObj of row.values) { - if (valueObj.value.schema?.schema) { - const schemaStr = valueObj.value.schema.schema - const result = secureEvaluateSchema(schemaStr) - - if (result.success && result.schema) { - this.log(`Successfully parsed schema from schema config: ${schemaConfig.key}`) - return result.schema - } - - console.warn(`Failed to parse schema from schema config ${schemaConfig.key}: ${result.error}`) - } - } - } - - return undefined - } - - private typesMatch(typeA: ZodTypeAny, typeB: ZodTypeAny): boolean { - const innerA = this.getInnerType(typeA) - const innerB = this.getInnerType(typeB) - const {typeName: typeNameA} = innerA._def - const {typeName: typeNameB} = innerB._def - return typeNameA === typeNameB - } -} diff --git a/src/codegen/templates/dependencies/react-duration.mustache b/src/codegen/templates/dependencies/react-duration.mustache deleted file mode 100644 index 523e85a..0000000 --- a/src/codegen/templates/dependencies/react-duration.mustache +++ /dev/null @@ -1,8 +0,0 @@ -const PrefabDurationSchema = z - .object({ - seconds: z.number(), - ms: z.number(), - }) - .describe("PrefabDurationSchema"); - -type PrefabDuration = z.infer; diff --git a/src/codegen/templates/dependencies/react-mustache.mustache b/src/codegen/templates/dependencies/react-mustache.mustache deleted file mode 100644 index 0c3e0b5..0000000 --- a/src/codegen/templates/dependencies/react-mustache.mustache +++ /dev/null @@ -1 +0,0 @@ -import Mustache from 'mustache'; diff --git a/src/codegen/templates/dependencies/ruby-mustache.mustache b/src/codegen/templates/dependencies/ruby-mustache.mustache deleted file mode 100644 index 05bbd0f..0000000 --- a/src/codegen/templates/dependencies/ruby-mustache.mustache +++ /dev/null @@ -1 +0,0 @@ -# nothing here \ No newline at end of file diff --git a/src/codegen/templates/dependencies/typescript-mustache.mustache b/src/codegen/templates/dependencies/typescript-mustache.mustache deleted file mode 100644 index 0c3e0b5..0000000 --- a/src/codegen/templates/dependencies/typescript-mustache.mustache +++ /dev/null @@ -1 +0,0 @@ -import Mustache from 'mustache'; diff --git a/src/codegen/templates/python-accessor.mustache b/src/codegen/templates/python-accessor.mustache deleted file mode 100644 index 4a21585..0000000 --- a/src/codegen/templates/python-accessor.mustache +++ /dev/null @@ -1,3 +0,0 @@ - def {{methodName}}(self): - raw = self.get('{{key}}') - return {{{returnValue}}} diff --git a/src/codegen/templates/python-schema.mustache b/src/codegen/templates/python-schema.mustache deleted file mode 100644 index ae3fee1..0000000 --- a/src/codegen/templates/python-schema.mustache +++ /dev/null @@ -1 +0,0 @@ -"{{key}}": {{{zodType}}} \ No newline at end of file diff --git a/src/codegen/templates/python.mustache b/src/codegen/templates/python.mustache deleted file mode 100644 index cd0a82f..0000000 --- a/src/codegen/templates/python.mustache +++ /dev/null @@ -1,14 +0,0 @@ -# AUTOGENERATED CODE - DO NOT EDIT -# AUTOGENERATED with prefab-cli's gen command -from typing import Dict, Any, Optional, Callable, TypeVar, Generic, Union, List -import prefab_cloud_python -import pystache - -class PrefabTypesafe: - def __init__(self, prefab): - self.prefab = prefab - - def get(self, key): - return self.prefab.get(key) - -{{{accessorMethods}}} diff --git a/src/codegen/templates/react-accessor.mustache b/src/codegen/templates/react-accessor.mustache deleted file mode 100644 index 22da4be..0000000 --- a/src/codegen/templates/react-accessor.mustache +++ /dev/null @@ -1,18 +0,0 @@ -{{#isFunctionReturn}} - {{methodName}}(): {{#params}}(params: {{params}}) => {{{returnType}}}{{/params}}{{^params}}() => {{{returnType}}}{{/params}} { - const raw = this.get('{{key}}'); - return {{{returnValue}}}; - } -{{/isFunctionReturn}} -{{^isFunctionReturn}} -{{#isFeatureFlag}} - get {{methodName}}() { - return this.prefab.isEnabled('{{key}}'); - } -{{/isFeatureFlag}} -{{^isFeatureFlag}} - get {{methodName}}() { - return this.prefab.get('{{key}}') as {{returnType}}; - } -{{/isFeatureFlag}} -{{/isFunctionReturn}} \ No newline at end of file diff --git a/src/codegen/templates/react-schema.mustache b/src/codegen/templates/react-schema.mustache deleted file mode 100644 index 3e16fc5..0000000 --- a/src/codegen/templates/react-schema.mustache +++ /dev/null @@ -1 +0,0 @@ -"{{key}}": {{{zodType}}} \ No newline at end of file diff --git a/src/codegen/templates/react.mustache b/src/codegen/templates/react.mustache deleted file mode 100644 index 390ddbf..0000000 --- a/src/codegen/templates/react.mustache +++ /dev/null @@ -1,34 +0,0 @@ -// AUTOGENERATED CODE - DO NOT EDIT -// AUTOGENERATED with prefab-cli's gen command -import { Prefab } from "@prefab-cloud/prefab-cloud-js"; -import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react"; -import { z } from "zod"; - -{{{dependencies}}} - -// Generated parameter schemas for methods that use Mustache templates -{{#accessorMethods}} -{{#paramsSchema}} -const {{paramsSchemaName}} = {{{paramsSchema}}}; -{{/paramsSchema}} -{{/accessorMethods}} - -export const prefabSchema = z.object({ - {{{schemaLines}}} -}); - -export type PrefabConfig = z.infer; - -export class PrefabTypesafe { - constructor(private prefab: Prefab) { } - - get(key: K): PrefabConfig[K] { - const value = this.prefab.get(key); - return prefabSchema.shape[key].parse(value) as PrefabConfig[K]; - } - - {{{accessorMethods}}} -} - -// Create a pre-typed hook specifically for PrefabTypesafe -export const usePrefabConfig = createPrefabHook(PrefabTypesafe); diff --git a/src/codegen/templates/ruby-accessor.mustache b/src/codegen/templates/ruby-accessor.mustache deleted file mode 100644 index cde6ecd..0000000 --- a/src/codegen/templates/ruby-accessor.mustache +++ /dev/null @@ -1,4 +0,0 @@ - def self.{{methodName}}(default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED) - raw = self.get('{{key}}', default, jit_context) - return {{{returnValue}}} - end \ No newline at end of file diff --git a/src/codegen/templates/ruby-schema.mustache b/src/codegen/templates/ruby-schema.mustache deleted file mode 100644 index 3e16fc5..0000000 --- a/src/codegen/templates/ruby-schema.mustache +++ /dev/null @@ -1 +0,0 @@ -"{{key}}": {{{zodType}}} \ No newline at end of file diff --git a/src/codegen/templates/ruby.mustache b/src/codegen/templates/ruby.mustache deleted file mode 100644 index 989a320..0000000 --- a/src/codegen/templates/ruby.mustache +++ /dev/null @@ -1,11 +0,0 @@ -# AUTOGENERATED CODE - DO NOT EDIT -# AUTOGENERATED with prefab-cli's gen command - -NO_DEFAULT_PROVIDED = :no_default_provided -class PrefabTypesafe - def self.get(key, default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED) - Prefab.get(key, default, jit_context) - end - -{{{accessorMethods}}} -end \ No newline at end of file diff --git a/src/codegen/templates/typescript-accessor.mustache b/src/codegen/templates/typescript-accessor.mustache deleted file mode 100644 index 19228ca..0000000 --- a/src/codegen/templates/typescript-accessor.mustache +++ /dev/null @@ -1,4 +0,0 @@ - {{methodName}}(contexts?: Contexts | ContextObj){{#isFunctionReturn}}{{#params}}: (params: {{params}}) => {{/params}}{{/isFunctionReturn}}{{^isFunctionReturn}}: {{/isFunctionReturn}}{{{returnType}}} { - const raw = this.get('{{key}}', contexts); - return {{{returnValue}}}; - } \ No newline at end of file diff --git a/src/codegen/templates/typescript-schema.mustache b/src/codegen/templates/typescript-schema.mustache deleted file mode 100644 index 3e16fc5..0000000 --- a/src/codegen/templates/typescript-schema.mustache +++ /dev/null @@ -1 +0,0 @@ -"{{key}}": {{{zodType}}} \ No newline at end of file diff --git a/src/codegen/templates/typescript.mustache b/src/codegen/templates/typescript.mustache deleted file mode 100644 index 6d12bd7..0000000 --- a/src/codegen/templates/typescript.mustache +++ /dev/null @@ -1,65 +0,0 @@ -// AUTOGENERATED CODE - DO NOT EDIT -// AUTOGENERATED with prefab-cli's gen command -import { z } from "zod"; -import { Prefab, Contexts } from "@prefab-cloud/prefab-cloud-node"; -import Mustache from 'mustache'; -type ContextObj = Record>; - -type ZodRawShape = Record; - -/** - * Creates a schema where all fields are optional but throw when accessing an undefined value. - * This ensures we can parse a partial object and access provided values. It will throw when - * missing values are accessed. - */ -function optionalRequiredAccess(shape: T) { - // Make all fields optional - const optionalShape: ZodRawShape = {}; - - for (const key in shape) { - optionalShape[key] = shape[key]; - } - - // Create the schema with optional fields and apply a proxy to throw when accessing a missing value - return z.object(optionalShape).transform(obj => { - return new Proxy(obj, { - get(target: Record, prop: string | symbol) { - // Handle special symbols for JS runtime - if (typeof prop === 'symbol') { - return Reflect.get(target, prop); - } - - // For regular string properties - if (prop in target && target[prop] !== undefined) { - return target[prop]; - } - - throw new Error(`Property ${String(prop)} is required but was not provided`); - } - }); - }); -} - -// Generated parameter schemas for methods that use Mustache templates -{{#accessorMethods}} -{{#paramsSchema}} -const {{paramsSchemaName}} = {{{paramsSchema}}}; -{{/paramsSchema}} -{{/accessorMethods}} - -export const prefabSchema = z.object({ - {{{schemaLines}}} -}); - -export type PrefabConfig = z.infer; - -export class PrefabTypesafe { - constructor(private prefab: Prefab) { } - - get(key: K, contexts?: Contexts | ContextObj): PrefabConfig[K] { - const value = this.prefab.get(key, contexts); - return prefabSchema.shape[key].parse(value) as PrefabConfig[K]; - } - -{{{accessorMethods}}} -} \ No newline at end of file diff --git a/src/codegen/types.ts b/src/codegen/types.ts index 7cc6b71..ac64375 100644 --- a/src/codegen/types.ts +++ b/src/codegen/types.ts @@ -1,9 +1,7 @@ import {z} from 'zod' export enum SupportedLanguage { - Python = 'python', React = 'react', - Ruby = 'ruby', TypeScript = 'typescript', } diff --git a/src/codegen/zod-generator.ts b/src/codegen/zod-generator.ts deleted file mode 100644 index 3c3fda8..0000000 --- a/src/codegen/zod-generator.ts +++ /dev/null @@ -1,312 +0,0 @@ -import Mustache from 'mustache' -import fs from 'node:fs' -import path from 'node:path' -import {fileURLToPath} from 'node:url' - -import {BaseGenerator} from './code-generators/base-generator.js' -import {generatePythonClientCode} from './python/generator.js' -import {SchemaInferrer, SchemaWithProvidence} from './schema-inferrer.js' -import {type Config, type ConfigFile, SupportedLanguage} from './types.js' -import {ZodUtils} from './zod-utils.js' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) - -export interface AccessorMethod { - isFeatureFlag: boolean - isFunctionReturn: boolean - key: string - methodName: string - params: string - returnType: string - returnValue: string -} - -export interface SchemaLine { - key: string - schemaName: string - zodType: string -} - -export interface TemplateData { - accessorMethods: AccessorMethod[] - schemaLines: SchemaLine[] -} - -/** - * Generates typed code for configs using Zod for validation - */ -export class ZodGenerator extends BaseGenerator { - private dependencies: Set = new Set() - private language: SupportedLanguage - private methods: {[key: string]: AccessorMethod} = {} - private schemaInferrer: SchemaInferrer - - constructor( - language: SupportedLanguage, - configFile: ConfigFile, - log: (category: string | unknown, message?: unknown) => void, - ) { - super({configFile, log}) - this.language = language - this.schemaInferrer = new SchemaInferrer(log) - } - - get filename(): string { - return this.language === SupportedLanguage.Python - ? 'prefab.py' - : this.language === SupportedLanguage.Ruby - ? 'prefab.rb' - : 'prefab.ts' - } - - /** - * Generate code for the specified language - */ - generate(): string { - if (this.language === SupportedLanguage.Python) { - return generatePythonClientCode(this.configFile, this.schemaInferrer, 'PrefabTypedClient') - } - - // Get base template for the framework - const templateName = this.getTemplateNameForLanguage() - const templatePath = path.join(__dirname, 'templates', `${templateName}.mustache`) - - if (!fs.existsSync(templatePath)) { - throw new Error(`Template for language '${this.language}' not found at ${templatePath}`) - } - - const baseTemplate = fs.readFileSync(templatePath, 'utf8') - - // Filter configs based on type and sendToClientSdk for React - const filteredConfigs = this.configFile.configs - .filter((config) => config.configType === 'FEATURE_FLAG' || config.configType === 'CONFIG') - .filter((config) => config.rows.length > 0) - .filter( - (config) => - this.language !== SupportedLanguage.React || - config.configType === 'FEATURE_FLAG' || - config.sendToClientSdk === true, - ) - .sort((a, b) => a.key.localeCompare(b.key)) - - this.log('Exportable configs:', filteredConfigs.length) - - // Generate individual accessor methods - const accessorMethods = filteredConfigs.map((config) => this.renderAccessorMethod(config)).join('\n') - - // Generate individual schema lines - const schemaLines = filteredConfigs.map((config) => this.renderSchemaLine(config)).join(',\n ') - - // Render the base template with the generated content - const result = Mustache.render(baseTemplate, { - accessorMethods, - dependencies: this.renderDependencies(), - schemaLines, - }) - - return result - } - - /** - * Generate an accessor method for a single config - */ - generateAccessorMethod(config: Config): AccessorMethod { - const {schema: schemaObj} = this.schemaInferrer.zodForConfig(config, this.configFile, this.language) - const returnValue = ZodUtils.generateReturnValueCode(schemaObj, '', this.language) - - const paramsSchema = ZodUtils.paramsOf(schemaObj) - const params = paramsSchema ? ZodUtils.zodTypeToTypescript(paramsSchema) : '' - // For function return types, they should return a function taking params - const isFunction = schemaObj._def.typeName === 'ZodFunction' - this.log(schemaObj) - this.log(ZodUtils.zodTypeToTypescript(schemaObj)) - const returnType = isFunction - ? ZodUtils.zodTypeToTypescript(schemaObj._def.returns) - : ZodUtils.zodTypeToTypescript(schemaObj) - - const accessorMethod = this.massageAccessorMethodForLanguage(config, { - isFeatureFlag: config.configType === 'FEATURE_FLAG', - isFunctionReturn: isFunction, - key: config.key, - methodName: ZodUtils.keyToMethodName(config.key), - params, - returnType, - returnValue, - }) - - if (this.methods[accessorMethod.methodName]) { - throw new Error( - `Method '${accessorMethod.methodName}' is already registered. Prefab key ${config.key} conflicts with ${ - this.methods[accessorMethod.methodName].key - }`, - ) - } - - this.methods[accessorMethod.methodName] = accessorMethod - - return accessorMethod - } - - /** - * Generate a schema line for a single config - */ - generateSchemaLine(config: Config): SchemaLine { - const {providence, schema: simplified} = this.generateSimplifiedSchema(config) - const zodType = ZodUtils.zodToString(simplified, config.key, providence, this.language) - - return this.massageSchemaLineForLanguage(config, { - key: config.key, - schemaName: ZodUtils.keyToSchemaName(config.key), - zodType, - }) - } - - generateSimplifiedSchema(config: Config): SchemaWithProvidence { - const schemaObj = this.schemaInferrer.zodForConfig(config, this.configFile, this.language) - - return { - providence: schemaObj.providence, - schema: ZodUtils.simplifyFunctions(schemaObj.schema), - } - } - - /** - * Render a single accessor method for the given language - */ - renderAccessorMethod(config: Config): string { - const templateName = this.getTemplateNameForLanguage() - const templatePath = path.join(__dirname, 'templates', `${templateName}-accessor.mustache`) - - if (!fs.existsSync(templatePath)) { - throw new Error(`Accessor template for language '${this.language}' not found at ${templatePath}`) - } - - const template = fs.readFileSync(templatePath, 'utf8') - const accessorMethod = this.generateAccessorMethod(config) - - return Mustache.render(template, accessorMethod) - } - - /** - * Render a single schema line for the given language - */ - renderSchemaLine(config: Config): string { - const templateName = this.getTemplateNameForLanguage() - const templatePath = path.join(__dirname, 'templates', `${templateName}-schema.mustache`) - - if (!fs.existsSync(templatePath)) { - throw new Error(`Schema template for language '${this.language}' not found at ${templatePath}`) - } - - const template = fs.readFileSync(templatePath, 'utf8') - const schemaLine = this.generateSchemaLine(config) - - return Mustache.render(template, schemaLine) - } - - /** - * Get the template file name for the given language - */ - private getTemplateNameForLanguage(): string { - switch (this.language) { - case SupportedLanguage.Python: { - return 'python' - } - - case SupportedLanguage.React: { - return 'react' - } - - case SupportedLanguage.Ruby: { - return 'ruby' - } - - default: { - return 'typescript' - } - } - } - - /** - * Customize accessor method properties based on language requirements - */ - private massageAccessorMethodForLanguage(config: Config, accessorMethod: AccessorMethod): AccessorMethod { - if (accessorMethod.isFunctionReturn) { - this.dependencies.add('mustache') - } - - switch (this.language) { - case SupportedLanguage.TypeScript: { - if (config.valueType === 'DURATION') { - return { - ...accessorMethod, - returnType: 'number', - } - } - - break - } - - case SupportedLanguage.React: { - if (config.valueType === 'DURATION') { - this.dependencies.add('duration') - return { - ...accessorMethod, - returnType: 'PrefabDuration', - } - } - - break - } - } - - return accessorMethod - } - - /** - * Customize schema line properties based on language requirements - */ - private massageSchemaLineForLanguage(config: Config, schemaLine: SchemaLine): SchemaLine { - switch (this.language) { - case SupportedLanguage.TypeScript: { - if (config.valueType === 'DURATION') { - return { - ...schemaLine, - zodType: 'z.number()', - } - } - - break - } - - case SupportedLanguage.React: { - if (config.valueType === 'DURATION') { - this.dependencies.add('duration') - return { - ...schemaLine, - zodType: 'PrefabDurationSchema', - } - } - - break - } - } - - return schemaLine - } - - private renderDependencies(): string { - const templateName = this.getTemplateNameForLanguage() - return [...this.dependencies] - .map((dep) => { - const templatePath = path.join(__dirname, 'templates', `dependencies/${templateName}-${dep}.mustache`) - if (!fs.existsSync(templatePath)) { - throw new Error(`Dependency template for language '${this.language}' not found at ${templatePath}`) - } - - const template = fs.readFileSync(templatePath, 'utf8') - return Mustache.render(template, {}) - }) - .join('\n') - } -} diff --git a/src/codegen/zod-utils.ts b/src/codegen/zod-utils.ts deleted file mode 100644 index 47767d1..0000000 --- a/src/codegen/zod-utils.ts +++ /dev/null @@ -1,576 +0,0 @@ -import {z} from 'zod' - -import {SchemaWithProvidence} from './schema-inferrer.js' -import {SupportedLanguage} from './types.js' - -export const ZodUtils = { - /** - * Generate code for transforming raw data based on a Zod schema - * @param zodType The Zod schema - * @param propertyPath Current property path for nested properties - * @returns string representing the code to transform raw data - */ - generateReturnValueCode(zodType: z.ZodTypeAny, propertyPath: string = '', language: SupportedLanguage): string { - if (!zodType || !zodType._def) return 'raw' - - switch (zodType._def.typeName) { - case 'ZodString': - case 'ZodNumber': - case 'ZodBoolean': - case 'ZodNull': - case 'ZodUndefined': { - return propertyPath ? `raw${propertyPath}` : 'raw' - } - - case 'ZodArray': { - const elementCode = this.generateReturnValueCode(zodType._def.type, propertyPath, language) - if (elementCode === 'raw') { - return propertyPath ? `raw${propertyPath}` : 'raw' - } - - return propertyPath ? `raw${propertyPath}` : 'raw' - } - - case 'ZodObject': { - const shape = zodType._def.shape() - const props = [] - - for (const key in shape) { - if (Object.hasOwn(shape, key)) { - // Always use bracket notation for consistency and to handle all edge cases - const newPath = propertyPath ? `${propertyPath}["${key}"]` : `["${key}"]` - const propCode = this.generateReturnValueCode(shape[key], newPath, language) - - const outputKey = `"${key}"` - - if (shape[key]._def.typeName === 'ZodFunction') { - // Handle function within object directly - props.push(`${outputKey}: ${propCode}`) - } else if (propCode === `raw${newPath}`) { - // Simple passthrough for primitive types - props.push(`${outputKey}: raw${newPath}`) - } else { - // For complex types that aren't functions - props.push(`${outputKey}: ${propCode}`) - } - } - } - - if (props.length === 0) { - return propertyPath ? `raw${propertyPath}` : 'raw' - } - - return `{ ${props.join(', ')} }` - } - - case 'ZodOptional': { - const innerCode = this.generateReturnValueCode(zodType._def.innerType, propertyPath, language) - if (innerCode === `raw${propertyPath}`) { - return innerCode - } - - return innerCode - } - - case 'ZodFunction': { - // For functions, we need special handling based on context - const paramsSchema = this.paramsOf(zodType) - const paramsType = paramsSchema ? this.zodTypeToTypescript(paramsSchema) : '{}' - if (language === SupportedLanguage.TypeScript || language === SupportedLanguage.React) { - return `(params: ${paramsType}) => Mustache.render(raw${propertyPath} ?? "", params)` - } - - if (language === SupportedLanguage.Ruby) { - return `->(params) { Mustache.render(raw${propertyPath}, params)}` - } - - return `lambda params: pystache.render(raw${propertyPath}, params)` - } - - case 'ZodUnion': { - // For union types, we need to examine each option - const {options} = zodType._def - - // Check if any of the options are functions - const hasFunctions = options.some((t: z.ZodTypeAny) => t._def.typeName === 'ZodFunction') - - if (hasFunctions) { - // If we have functions in the union, we need to handle differently - // For simplicity, use the first function type in the union - for (const option of options) { - if (option._def.typeName === 'ZodFunction') { - return this.generateReturnValueCode(option, propertyPath, language) - } - } - } - - // If no functions or a simpler case, just return raw value - return propertyPath ? `raw${propertyPath}` : 'raw' - } - - default: { - return propertyPath ? `raw${propertyPath}` : 'raw' - } - } - }, - - /** - * Convert config key to a valid method name - * - * This function handles several transformations: - * 1. Replaces special characters with dots for word separation - * 2. Converts snake_case and kebab-case to camelCase within parts - * 3. Ensures the first character is valid for identifiers - * 4. Joins parts with camelCase (first part lowercase, subsequent parts capitalized) - */ - keyToMethodName(key: string): string { - if (!key || key.trim() === '') { - return '_empty_key' - } - - // Step 1: Replace spaces with dots for consistent handling - let processedKey = key.trim().replaceAll(/\s+/g, '.') - - // Step 2: Replace special characters with dots (except underscores and hyphens) - processedKey = processedKey.replaceAll(/[^\w.\--]/g, '.') - - // Step 3: Replace consecutive dots with a single dot - processedKey = processedKey.replaceAll(/\.{2,}/g, '.') - - // Step 4: Split by dots and process each part - const parts = processedKey.split('.').filter((part) => part.length > 0) - - if (parts.length === 0) { - return '_empty_key' - } - - // Step 5: Process each part - const processedParts = parts.map((part) => { - // Ensure part starts with an underscore if it begins with a digit - if (/^\d/.test(part)) { - part = '_' + part - } - - // Handle uppercase snake case patterns specifically - if (/^[\dA-Z_]+$/.test(part)) { - return part - .toLowerCase() - .split('_') - .filter((word) => word.length > 0) - .map((word, idx) => - // First word lowercase, subsequent words capitalized (camelCase) - idx === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1), - ) - .join('') - } - - // Convert kebab-case to camelCase - const kebabProcessed = part - .split('-') - .map((segment, idx) => - // First segment stays as is, subsequent segments get capitalized - idx === 0 ? segment : segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase(), - ) - .join('') - - // Convert snake_case to camelCase - const finalProcessed = kebabProcessed - .split('_') - .map((segment, idx) => - // First segment stays as is, subsequent segments get capitalized - idx === 0 ? segment : segment.charAt(0).toUpperCase() + segment.slice(1), - ) - .join('') - - return finalProcessed - }) - - // Step 6: Join parts with camelCase (first part as is, subsequent parts capitalized) - const result = processedParts - .map((part, index) => { - if (index === 0) { - return part - } - - return part.charAt(0).toUpperCase() + part.slice(1) - }) - .join('') - - // Step 7: Ensure the result is a safe identifier - return this.makeSafeIdentifier(result) - }, - - /** - * Convert a config key to a schema variable name - */ - keyToSchemaName(key: string): string { - // Convert key to method name and append Schema - return this.keyToMethodName(key) + 'Schema' - }, - - /** - * Ensure a string is a safe identifier for JavaScript and Python - */ - makeSafeIdentifier(identifier: string): string { - // Ensure it starts with a letter or underscore - let result = identifier - if (/^[^A-Z_a-z]/.test(result)) { - result = '_' + result - } - - // Replace invalid characters with underscores - // Note: $ is allowed in JavaScript but not in Python, so we explicitly replace it - result = result.replaceAll(/\W/g, '_') - - // Avoid Python reserved keywords - const pythonKeywords = [ - 'False', - 'None', - 'True', - 'and', - 'as', - 'assert', - 'async', - 'await', - 'break', - 'class', - 'continue', - 'def', - 'del', - 'elif', - 'else', - 'except', - 'finally', - 'for', - 'from', - 'global', - 'if', - 'import', - 'in', - 'is', - 'lambda', - 'nonlocal', - 'not', - 'or', - 'pass', - 'raise', - 'return', - 'try', - 'while', - 'with', - 'yield', - ] - - if (pythonKeywords.includes(result)) { - result += '_' - } - - return result - }, - - objectTypeForLanguage( - language: SupportedLanguage, - providence: SchemaWithProvidence['providence'], - props: string, - hasArrayParent: boolean = false, - ): string { - if (language === SupportedLanguage.TypeScript && providence === 'inferred' && !hasArrayParent) { - return `optionalRequiredAccess({${props}})` - } - - return `z.object({${props}})` - }, - - /** - * Extract parameter schema from a Zod function schema - * Replace all optionals with their non-optional types, we want users to have to pass all params. - * @param schema The Zod schema - * @returns The parameters schema if it's a function, or undefined otherwise - */ - paramsOf(schema: z.ZodTypeAny): undefined | z.ZodTypeAny { - if (!schema || !schema._def) return undefined - - // Check if this is a function schema - if (schema._def.typeName === 'ZodFunction') { - // Get the args schema (which is actually a ZodTuple) - const argsSchema = schema._def.args - - // If it's a tuple with a single item, extract that item - if (argsSchema._def.typeName === 'ZodTuple' && argsSchema._def.items && argsSchema._def.items.length === 1) { - const paramSchema = argsSchema._def.items[0] - - // If the parameter is an object, process its properties - if (paramSchema._def.typeName === 'ZodObject') { - const shape = paramSchema._def.shape() - const newShape: Record = {} - - // Remove optionality from all properties - for (const key in shape) { - if (Object.hasOwn(shape, key)) { - const propType = shape[key] as z.ZodTypeAny - newShape[key] = propType._def.typeName === 'ZodOptional' ? propType._def.innerType : propType - } - } - - return z.object(newShape) - } - - return paramSchema - } - - // Otherwise return the args schema as is - return argsSchema - } - - // Not a function, return undefined - return undefined - }, - - /** - * Simplify a Zod schema by replacing function types with their return types - */ - simplifyFunctions(schema: z.ZodTypeAny): z.ZodTypeAny { - if (!schema || !schema._def) return schema - - // Check for ZodFunction type - if (schema._def.typeName === 'ZodFunction') { - // Replace function with its return type - return this.simplifyFunctions(schema._def.returns) - } - - // Handle ZodObject recursively - if (schema._def.typeName === 'ZodObject') { - const shape = schema._def.shape() - const newShape: Record = {} - - // Process each property - for (const key in shape) { - if (Object.hasOwn(shape, key)) { - newShape[key] = this.simplifyFunctions(shape[key]) - } - } - - return z.object(newShape) - } - - // Handle ZodArray recursively - if (schema._def.typeName === 'ZodArray') { - const elementType = this.simplifyFunctions(schema._def.type) - return z.array(elementType) - } - - // Handle ZodOptional recursively - if (schema._def.typeName === 'ZodOptional') { - const innerType = this.simplifyFunctions(schema._def.innerType) - return z.optional(innerType) - } - - // Handle ZodUnion recursively - if (schema._def.typeName === 'ZodUnion') { - const options = schema._def.options.map((option: z.ZodTypeAny) => this.simplifyFunctions(option)) - return z.union(options) - } - - // For all other types, return as is - return schema - }, - - /** - * Convert a Zod schema to its string representation - */ - zodToString( - schema: z.ZodType, - key: string, - providence: SchemaWithProvidence['providence'], - language: SupportedLanguage, - hasArrayParent: boolean = false, - ): string { - // Keep using any for internal properties that aren't exposed in the type definitions - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const def = schema._def as any - - // Check for primitive types - if (def.typeName === 'ZodString') { - return 'z.string()' - } - - if (def.typeName === 'ZodNumber') { - // Check if this is an integer by examining the checks array - if (def.checks && Array.isArray(def.checks)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const hasIntCheck = def.checks.some((check: any) => check.kind === 'int') - if (hasIntCheck) { - return 'z.number().int()' - } - } - - return 'z.number()' - } - - if (def.typeName === 'ZodBoolean') { - return 'z.boolean()' - } - - if (def.typeName === 'ZodNull') { - return 'z.null()' - } - - if (def.typeName === 'ZodUndefined') { - return 'z.undefined()' - } - - if (def.typeName === 'ZodArray') { - const innerType = this.zodToString(def.type, key, providence, language, true) - return `z.array(${innerType})` - } - - // Handle ZodOptional - if (def.typeName === 'ZodOptional') { - const innerType = this.zodToString(def.innerType, key, providence, language, hasArrayParent) - return `${innerType}.optional()` - } - - // Handle ZodUnion - if (def.typeName === 'ZodUnion') { - const options = def.options.map((option: z.ZodType) => - this.zodToString(option, key, providence, language, hasArrayParent), - ) - return `z.union([${options.join(', ')}])` - } - - // Handle ZodFunction - if (def.typeName === 'ZodFunction') { - // Handle the arguments - const argsSchema = def.args - const returnsSchema = def.returns - - return `z.function().args(${this.zodToString(argsSchema, key, providence, language, hasArrayParent)}).returns(${this.zodToString( - returnsSchema, - key, - providence, - language, - hasArrayParent, - )})` - } - - // Handle ZodTuple (used for function args) - if (def.typeName === 'ZodTuple') { - if (def.items && def.items.length === 1) { - return this.zodToString(def.items[0], key, providence, language, hasArrayParent) - } - - return this.zodToString(def.items[0], key, providence, language, hasArrayParent) // Just take the first item for simplicity - } - - // Handle ZodObject - if (def.typeName === 'ZodObject') { - const shape = def.shape() - const props = Object.entries(shape) - .map( - ([key, value]) => - `${key}: ${this.zodToString(value as z.ZodTypeAny, key, providence, language, hasArrayParent)}`, - ) - .join(', ') - - return this.objectTypeForLanguage(language, providence, props, hasArrayParent) - } - - if (def.typeName === 'ZodEnum') { - const values = def.values.map((v: string) => `'${v}'`).join(',') - return `z.enum([${values}])` - } - - if (def.typeName === 'ZodUnknown') { - return 'z.unknown()' - } - - console.warn(`Unknown zod type for ${key}:`, schema) - return 'z.any()' - }, - - // Convert a Zod type to its TypeScript equivalent - zodTypeToTypescript(zodType: z.ZodTypeAny): string { - if (!zodType || !zodType._def) return 'any' - - switch (zodType._def.typeName) { - case 'ZodString': { - return 'string' - } - - case 'ZodNumber': { - return 'number' - } - - case 'ZodBoolean': { - return 'boolean' - } - - case 'ZodOptional': { - return `${this.zodTypeToTypescript(zodType._def.innerType)}?` - } - - case 'ZodNull': { - return 'null' - } - - case 'ZodUndefined': { - return 'undefined' - } - - case 'ZodArray': { - const innerType = this.zodTypeToTypescript(zodType._def.type) - return `${innerType}[]` - } - - case 'ZodObject': { - const shape = zodType._def.shape() - const props = [] - for (const key in shape) { - if (Object.hasOwn(shape, key)) { - const propType = shape[key] - const isOptional = propType._def.typeName === 'ZodOptional' - const typeStr = isOptional - ? this.zodTypeToTypescript(propType._def.innerType) - : this.zodTypeToTypescript(propType) - props.push(`${key}${isOptional ? '?' : ''}: ${typeStr}`) - } - } - - return `{ ${props.join('; ')} }` - } - - case 'ZodEnum': { - const options = zodType._def.values - return options.map((o: string) => `'${o}'`).join(' | ') - } - - case 'ZodUnion': { - const unionTypes = zodType._def.options.map((t: z.ZodTypeAny) => { - const typeString = this.zodTypeToTypescript(t) - // If it's a function type (contains => notation), wrap it in parentheses - if (typeString.includes('=>')) { - return `(${typeString})` - } - - return typeString - }) - return unionTypes.join(' | ') - } - - case 'ZodFunction': { - const paramsSchema = this.paramsOf(zodType) - const paramsType = paramsSchema ? this.zodTypeToTypescript(paramsSchema) : '{}' - const returnType = this.zodTypeToTypescript(zodType._def.returns) - return `(params: ${paramsType}) => ${returnType}` - } - - case 'ZodUnknown': { - return 'unknown' - } - - default: { - return 'any' - } - } - }, -} diff --git a/src/commands/generate.ts b/src/commands/generate.ts index abe7b2b..6a29465 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -7,7 +7,6 @@ import {NodeTypeScriptGenerator} from '../codegen/code-generators/node-typescrip import {ReactTypeScriptGenerator} from '../codegen/code-generators/react-typescript-generator.js' import {ConfigDownloader} from '../codegen/config-downloader.js' import {type ConfigFile, SupportedLanguage} from '../codegen/types.js' -import {ZodGenerator} from '../codegen/zod-generator.js' import {APICommand} from '../index.js' import {createFileManager} from '../util/file-manager.js' @@ -19,7 +18,6 @@ export default class Generate extends APICommand { static examples = [ '<%= config.bin %> <%= command.id %> --target node-ts', '<%= config.bin %> <%= command.id %> --target react-ts --output-dir custom/path', - '<%= config.bin %> <%= command.id %> --target python', ] static flags = { @@ -30,7 +28,7 @@ export default class Generate extends APICommand { target: Flags.string({ default: 'node-ts', description: 'language/framework to generate code for', - options: ['node-ts', 'react-ts', 'python-pydantic', 'ruby'], + options: ['node-ts', 'react-ts'], }), } @@ -81,17 +79,11 @@ export default class Generate extends APICommand { return new NodeTypeScriptGenerator({configFile, log: this.verboseLog}) case SupportedLanguage.React: return new ReactTypeScriptGenerator({configFile, log: this.verboseLog}) - default: - return new ZodGenerator(language, configFile, this.verboseLog.bind(this)) } } private resolveLanguage(languageTarget: string | undefined): SupportedLanguage { switch (languageTarget?.toLowerCase()) { - case 'python-pydantic': { - return SupportedLanguage.Python - } - case 'react-ts': { return SupportedLanguage.React } @@ -100,10 +92,6 @@ export default class Generate extends APICommand { return SupportedLanguage.TypeScript } - case 'ruby': { - return SupportedLanguage.Ruby - } - default: { throw new Error(`Unsupported target: ${languageTarget}`) } diff --git a/test/codegen/python/generator.test.ts b/test/codegen/python/generator.test.ts deleted file mode 100644 index bccfae9..0000000 --- a/test/codegen/python/generator.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import {expect} from 'chai' - -import {generatePythonClientCode} from '../../../src/codegen/python/generator.js' -import {SchemaInferrer} from '../../../src/codegen/schema-inferrer.js' -import {type Config, type ConfigFile, SupportedLanguage} from '../../../src/codegen/types.js' - -describe('Python Generator Integration', () => { - it('should generate Python code from config files', () => { - // Create a mock config file with different types of configs - const mockConfigFile: ConfigFile = { - configs: [ - { - configType: 'FEATURE_FLAG', - key: 'feature_enabled', - rows: [ - { - values: [{value: {bool: true}}], - }, - ], - valueType: 'BOOL', - }, - { - configType: 'CONFIG', - key: 'api_url', - rows: [ - { - values: [{value: {string: 'https://api.example.com'}}], - }, - ], - valueType: 'STRING', - }, - { - configType: 'CONFIG', - key: 'timeout_seconds', - rows: [ - { - values: [{value: {int: 30}}], - }, - ], - valueType: 'INT', - }, - { - configType: 'CONFIG', - key: 'rate_limits', - rows: [ - { - values: [ - { - value: { - json: { - json: JSON.stringify({ - enterprise: 5000, - premium: 500, - standard: 100, - }), - }, - }, - }, - ], - }, - ], - valueType: 'JSON', - }, - { - configType: 'CONFIG', - key: 'allowed_origins', - rows: [ - { - values: [ - { - value: { - // @ts-expect-error: The local ConfigValue interface doesn't have string_list but the Python generator expects it - string_list: {values: ['localhost', 'example.com', 'api.example.com']}, - }, - }, - ], - }, - ], - valueType: 'STRING_LIST', - }, - ], - } - - // Mock SchemaInferrer - use a simple implementation that returns appropriate schemas - const mockSchemaInferrer = { - zodForConfig(config: Config, _configFile: ConfigFile, _language: SupportedLanguage) { - if (config.key === 'feature_enabled') { - return {providence: 'inferred', schema: {_def: {typeName: 'ZodBoolean'}}} // Mock boolean schema - } - - if (config.key === 'api_url') { - return {providence: 'inferred', schema: {_def: {typeName: 'ZodString'}}} // Mock string schema - } - - if (config.key === 'timeout_seconds') { - return {providence: 'inferred', schema: {_def: {checks: [{kind: 'int'}], typeName: 'ZodNumber'}}} // Mock integer schema - } - - if (config.key === 'rate_limits') { - return {providence: 'inferred', schema: {_def: {typeName: 'ZodObject'}}} // Mock object schema - } - - if (config.key === 'allowed_origins') { - return { - providence: 'inferred', - schema: {_def: {element: {_def: {typeName: 'ZodString'}}, typeName: 'ZodArray'}}, - } // Mock string array schema - } - - if (config.key === 'feature.enabled') { - return {providence: 'inferred', schema: {_def: {checks: [{kind: 'int'}], typeName: 'ZodNumber'}}} // Mock integer schema - } - - return {providence: 'inferred', schema: {_def: {typeName: 'ZodUnknown'}}} - }, - } as unknown as SchemaInferrer - - // Generate the Python code - const generatedCode = generatePythonClientCode(mockConfigFile, mockSchemaInferrer, 'PrefabClient') - - // Verify key parts of the generated code - expect(generatedCode).to.include('class PrefabClient:') - - // Check for methods - expect(generatedCode).to.include('def feature_enabled(self') - expect(generatedCode).to.include('def api_url(self') - expect(generatedCode).to.include('def timeout_seconds(self') - expect(generatedCode).to.include('def rate_limits(self') - expect(generatedCode).to.include('def allowed_origins(self') - - // Check for return types - expect(generatedCode).to.include('-> bool:') - expect(generatedCode).to.include('-> str:') - expect(generatedCode).to.include('-> int:') - expect(generatedCode).to.include('-> List[str]:') - - // Check for value type handling - expect(generatedCode).to.include('if isinstance(config_value, bool):') - expect(generatedCode).to.include('if isinstance(config_value, str):') - expect(generatedCode).to.include('if isinstance(config_value, int):') - expect(generatedCode).to.include( - 'if isinstance(config_value, list) and all(isinstance(x, str) for x in config_value):', - ) - - // Check for ConfigValue field handling - expect(generatedCode).to.include('if isinstance(config_value, bool):') - expect(generatedCode).to.include('if isinstance(config_value, str):') - expect(generatedCode).to.include('if isinstance(config_value, int):') - expect(generatedCode).to.include('return config_value') - - // Check for appropriate imports - expect(generatedCode).to.include('from typing import') - expect(generatedCode).to.include('import logging') - expect(generatedCode).to.include('import prefab_cloud_python') - expect(generatedCode).to.include('from prefab_cloud_python import') - expect(generatedCode).to.include('Context') - }) - - it('throws if method names conflict', () => { - // Create a mock config file with different types of configs - const mockConfigFile: ConfigFile = { - configs: [ - { - configType: 'FEATURE_FLAG', - key: 'feature_enabled', - rows: [ - { - values: [{value: {bool: true}}], - }, - ], - valueType: 'BOOL', - }, - { - configType: 'CONFIG', - key: 'api_url', - rows: [ - { - values: [{value: {string: 'https://api.example.com'}}], - }, - ], - valueType: 'STRING', - }, - { - configType: 'CONFIG', - key: 'feature.enabled', - rows: [ - { - values: [{value: {int: 30}}], - }, - ], - valueType: 'INT', - }, - ], - } - - const mockSchemaInferrer = { - zodForConfig(config: Config, _configFile: ConfigFile, _language: SupportedLanguage) { - if (config.key === 'feature_enabled') { - return {providence: 'inferred', schema: {_def: {typeName: 'ZodBoolean'}}} // Mock boolean schema - } - - if (config.key === 'api_url') { - return {providence: 'inferred', schema: {_def: {typeName: 'ZodString'}}} // Mock string schema - } - - if (config.key === 'feature.enabled') { - return {providence: 'inferred', schema: {_def: {checks: [{kind: 'int'}], typeName: 'ZodNumber'}}} // Mock integer schema - } - - return {providence: 'inferred', schema: {_def: {typeName: 'ZodUnknown'}}} - }, - } as unknown as SchemaInferrer - - expect(() => generatePythonClientCode(mockConfigFile, mockSchemaInferrer)).to.throw( - `Unable to generate method 'feature_enabled' for config key 'feature.enabled' because it has already been generated for config key 'feature_enabled'.`, - ) - }) -}) diff --git a/test/codegen/python/pydantic-generator.test.ts b/test/codegen/python/pydantic-generator.test.ts deleted file mode 100644 index 3d55628..0000000 --- a/test/codegen/python/pydantic-generator.test.ts +++ /dev/null @@ -1,891 +0,0 @@ -import chai, {expect} from 'chai' -import {z} from 'zod' - -import {UnifiedPythonGenerator} from '../../../src/codegen/python/pydantic-generator.js' -import {ZodUtils} from '../../../src/codegen/zod-utils.js' - -describe('UnifiedPythonGenerator', () => { - let generator: UnifiedPythonGenerator - chai.config.truncateThreshold = 0 // Disables truncation completely - - beforeEach(() => { - generator = new UnifiedPythonGenerator({ - className: 'TestClient', - }) - }) - - describe('Basic type handling', () => { - it('should identify basic types correctly', () => { - expect(generator.isBasicType('str')).to.be.true - expect(generator.isBasicType('int')).to.be.true - expect(generator.isBasicType('float')).to.be.true - expect(generator.isBasicType('bool')).to.be.true - expect(generator.isBasicType('datetime.datetime')).to.be.true - expect(generator.isBasicType('List[str]')).to.be.true - - expect(generator.isBasicType('Dict[str, Any]')).to.be.false - expect(generator.isBasicType('CustomModel')).to.be.false - }) - - it('should register basic types correctly', () => { - expect(generator.registerModel(z.string(), 'MyString')).to.equal('str') - expect(generator.registerModel(z.number().int(), 'MyInt')).to.equal('int') - expect(generator.registerModel(z.number(), 'MyFloat')).to.equal('float') - expect(generator.registerModel(z.boolean(), 'MyBool')).to.equal('bool') - }) - }) - - describe('Method generation', () => { - it('should generate a method for a boolean type', () => { - const methodCode = generator.generateMethodCode('is_feature_enabled', { - docstring: 'Check if feature is enabled', - originalKey: 'is_feature_enabled', - params: [], - returnType: 'bool', - valueType: 'BOOL', - }) - - expect(methodCode).to.include( - 'def is_feature_enabled(self, context: Optional[ContextDictOrContext] = None, fallback: Optional[bool] = None) -> Optional[bool]:', - ) - expect(methodCode).to.include('"""') - expect(methodCode).to.include('Check if feature is enabled') - expect(methodCode).to.include('config_value = self.client.get("is_feature_enabled", context=context)') - expect(methodCode).to.include('if isinstance(config_value, bool):') - expect(methodCode).to.include('return config_value') - }) - - it('should generate a method for a float type', () => { - const methodCode = generator.generateMethodCode('get_conversion_rate', { - docstring: 'Get conversion rate', - originalKey: 'get_conversion_rate', - params: [], - returnType: 'float', - valueType: 'DOUBLE', - }) - - expect(methodCode).to.include( - 'def get_conversion_rate(self, context: Optional[ContextDictOrContext] = None, fallback: Optional[float] = None) -> Optional[float]:', - ) - expect(methodCode).to.include('"""') - expect(methodCode).to.include('Get conversion rate') - expect(methodCode).to.include('config_value = self.client.get("get_conversion_rate", context=context)') - expect(methodCode).to.include('if isinstance(config_value, (int, float)):') - expect(methodCode).to.include('return float(config_value)') - }) - - it('should generate a method for a string type', () => { - const methodCode = generator.generateMethodCode('get_api_url', { - docstring: 'Get the API URL', - originalKey: 'get_api_url', - params: [], - returnType: 'str', - valueType: 'STRING', - }) - - expect(methodCode).to.include( - 'def get_api_url(self, context: Optional[ContextDictOrContext] = None, fallback: Optional[str] = None) -> Optional[str]:', - ) - expect(methodCode).to.include('"""') - expect(methodCode).to.include('Get the API URL') - expect(methodCode).to.include('config_value = self.client.get("get_api_url", context=context)') - expect(methodCode).to.include('if isinstance(config_value, str):') - expect(methodCode).to.include('raw = config_value') - expect(methodCode).to.include('return raw') - }) - - it('should generate a method for a string list type', () => { - const methodCode = generator.generateMethodCode('get_allowed_domains', { - docstring: 'Get allowed domains', - originalKey: 'get_allowed_domains', - params: [], - returnType: 'List[str]', - valueType: 'STRING_LIST', - }) - - expect(methodCode).to.include( - 'def get_allowed_domains(self, context: Optional[ContextDictOrContext] = None, fallback: Optional[List[str]] = None) -> Optional[List[str]]:', - ) - expect(methodCode).to.include('"""') - expect(methodCode).to.include('Get allowed domains') - expect(methodCode).to.include('config_value = self.client.get("get_allowed_domains", context=context)') - expect(methodCode).to.include( - 'if isinstance(config_value, list) and all(isinstance(x, str) for x in config_value):', - ) - expect(methodCode).to.include('return config_value') - }) - - it('should generate a method for a custom model', () => { - generator.registerModel( - z.object({ - port: z.number().int(), - timeout: z.number(), - url: z.string(), - }), - 'ServiceConfig', - ) - - const methodCode = generator.generateMethodCode('get_service_config', { - docstring: 'Get the service configuration', - originalKey: 'get_service_config', - params: [], - returnType: 'ServiceConfig', - valueType: 'JSON', - }) - - expect(methodCode).to.include( - "def get_service_config(self, context: Optional[ContextDictOrContext] = None, fallback: Optional['TestClient.ServiceConfig'] = None) -> Optional['TestClient.ServiceConfig']:", - ) - expect(methodCode).to.include('if isinstance(config_value, dict):') - expect(methodCode).to.include('return self.ServiceConfig(**config_value)') - }) - - it('should generate methods with parameters', () => { - const methodCode = generator.generateMethodCode('get_user_preference', { - docstring: 'Get user preference', - originalKey: 'get_user_preference', - params: [ - {default: 'None', name: 'user_id', type: 'str'}, - {default: '"default"', name: 'preference_type', type: 'str'}, - ], - returnType: 'str', - valueType: 'STRING', - }) - - expect(methodCode).to.include( - 'def get_user_preference(self, user_id: str = None, preference_type: str = "default", context: Optional[ContextDictOrContext] = None, fallback: Optional[str] = None) -> Optional[str]:', - ) - expect(methodCode).to.include('user_id: Description of user_id') - expect(methodCode).to.include('preference_type: Description of preference_type') - }) - - it('should generate method code with template parameters', () => { - // Generate a method with template parameters - const methodCode = generator.generateMethodCode('getGreetingTemplate', { - docstring: 'Get a greeting template', - hasTemplateParams: true, - originalKey: 'getGreetingTemplate', - paramClassName: 'GreetingTemplateParams', - params: [], - returnType: 'str', - valueType: 'STRING', - }) - - // Check method signature includes params parameter with properly qualified class name - expect(methodCode).to.include( - "def getGreetingTemplate(self, params: Optional['TestClient.GreetingTemplateParams'] = None", - ) - - // Check method documentation - expect(methodCode).to.include('params: Parameters for template rendering') - - // Check value extraction with template rendering - expect(methodCode).to.include('if isinstance(config_value, str):') - expect(methodCode).to.include('raw = config_value') - expect(methodCode).to.include('return pystache.render(raw, params.__dict__) if params else raw') - - // Check that the docstring includes the explanation about template rendering behavior - expect(methodCode).to.include('Returns:') - - // Update these assertions to match exact formatting in the code - const returnsSection = methodCode.split('Returns:')[1].split('\n """')[0].trim() - expect(returnsSection).to.include('str: The configuration value') - expect(returnsSection).to.include("If 'params' is provided, returns the template rendered with those parameters.") - expect(returnsSection).to.include("If 'params' is None, returns the raw template string without rendering.") - }) - }) - - describe('Value extraction', () => { - it('should generate extraction code for basic types with value type', () => { - const boolExtraction = generator.generateValueExtraction('bool', true, 'BOOL') - expect(boolExtraction).to.include('if isinstance(config_value, bool):') - expect(boolExtraction).to.include('return config_value') - - const intExtraction = generator.generateValueExtraction('int', true, 'INT') - expect(intExtraction).to.include('if isinstance(config_value, int):') - expect(intExtraction).to.include('return config_value') - - const strExtraction = generator.generateValueExtraction('str', true, 'STRING') - expect(strExtraction).to.include('if isinstance(config_value, str):') - expect(strExtraction).to.include('raw = config_value') - expect(strExtraction).to.include('return raw') - }) - - it('should generate extraction code for complex types', () => { - const extraction = generator.generateValueExtraction('UserSettings', false, 'JSON') - expect(extraction).to.include('if isinstance(config_value, dict):') - expect(extraction).to.include('return self.UserSettings(**config_value)') - }) - - it('should generate value extraction with template support', () => { - // Test string extraction with template parameters - const extractionCode = generator.generateValueExtraction('str', true, 'STRING', true) - - expect(extractionCode).to.include('if isinstance(config_value, str):') - expect(extractionCode).to.include('raw = config_value') - expect(extractionCode).to.include('return pystache.render(raw, params.__dict__) if params else raw') - - // Test string extraction without template parameters - const normalExtractionCode = generator.generateValueExtraction('str', true, 'STRING', false) - - expect(normalExtractionCode).to.include('if isinstance(config_value, str):') - expect(normalExtractionCode).to.include('raw = config_value') - expect(normalExtractionCode).to.include('return raw') - expect(normalExtractionCode).not.to.include('pystache.render') - }) - }) - - describe('Import calculation', () => { - it('should calculate base imports correctly', () => { - // Test with empty methods - const {imports, needsJson, typingImports} = generator.calculateNeededImports() - - expect(imports).to.include('import logging') - expect(imports).to.include('from prefab_cloud_python import Client, Context, ContextDictOrContext') - expect(imports).to.include('import prefab_cloud_python') - - expect(typingImports).to.include('Optional') - - expect(needsJson).to.be.false - }) - - it('should add imports for complex types', () => { - // Register a complex model method - const userSchema = z.object({ - email: z.string().email(), - id: z.number().int(), - isActive: z.boolean(), - name: z.string(), - }) - - generator.registerMethod('get_user', userSchema, 'User', [], 'Get user data', 'JSON') - - const {imports, needsJson} = generator.calculateNeededImports() - - // Should include pydantic imports - expect(imports).to.include('from pydantic import BaseModel, ValidationError') - // The needsJson flag should be false as JSON parsing is handled by the prefab-cloud-python client - expect(needsJson).to.be.false - }) - - it('should add imports for specialized types', () => { - // Register methods with specialized types - generator.registerMethod('get_timeout', z.number(), 'Timeout', [], 'Get timeout', 'DOUBLE') - generator.registerMethod('get_allowed_domains', z.array(z.string()), 'Domains', [], 'Get domains', 'STRING_LIST') - - // Ensure we register a date method with DATE valueType to trigger datetime import - generator.registerMethod('get_timestamp', z.date(), 'Timestamp', [], 'Get timestamp', 'DATE') - - const {imports, typingImports} = generator.calculateNeededImports() - - // Check for List import - expect(typingImports).to.include('List') - - // Check for datetime import - expect(imports).to.include('from datetime import datetime') - }) - }) - - describe('Model generation', () => { - it('should generate a Pydantic model for an object schema', () => { - const userSchema = z.object({ - email: z.string().email(), - id: z.number().int(), - isActive: z.boolean(), - name: z.string(), - }) - - const modelCode = generator.generatePydanticModel(userSchema, 'User') - - expect(modelCode).to.include('class User(BaseModel):') - expect(modelCode).to.include('id: int') - expect(modelCode).to.include('name: str') - expect(modelCode).to.include('email: str') - expect(modelCode).to.include('isActive: bool') - }) - - it('should generate a wrapper model for non-object schemas', () => { - const listSchema = z.array(z.string()) - const modelCode = generator.generatePydanticModel(listSchema, 'StringList') - - expect(modelCode).to.include('class StringList(BaseModel):') - expect(modelCode).to.include('value: List[str]') - }) - }) - - describe('Client generation', () => { - it('should generate a client class with methods', () => { - // Register a method - generator.registerMethod('getConfig', z.object({key: z.string()}), undefined, [], 'Get config', 'JSON') - - // Generate the Python code - const pythonCode = generator.generatePythonFile() - - // Verify the client class is generated - expect(pythonCode).to.include('class TestClient:') - expect(pythonCode).to.include('def get_config') - expect(pythonCode).to.include('def get_config_with_fallback_value') - }) - }) - - describe('Full file generation', () => { - it('should generate a complete Python file', () => { - // Register some methods with different types - generator.registerMethod( - 'is_feature_enabled', - z.boolean(), - 'FeatureFlag', - [], - 'Check if feature is enabled', - 'BOOL', - ) - generator.registerMethod('get_api_url', z.string(), 'ApiUrl', [], 'Get API URL', 'STRING') - generator.registerMethod('get_timeout', z.number(), 'Timeout', [], 'Get timeout in seconds', 'INT') - - // Add a complex type - const configSchema = z.object({ - api_key: z.string(), - debug_mode: z.boolean().default(false), - rate_limit: z.number().int(), - }) - - generator.registerMethod('get_config', configSchema, 'Config', [], 'Get service configuration', 'JSON') - - const pythonFile = generator.generatePythonFile() - - // Print the entire python file for debugging - console.log('Generated Python File:') - console.log(pythonFile) - - // Check imports - expect(pythonFile).to.include('import logging') - expect(pythonFile).to.include('from pydantic import BaseModel, ValidationError') - expect(pythonFile).to.include('from prefab_cloud_python import Client, Context') - expect(pythonFile).to.include('from typing import') - - // Verify that prefab_cloud_python is imported at the file level - expect(pythonFile).to.include('import prefab_cloud_python') - - // Verify that the import is not present in the constructor - const constructorCode = pythonFile.split('def __init__')[1].split('def')[0] - expect(constructorCode).not.to.include('import prefab_cloud_python') - - // Instead of checking for a specific model name, check for any Pydantic model - expect(pythonFile).to.include('class ') - expect(pythonFile).to.include('(BaseModel):') - - // Check methods - expect(pythonFile).to.include('def is_feature_enabled(self') - expect(pythonFile).to.include('def get_api_url(self') - expect(pythonFile).to.include('def get_timeout(self') - expect(pythonFile).to.include('def get_config(self') - }) - }) - - describe('Array handling', () => { - it('should correctly handle arrays of basic types', () => { - const stringArraySchema = z.array(z.string()) - const returnType = generator.registerModel(stringArraySchema, 'StringArray') - - // Should convert to List[str] type - expect(returnType).to.equal('List[str]') - - // Test method generation with a string list - const methodCode = generator.generateMethodCode('get_tags', { - docstring: 'Get tags list', - originalKey: 'get_tags', - params: [], - returnType: 'List[str]', - valueType: 'STRING_LIST', - }) - - expect(methodCode).to.include('def get_tags(self') - expect(methodCode).to.include('-> Optional[List[str]]') - expect(methodCode).to.include( - 'if isinstance(config_value, list) and all(isinstance(x, str) for x in config_value):', - ) - expect(methodCode).to.include('return config_value') - }) - - it('should register methods with array parameters', () => { - // Test with a method that takes an array parameter - const methodCode = generator.generateMethodCode('filter_items', { - docstring: 'Filter items by categories', - originalKey: 'filter_items', - params: [ - {default: '[]', name: 'categories', type: 'List[str]'}, - {default: '10', name: 'limit', type: 'int'}, - ], - returnType: 'List[str]', - valueType: 'STRING_LIST', - }) - - expect(methodCode).to.include('def filter_items(self, categories: List[str] = [], limit: int = 10') - expect(methodCode).to.include('categories: Description of categories') - }) - }) - - describe('Complex type handling', () => { - it('should handle nested object structures', () => { - // Define a nested schema - const addressSchema = z.object({ - city: z.string(), - street: z.string(), - zipCode: z.string(), - }) - - const userSchema = z.object({ - address: addressSchema, - email: z.string().email(), - name: z.string(), - }) - - // Register both models - const addressType = generator.registerModel(addressSchema, 'Address') - generator.registerModel(userSchema, 'User') - - // Address should be registered as a separate model - expect(addressType).to.include('Address') - - // Test the generated models - const userModelCode = generator.generatePydanticModel(userSchema, 'User') - expect(userModelCode).to.include('address: ') - - // Generate the full Python code - const pythonCode = generator.generatePythonFile() - - // Verify models are nested within the client class - expect(pythonCode).to.include('class TestClient:') - expect(pythonCode).to.include(' class AddressModel(BaseModel):') - expect(pythonCode).to.include(' street: str') - expect(pythonCode).to.include(' city: str') - expect(pythonCode).to.include(' zipCode: str') - expect(pythonCode).to.include(' class UserModel(BaseModel):') - expect(pythonCode).to.include(' name: str') - expect(pythonCode).to.include(' email: str') - expect(pythonCode).to.include(' address: TestClient.AddressModel') - - // Check the import calculation - const {needsJson} = generator.calculateNeededImports() - expect(needsJson).to.be.false - }) - - it('should handle dictionary types', () => { - const dictSchema = z.record(z.string()) - const returnType = generator.registerModel(dictSchema, 'StringDict') - - // Should convert to Dict type - expect(returnType).to.include('Dict') - - // Test method with dictionary return - const methodCode = generator.generateMethodCode('get_metadata', { - docstring: 'Get metadata dictionary', - originalKey: 'get_metadata', - params: [], - returnType: 'Dict[str, str]', - valueType: 'JSON', - }) - - expect(methodCode).to.include('def get_metadata(self') - expect(methodCode).to.include('Dict[str, str]') - expect(methodCode).to.include('if isinstance(config_value, dict):') - }) - - it('should handle nested JSON objects with Mustache templates', () => { - // Create a JSON schema with a mustache template in the url property - const urlWithMustacheSchema = z.object({ - retries: z.number(), - timeout: z.number(), - url: z - .function() - .args( - z.object({ - host: z.string(), - scheme: z.string(), - }), - ) - .returns(z.string()), - }) - - // Register the method with the schema - generator.registerMethod( - 'url_with_mustache', - urlWithMustacheSchema, - 'UrlWithMustache', - [], - 'Get URL with template parameters', - 'JSON', - ) - - // Generate the Python method code - const method = (generator as any).methods.get('url_with_mustache') - expect(method).to.exist - expect(method.hasTemplateParams).to.be.true - - // Generate the Python code for the entire client - const pythonCode = generator.generatePythonFile() - - // Verify the imports include pystache - expect(pythonCode).to.include('import pystache') - - // Verify a parameter class was generated for the URL template - expect(pythonCode).to.include('class UrlWithMustacheParams') - expect(pythonCode).to.include('scheme: str') - expect(pythonCode).to.include('host: str') - - // Verify the model class has correct types and is nested within the client class - expect(pythonCode).to.include('class TestClient:') - expect(pythonCode).to.include(' class UrlWithMustacheModel(BaseModel):') - expect(pythonCode).to.include(' url: str') - expect(pythonCode).to.include(' timeout: float') - expect(pythonCode).to.include(' retries: float') - - // Verify the method uses the nested model type - expect(pythonCode).to.include('def url_with_mustache(self') - expect(pythonCode).to.include("params: Optional['TestClient.UrlWithMustacheParams'] = None") - expect(pythonCode).to.include("-> Optional['TestClient.UrlWithMustacheModel']") - }) - }) - - describe('Edge cases', () => { - it('should handle specialized value types', () => { - // Test duration value type - const durationMethodCode = generator.generateValueExtraction('datetime.timedelta', true, 'DURATION') - expect(durationMethodCode).to.include('if isinstance(config_value, timedelta):') - expect(durationMethodCode).to.include('return config_value') - - // Test JSON value type with primitive return - const jsonBoolMethodCode = generator.generateValueExtraction('bool', true, 'JSON') - expect(jsonBoolMethodCode).to.include('if isinstance(config_value, dict):') - expect(jsonBoolMethodCode).to.include('return self.bool(**config_value)') - }) - }) - - describe('Mustache template handling', () => { - it('should generate a parameter class for template parameters', () => { - // Create a template function schema with object params - const templateSchema = z - .function() - .args( - z.object({ - status: z.string(), - userId: z.number().int(), - }), - ) - .returns(z.string()) - .describe('Template') - - // Generate a parameter class - const className = generator.generateParamClass('get_user_status', templateSchema._def.args._def.items[0]) - - // Check the class name - expect(className).to.equal('GetUserStatusParams') - - // Get the parameter class info from the generator's internal state - const paramClass = (generator as any).paramClasses.get(className) - expect(paramClass).to.exist - - // Check that parameters preserve their exact original names for mustache templates - expect(paramClass.fields[0]).to.deep.include({name: 'status', type: 'str'}) - expect(paramClass.fields[1]).to.deep.include({name: 'userId', type: 'int'}) - }) - - it('should detect template parameters in registerMethod', () => { - // Create a template function schema - const templateSchema = z - .function() - .args( - z.object({ - company: z.string(), - name: z.string(), - }), - ) - .returns(z.string()) - .describe('GreetingTemplate') - - // Register the method - generator.registerMethod( - 'getGreetingTemplate', - templateSchema, - undefined, - [], - 'Get a greeting template that can be rendered with name and company', - 'STRING', - ) - - // Check that the method was registered with template parameters - const method = (generator as any).methods.get('get_greeting_template') - expect(method).to.exist - expect(method.hasTemplateParams).to.be.true - expect(method.paramClassName).to.equal('GetGreetingTemplateParams') - - // The imports should include pystache - const {imports} = generator as any - const standardImports = [...(imports as any).standardImports] - expect(standardImports).to.include('pystache') - }) - - it('should correctly generate imports for templates', () => { - // Create a generator without templates - const generatorNoTemplates = new UnifiedPythonGenerator({ - className: 'NoTemplatesClient', - }) - - // Register a regular method - generatorNoTemplates.registerMethod('getConfig', z.object({key: z.string()}), undefined, [], 'Get config', 'JSON') - - // Generate Python file - const pythonCodeNoTemplates = generatorNoTemplates.generatePythonFile() - - // Should not include pystache - expect(pythonCodeNoTemplates).not.to.include('import pystache') - - // Now create a generator with templates - const generatorWithTemplates = new UnifiedPythonGenerator({ - className: 'TemplatesClient', - }) - - // Register a template method - generatorWithTemplates.registerMethod( - 'getGreetingTemplate', - z - .function() - .args(z.object({name: z.string()})) - .returns(z.string()), - undefined, - [], - 'Get greeting template', - 'STRING', - ) - - // Generate Python file - const pythonCodeWithTemplates = generatorWithTemplates.generatePythonFile() - - // Should include pystache - expect(pythonCodeWithTemplates).to.include('import pystache') - }) - }) - - describe('Method name sanitization', () => { - it('should sanitize method names with dots', () => { - // Create a schema with a problematic method name - const schema = z.object({ - field1: z.string(), - field2: z.number(), - }) - - // Register a method with dots in the name - generator.registerMethod('url.with.mustache', schema, 'UrlConfig', [], 'Get URL configuration', 'JSON') - - // Generate method code - const methodCode = generator.generateMethodCode('url_with_mustache', { - docstring: 'Get URL configuration', - originalKey: 'url.with.mustache', - params: [], - returnType: 'UrlConfig', - valueType: 'JSON', - }) - - // Verify the method name is sanitized correctly - expect(methodCode).to.include('def url_with_mustache(self') - expect(methodCode).not.to.include('def url.with.mustache(self') - - // Generate the full client code - const pythonCode = generator.generatePythonFile() - - // Verify the sanitized method name appears in the full code - expect(pythonCode).to.include('def url_with_mustache(self') - expect(pythonCode).not.to.include('def url.with.mustache(self') - }) - - it('should sanitize method names with other special characters', () => { - // Generate Python file with methods that have special characters - generator.registerMethod( - 'feature.flag.is-enabled?', - z.boolean(), - undefined, - [], - 'Check if feature flag is enabled', - 'BOOL', - ) - generator.registerMethod('api-gateway/endpoint', z.string(), undefined, [], 'Get API endpoint', 'STRING') - - const pythonCode = generator.generatePythonFile() - - // Log the method name transformation for debugging - console.log( - "ZodUtils.keyToMethodName('api-gateway/endpoint') => '" + - ZodUtils.keyToMethodName('api-gateway/endpoint') + - "'", - ) - console.log( - "ZodUtils.keyToMethodName('feature.flag.is-enabled?') => '" + - ZodUtils.keyToMethodName('feature.flag.is-enabled?') + - "'", - ) - - // Check that the special characters were handled properly - expect(pythonCode).to.include('def feature_flag_is_enabled(self') - expect(pythonCode).to.include('def api_gateway_endpoint(self') - }) - }) - - describe('Fallback method generation', () => { - it('should generate a fallback method for a boolean type', () => { - // Using private method for testing - // @ts-expect-error - accessing private method for testing - const methodCode = generator.generateFallbackMethod('is_feature_enabled', { - docstring: 'Check if feature is enabled', - originalKey: 'is_feature_enabled', - params: [], - returnType: 'bool', - valueType: 'BOOL', - }) - - expect(methodCode).to.include( - 'def is_feature_enabled_with_fallback_value(self, fallback: bool, context: Optional[ContextDictOrContext] = None) -> bool:', - ) - expect(methodCode).to.include('"""') - expect(methodCode).to.include('Check if feature is enabled') - expect(methodCode).to.include('fallback: Required fallback value') - expect(methodCode).to.include('return self.is_feature_enabled(context, fallback)') - }) - - it('should generate a fallback method for a float type', () => { - // @ts-expect-error - accessing private method for testing - const methodCode = generator.generateFallbackMethod('get_conversion_rate', { - docstring: 'Get conversion rate', - originalKey: 'get_conversion_rate', - params: [], - returnType: 'float', - valueType: 'DOUBLE', - }) - - expect(methodCode).to.include( - 'def get_conversion_rate_with_fallback_value(self, fallback: float, context: Optional[ContextDictOrContext] = None) -> float:', - ) - expect(methodCode).to.include('"""') - expect(methodCode).to.include('Get conversion rate') - expect(methodCode).to.include('fallback: Required fallback value') - expect(methodCode).to.include('return self.get_conversion_rate(context, fallback)') - }) - - it('should generate a fallback method for a string type', () => { - // @ts-expect-error - accessing private method for testing - const methodCode = generator.generateFallbackMethod('get_api_url', { - docstring: 'Get the API URL', - originalKey: 'get_api_url', - params: [], - returnType: 'str', - valueType: 'STRING', - }) - - expect(methodCode).to.include( - 'def get_api_url_with_fallback_value(self, fallback: str, context: Optional[ContextDictOrContext] = None) -> str:', - ) - expect(methodCode).to.include('"""') - expect(methodCode).to.include('Get the API URL') - expect(methodCode).to.include('fallback: Required fallback value') - expect(methodCode).to.include('return self.get_api_url(context, fallback)') - }) - - it('should generate a fallback method for a string list type', () => { - // @ts-expect-error - accessing private method for testing - const methodCode = generator.generateFallbackMethod('get_allowed_domains', { - docstring: 'Get allowed domains', - originalKey: 'get_allowed_domains', - params: [], - returnType: 'List[str]', - valueType: 'STRING_LIST', - }) - - expect(methodCode).to.include( - 'def get_allowed_domains_with_fallback_value(self, fallback: List[str], context: Optional[ContextDictOrContext] = None) -> List[str]:', - ) - expect(methodCode).to.include('"""') - expect(methodCode).to.include('Get allowed domains') - expect(methodCode).to.include('fallback: Required fallback value') - expect(methodCode).to.include('return self.get_allowed_domains(context, fallback)') - }) - - it('should generate a fallback method for a custom model', () => { - generator.registerModel( - z.object({ - port: z.number().int(), - timeout: z.number(), - url: z.string(), - }), - 'ServiceConfig', - ) - - // @ts-expect-error - accessing private method for testing - const methodCode = generator.generateFallbackMethod('get_service_config', { - docstring: 'Get the service configuration', - originalKey: 'get_service_config', - params: [], - returnType: 'ServiceConfig', - valueType: 'JSON', - }) - - expect(methodCode).to.include( - "def get_service_config_with_fallback_value(self, fallback: 'TestClient.ServiceConfig', context: Optional[ContextDictOrContext] = None) -> 'TestClient.ServiceConfig':", - ) - expect(methodCode).to.include('"""') - expect(methodCode).to.include('Get the service configuration') - expect(methodCode).to.include('fallback: Required fallback value') - expect(methodCode).to.include('return self.get_service_config(context, fallback)') - }) - - it('should generate fallback methods with parameters', () => { - // @ts-expect-error - accessing private method for testing - const methodCode = generator.generateFallbackMethod('get_user_preference', { - docstring: 'Get user preference', - originalKey: 'get_user_preference', - params: [ - {default: 'None', name: 'user_id', type: 'str'}, - {default: '"default"', name: 'preference_type', type: 'str'}, - ], - returnType: 'str', - valueType: 'STRING', - }) - - expect(methodCode).to.include( - 'def get_user_preference_with_fallback_value(self, fallback: str, user_id: str = None, preference_type: str = "default", context: Optional[ContextDictOrContext] = None) -> str:', - ) - expect(methodCode).to.include('"""') - expect(methodCode).to.include('Get user preference') - expect(methodCode).to.include('fallback: Required fallback value') - expect(methodCode).to.include('user_id: Description of user_id') - expect(methodCode).to.include('preference_type: Description of preference_type') - expect(methodCode).to.include('return self.get_user_preference(user_id, preference_type, context, fallback)') - }) - - it('should generate fallback method with template parameters', () => { - // @ts-expect-error - accessing private method for testing - const methodCode = generator.generateFallbackMethod('getGreetingTemplate', { - docstring: 'Get a greeting template', - hasTemplateParams: true, - originalKey: 'getGreetingTemplate', - paramClassName: 'GreetingTemplateParams', - params: [], - returnType: 'str', - valueType: 'STRING', - }) - - // Check method signature includes params parameter with proper prefix - expect(methodCode).to.include( - "def getGreetingTemplate_with_fallback_value(self, fallback: str, params: Optional['TestClient.GreetingTemplateParams'] = None", - ) - - // Check method documentation - expect(methodCode).to.include('fallback: Required fallback value') - expect(methodCode).to.include('params: Parameters for template rendering') - - // Check return type is non-optional - expect(methodCode).to.include('-> str:') - - // Check method call passes parameters correctly - expect(methodCode).to.include('return self.getGreetingTemplate(params, context, fallback)') - }) - }) -}) diff --git a/test/codegen/schema-inferrer.test.ts b/test/codegen/schema-inferrer.test.ts deleted file mode 100644 index cc1c936..0000000 --- a/test/codegen/schema-inferrer.test.ts +++ /dev/null @@ -1,1119 +0,0 @@ -import {expect} from '@oclif/test' -import {z} from 'zod' - -import type {Config, ConfigFile} from '../../src/codegen/types.js' - -import {SchemaInferrer} from '../../src/codegen/schema-inferrer.js' -import {SupportedLanguage} from '../../src/codegen/types' -import {ZodUtils} from '../../src/codegen/zod-utils.js' - -const logger = (category: string | unknown, message?: unknown) => { - console.log(category, message) -} - -describe('SchemaInferrer', () => { - const inferrer = new SchemaInferrer(logger) - - describe('zodForConfig', () => { - it('should infer from a number', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test', - rows: [{values: [{value: {int: 1}}]}], - valueType: 'INT', - } - const configFile: ConfigFile = { - configs: [config], - } - - const {schema: result} = inferrer.zodForConfig(config, configFile, SupportedLanguage.TypeScript) - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'z.number().int()', - ) - }) - - it('should infer from a double', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test', - rows: [{values: [{value: {int: 1}}]}], - valueType: 'DOUBLE', - } - const configFile: ConfigFile = { - configs: [config], - } - - const {schema: result} = inferrer.zodForConfig(config, configFile, SupportedLanguage.TypeScript) - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal('z.number()') - }) - - it('should infer from a simple string', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test', - rows: [{values: [{value: {string: 'foo'}}]}], - valueType: 'STRING', - } - const configFile: ConfigFile = { - configs: [config], - } - - const {schema: result} = inferrer.zodForConfig(config, configFile, SupportedLanguage.TypeScript) - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal('z.string()') - }) - - it('should infer from a template string', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test', - rows: [{values: [{value: {string: 'foo {{name}}'}}]}], - valueType: 'STRING', - } - const configFile: ConfigFile = { - configs: [config], - } - - const {schema: result} = inferrer.zodForConfig(config, configFile, SupportedLanguage.TypeScript) - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'z.function().args(optionalRequiredAccess({name: z.string()})).returns(z.string())', - ) - - const {schema: resultPython} = inferrer.zodForConfig(config, configFile, SupportedLanguage.Python) - expect(ZodUtils.zodToString(resultPython, 'test', 'inferred', SupportedLanguage.Python)).to.equal( - 'z.function().args(z.object({name: z.string()})).returns(z.string())', - ) - }) - - it('should infer from a json', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test', - rows: [{values: [{value: {string: '{"name": "foo", "age": 10}'}}]}], - valueType: 'JSON', - } - const configFile: ConfigFile = { - configs: [config], - } - - const {schema: result} = inferrer.zodForConfig(config, configFile, SupportedLanguage.TypeScript) - expect(result._def.typeName).to.equal('ZodObject') - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'optionalRequiredAccess({name: z.string(), age: z.number()})', - ) - - const {schema: resultPython} = inferrer.zodForConfig(config, configFile, SupportedLanguage.Python) - expect(ZodUtils.zodToString(resultPython, 'test', 'inferred', SupportedLanguage.Python)).to.equal( - 'z.object({name: z.string(), age: z.number()})', - ) - }) - - it('should infer from a json with placeholders', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test', - rows: [{values: [{value: {string: '{"name": "foo {{name}}", "age": 10}'}}]}], - valueType: 'JSON', - } - const configFile: ConfigFile = { - configs: [config], - } - - const {schema: result} = inferrer.zodForConfig(config, configFile, SupportedLanguage.TypeScript) - expect(result._def.typeName).to.equal('ZodObject') - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'optionalRequiredAccess({name: z.function().args(optionalRequiredAccess({name: z.string()})).returns(z.string()), age: z.number()})', - ) - - const {schema: resultPython} = inferrer.zodForConfig(config, configFile, SupportedLanguage.Python) - expect(ZodUtils.zodToString(resultPython, 'test', 'inferred', SupportedLanguage.Python)).to.equal( - 'z.object({name: z.function().args(z.object({name: z.string()})).returns(z.string()), age: z.number()})', - ) - }) - - it('should infer non-optionals from a json with schema', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test', - rows: [{values: [{value: {string: '{"name": "foo", "age": 10}'}}]}], - schemaKey: 'schemaConfig', - valueType: 'JSON', - } - const schemaConfig: Config = { - configType: 'SCHEMA', - key: 'schemaConfig', - rows: [ - {values: [{value: {schema: {schema: 'z.object({name: z.string(), age: z.number()})', schemaType: 'ZOD'}}}]}, - ], - valueType: 'JSON', - } - const configFile: ConfigFile = { - configs: [config, schemaConfig], - } - - const {providence, schema: result} = inferrer.zodForConfig(config, configFile, SupportedLanguage.TypeScript) - expect(providence).to.equal('user') - expect(result._def.typeName).to.equal('ZodObject') - expect(ZodUtils.zodToString(result, 'test', 'user', SupportedLanguage.TypeScript)).to.equal( - 'z.object({name: z.string(), age: z.number()})', - ) - - const {schema: resultPython} = inferrer.zodForConfig(config, configFile, SupportedLanguage.Python) - expect(ZodUtils.zodToString(resultPython, 'test', 'user', SupportedLanguage.Python)).to.equal( - 'z.object({name: z.string(), age: z.number()})', - ) - }) - - it('torture test', () => { - const config: Config = { - configType: 'CONFIG', - - key: 'test', - rows: [ - { - values: [ - { - value: { - string: - '{"systemMessage": "you {{#user}} {{name}} {{/user}} {{#admin}} {{name}} {{/admin}}", "nested": { "stuff": [{ "name": "foo" }, { "name": "bar" }] }}', - }, - }, - ], - }, - ], - valueType: 'JSON', - } - const configFile: ConfigFile = { - configs: [config], - } - - const {schema: result} = inferrer.zodForConfig(config, configFile, SupportedLanguage.TypeScript) - expect(result._def.typeName).to.equal('ZodObject') - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'optionalRequiredAccess({systemMessage: z.function().args(optionalRequiredAccess({user: z.array(z.object({name: z.string()})), admin: z.array(z.object({name: z.string()}))})).returns(z.string()), nested: optionalRequiredAccess({stuff: z.array(z.object({name: z.string()}))})})', - ) - - const {schema: resultPython} = inferrer.zodForConfig(config, configFile, SupportedLanguage.Python) - expect(ZodUtils.zodToString(resultPython, 'test', 'inferred', SupportedLanguage.Python)).to.equal( - 'z.object({systemMessage: z.function().args(z.object({user: z.array(z.object({name: z.string()})), admin: z.array(z.object({name: z.string()}))})).returns(z.string()), nested: z.object({stuff: z.array(z.object({name: z.string()}))})})', - ) - }) - - it('should merge from a multiple string', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test', - rows: [ - {values: [{value: {string: 'foo {{name}}'}}]}, - {values: [{value: {string: 'bar {{baz}}'}}]}, - {values: [{value: {string: 'fizz {{buzz}}'}}]}, - ], - valueType: 'STRING', - } - const configFile: ConfigFile = { - configs: [config], - } - - const {schema: result} = inferrer.zodForConfig(config, configFile, SupportedLanguage.Python) - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.Python)).to.equal( - 'z.function().args(z.object({name: z.string().optional(), baz: z.string().optional(), buzz: z.string().optional()})).returns(z.string())', - ) - }) - - it('should merge from a multiple JSON', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test', - rows: [ - {values: [{value: {string: '{"name": "foo", "age": 10, "conflict": "string"}'}}]}, - {values: [{value: {string: '{"name": "foo2", "otherNum": 10, "conflict": 42}'}}]}, - ], - valueType: 'JSON', - } - const configFile: ConfigFile = { - configs: [config], - } - - const {schema: result} = inferrer.zodForConfig(config, configFile, SupportedLanguage.TypeScript) - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'optionalRequiredAccess({name: z.string(), age: z.number().optional(), conflict: z.union([z.string(), z.number()]), otherNum: z.number().optional()})', - ) - - const {schema: resultPython} = inferrer.zodForConfig(config, configFile, SupportedLanguage.Python) - expect(ZodUtils.zodToString(resultPython, 'test', 'inferred', SupportedLanguage.Python)).to.equal( - 'z.object({name: z.string(), age: z.number().optional(), conflict: z.union([z.string(), z.number()]), otherNum: z.number().optional()})', - ) - }) - - it('multi-row merge with placeholder test', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test', - rows: [ - { - values: [ - { - value: { - string: - '{"systemMessage": "you {{#user}} {{name}} {{/user}} {{#admin}} {{name}} {{/admin}}", "nested": { "stuff": [{ "name": "foo" }, { "name": "bar" }] }}', - }, - }, - ], - }, - { - values: [ - { - value: { - string: - '{"systemMessage": "message with {{placeholder}}", "nested": { "otherStuff": "string {{placeholder2}}" }}', - }, - }, - ], - }, - ], - valueType: 'JSON', - } - const configFile: ConfigFile = { - configs: [config], - } - - const {schema: result} = inferrer.zodForConfig(config, configFile, SupportedLanguage.TypeScript) - expect(result._def.typeName).to.equal('ZodObject') - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'optionalRequiredAccess({systemMessage: z.function().args(optionalRequiredAccess({user: z.array(z.object({name: z.string()})).optional(), admin: z.array(z.object({name: z.string()})).optional(), placeholder: z.string().optional()})).returns(z.string()), nested: optionalRequiredAccess({stuff: z.array(z.object({name: z.string()})).optional(), otherStuff: z.function().args(optionalRequiredAccess({placeholder2: z.string()})).returns(z.string()).optional()})})', - ) - }) - - it('should infer a string schema', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test.string', - rows: [ - { - values: [ - { - value: { - string: 'test', - }, - }, - ], - }, - ], - valueType: 'STRING', - } - - const {schema: result} = inferrer.zodForConfig(config, {configs: []}, SupportedLanguage.TypeScript) - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal('z.string()') - }) - - it('should use schema from referenced schema config', () => { - // Create a schema config that defines a person object with name and age - const schemaConfig: Config = { - configType: 'SCHEMA', - key: 'schemas.person', - rows: [ - { - values: [ - { - value: { - schema: { - schema: 'z.object({ name: z.string(), age: z.number() })', - schemaType: 'ZOD', - }, - }, - }, - ], - }, - ], - valueType: 'JSON', - } - - // Create a JSON config that references the schema - const jsonConfig: Config = { - configType: 'CONFIG', - key: 'test.person', - rows: [ - { - values: [ - { - value: { - json: { - json: '{"name":"John", "age": 30}', - }, - }, - }, - ], - }, - ], - schemaKey: 'schemas.person', // Reference to the schema config - valueType: 'JSON', - } - - const configFile: ConfigFile = { - configs: [schemaConfig, jsonConfig], - } - - const {schema: result} = inferrer.zodForConfig(jsonConfig, configFile, SupportedLanguage.TypeScript) - - // Verify it's an object schema with the expected properties - expect(result._def.typeName).to.equal('ZodObject') - const {shape} = result as z.ZodObject - expect((shape.name as any)._def.typeName).to.equal('ZodString') - expect((shape.age as any)._def.typeName).to.equal('ZodNumber') - }) - - it('should have optional template params when not all strings are templates when inferred', () => { - const jsonConfig: Config = { - configType: 'CONFIG', - key: 'test.person', - rows: [ - { - values: [ - { - value: { - json: { - json: '{"name":"string with {{placeholder}}"}', - }, - }, - }, - { - value: { - json: { - json: '{"name":"string without"}', - }, - }, - }, - ], - }, - ], - valueType: 'JSON', - } - - const configFile: ConfigFile = { - configs: [jsonConfig], - } - - const {schema: result} = inferrer.zodForConfig(jsonConfig, configFile, SupportedLanguage.TypeScript) - - expect(ZodUtils.zodToString(result, 'test', 'user', SupportedLanguage.TypeScript)).to.equal( - 'z.object({name: z.function().args(z.object({placeholder: z.string().optional()})).returns(z.string())})', - ) - }) - - it('should have optional template params when not all strings are templates with Schemas', () => { - // Create a schema config that defines a person object with name and age - const schemaConfig: Config = { - configType: 'SCHEMA', - key: 'schemas.person', - rows: [ - { - values: [ - { - value: { - schema: { - schema: 'z.object({ name: z.string()})', - schemaType: 'ZOD', - }, - }, - }, - ], - }, - ], - valueType: 'JSON', - } - - // Create a JSON config that references the schema - const jsonConfig: Config = { - configType: 'CONFIG', - key: 'test.person', - rows: [ - { - values: [ - { - value: { - json: { - json: '{"name":"string with {{placeholder}}"}', - }, - }, - }, - { - value: { - json: { - json: '{"name":"string without"}', - }, - }, - }, - ], - }, - ], - schemaKey: 'schemas.person', // Reference to the schema config - valueType: 'JSON', - } - - const configFile: ConfigFile = { - configs: [schemaConfig, jsonConfig], - } - - const {schema: result} = inferrer.zodForConfig(jsonConfig, configFile, SupportedLanguage.TypeScript) - - expect(ZodUtils.zodToString(result, 'test', 'user', SupportedLanguage.TypeScript)).to.equal( - 'z.object({name: z.function().args(z.object({placeholder: z.string().optional()})).returns(z.string())})', - ) - }) - - it('should handle optional properties in schema', () => { - // Create a schema config with optional properties - const schemaConfig: Config = { - configType: 'SCHEMA', - key: 'schemas.user', - rows: [ - { - values: [ - { - value: { - schema: { - schema: - 'z.object({ username: z.string(), email: z.string().optional(), age: z.number().optional() })', - schemaType: 'ZOD', - }, - }, - }, - ], - }, - ], - valueType: 'JSON', - } - - const jsonConfig: Config = { - configType: 'CONFIG', - key: 'test.user', - rows: [ - { - values: [ - { - value: { - json: { - json: '{"username":"johndoe", "email": "foo"}', - }, - }, - }, - ], - }, - ], - schemaKey: 'schemas.user', - valueType: 'JSON', - } - - const configFile: ConfigFile = { - configs: [schemaConfig, jsonConfig], - } - - const {providence, schema: result} = inferrer.zodForConfig(jsonConfig, configFile, SupportedLanguage.TypeScript) - expect(providence).to.equal('user') - // we do inference on the json config, then trust server to have the non-optional - // optional doesn't carry over to the zod - expect(ZodUtils.zodToString(result, 'test', 'user', SupportedLanguage.TypeScript)).to.equal( - 'z.object({username: z.string(), email: z.string().optional(), age: z.number().optional()})', - ) - - const {schema: resultPython} = inferrer.zodForConfig(jsonConfig, configFile, SupportedLanguage.Python) - expect(ZodUtils.zodToString(resultPython, 'test', 'user', SupportedLanguage.Python)).to.equal( - 'z.object({username: z.string(), email: z.string().optional(), age: z.number().optional()})', - ) - }) - - it('should fall back to inference when schema config is not found', () => { - const jsonConfig: Config = { - configType: 'CONFIG', - key: 'test.product', - rows: [ - { - values: [ - { - value: { - json: { - json: '{"name":"Product", "price": 99.99}', - }, - }, - }, - ], - }, - ], - schemaKey: 'non.existent.schema', // This schema key doesn't exist - valueType: 'JSON', - } - - const configFile: ConfigFile = { - configs: [jsonConfig], // No schema config exists - } - - const {schema: result} = inferrer.zodForConfig(jsonConfig, configFile, SupportedLanguage.TypeScript) - - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'optionalRequiredAccess({name: z.string(), price: z.number()})', - ) - }) - - it('should fall back to inference when schema cannot be evaluated', () => { - // Invalid schema that can't be parsed - const schemaConfig: Config = { - configType: 'SCHEMA', - key: 'schemas.invalid', - rows: [ - { - values: [ - { - value: { - schema: { - schema: 'z.object({ invalidSyntax: z.string( })', // Syntax error - schemaType: 'ZOD', - }, - }, - }, - ], - }, - ], - valueType: 'JSON', - } - - const jsonConfig: Config = { - configType: 'CONFIG', - key: 'test.invalid', - rows: [ - { - values: [ - { - value: { - json: { - json: '{"value":"test"}', - }, - }, - }, - ], - }, - ], - schemaKey: 'schemas.invalid', - valueType: 'JSON', - } - - const configFile: ConfigFile = { - configs: [schemaConfig, jsonConfig], - } - - const {schema: result} = inferrer.zodForConfig(jsonConfig, configFile, SupportedLanguage.TypeScript) - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'optionalRequiredAccess({value: z.string()})', - ) - - const {schema: resultPython} = inferrer.zodForConfig(jsonConfig, configFile, SupportedLanguage.Python) - expect(ZodUtils.zodToString(resultPython, 'test', 'inferred', SupportedLanguage.Python)).to.equal( - 'z.object({value: z.string()})', - ) - }) - - it('should handle complex schemas with nested objects and arrays', () => { - const schemaConfig: Config = { - configType: 'SCHEMA', - key: 'schemas.complex', - rows: [ - { - values: [ - { - value: { - schema: { - schema: `z.object({ - name: z.string(), - tags: z.array(z.string()), - metadata: z.object({ - created: z.string(), - modified: z.string().optional() - }), - status: z.enum(["active","inactive","pending"]) - })`, - schemaType: 'ZOD', - }, - }, - }, - ], - }, - ], - valueType: 'JSON', - } - - const jsonConfig: Config = { - configType: 'CONFIG', - key: 'test.complex', - rows: [ - { - values: [ - { - value: { - json: { - json: '{"name":"Complex", "tags":["test"], "metadata":{"created":"2023-01-01"}, "status":"active"}', - }, - }, - }, - ], - }, - ], - schemaKey: 'schemas.complex', - valueType: 'JSON', - } - - const configFile: ConfigFile = { - configs: [schemaConfig, jsonConfig], - } - - const {schema: result} = inferrer.zodForConfig(jsonConfig, configFile, SupportedLanguage.Python) - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.Python)).to.equal( - "z.object({name: z.string(), tags: z.array(z.string()), metadata: z.object({created: z.string(), modified: z.string().optional()}), status: z.enum(['active','inactive','pending'])})", - ) - }) - - it('should combine schema with template processing', () => { - // Create a schema config with a basic definition - const schemaConfig: Config = { - configType: 'SCHEMA', - key: 'schemas.template', - rows: [ - { - values: [ - { - value: { - schema: { - schema: 'z.object({ greeting: z.string(), data: z.object({ title: z.string() }) })', - schemaType: 'ZOD', - }, - }, - }, - ], - }, - ], - valueType: 'JSON', - } - - // Create a JSON config that references the schema but has template strings - const jsonConfig: Config = { - configType: 'CONFIG', - key: 'test.template', - rows: [ - { - values: [ - { - value: { - json: { - json: '{"greeting":"Hello {{name}}!", "data": {"title": "Welcome to {{place}}"}}', - }, - }, - }, - ], - }, - ], - schemaKey: 'schemas.template', - valueType: 'JSON', - } - - const configFile: ConfigFile = { - configs: [schemaConfig, jsonConfig], - } - - const {providence, schema: result} = inferrer.zodForConfig(jsonConfig, configFile, SupportedLanguage.TypeScript) - - expect(providence).to.equal('user') - - // Verify the hybrid approach - structure from schema with template processing - expect(result._def.typeName).to.equal('ZodObject') - const {shape} = result as z.ZodObject - - // The greeting should now be a function type because of the template - expect((shape.greeting as any)._def.typeName).to.equal('ZodFunction') - - // Check that the function has the expected argument structure - const greetingArgs = (shape.greeting as any)._def.args - expect(greetingArgs).to.exist - expect(greetingArgs._def.typeName).to.equal('ZodTuple') - expect(greetingArgs._def.items).to.exist - expect(greetingArgs._def.items.length).to.be.at.least(1) - - // Check that the first item in the tuple is an object with the expected shape - const firstArg = greetingArgs._def.items[0] - expect(firstArg._def.typeName).to.equal('ZodObject') - expect(firstArg.shape.name).to.exist - - // data should remain an object - expect((shape.data as any)._def.typeName).to.equal('ZodObject') - - // but its title should now be a function - const dataShape = (shape.data as z.ZodObject).shape - expect((dataShape.title as any)._def.typeName).to.equal('ZodFunction') - - // Check that the function has the expected argument structure - const titleArgs = (dataShape.title as any)._def.args - expect(titleArgs).to.exist - expect(titleArgs._def.typeName).to.equal('ZodTuple') - expect(titleArgs._def.items).to.exist - expect(titleArgs._def.items.length).to.be.at.least(1) - - // Check that the first item in the tuple is an object with the expected shape - const firstTitleArg = titleArgs._def.items[0] - expect(firstTitleArg._def.typeName).to.equal('ZodObject') - expect(firstTitleArg.shape.place).to.exist - }) - - it('should correctly process JSON with nested Mustache templates', () => { - // Create a JSON config with a template in a nested property - const config: Config = { - configType: 'CONFIG', - key: 'url.with.mustache', - rows: [ - { - values: [ - { - value: { - json: { - json: '{"url": "url is {{scheme}}://{{host}}", "timeout": 10, "retries": 10}', - }, - }, - }, - ], - }, - ], - schemaKey: 'url-schema', - valueType: 'JSON', - } - - const configFile: ConfigFile = { - configs: [config], - } - - const {providence, schema: result} = inferrer.zodForConfig(config, configFile, SupportedLanguage.TypeScript) - expect(providence).to.equal('inferred') - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'optionalRequiredAccess({url: z.function().args(optionalRequiredAccess({scheme: z.string(), host: z.string()})).returns(z.string()), timeout: z.number(), retries: z.number()})', - ) - - const {schema: resultPython} = inferrer.zodForConfig(config, configFile, SupportedLanguage.Python) - expect(ZodUtils.zodToString(resultPython, 'test', 'inferred', SupportedLanguage.Python)).to.equal( - 'z.object({url: z.function().args(z.object({scheme: z.string(), host: z.string()})).returns(z.string()), timeout: z.number(), retries: z.number()})', - ) - }) - }) - - // describe('getAllTemplateStrings', () => { - // let inferrer: SchemaInferrer - - // beforeEach(() => { - // // Initialize SchemaInferrer - // inferrer = new SchemaInferrer(logger) - // }) - - // it('should extract strings from direct string values', () => { - // const config: Config = { - // configType: 'CONFIG', - // key: 'test-config', - // rows: [ - // { - // values: [ - // { - // value: { - // string: 'Hello {{name}}!', - // }, - // }, - // ], - // }, - // ], - // valueType: 'STRING', - // } - - // const result = inferrer.getAllTemplateStrings(config) - - // expect(result).to.have.length(1) - // expect(result).to.contain('Hello {{name}}!') - // }) - - // it('should extract strings from JSON values', () => { - // const config: Config = { - // configType: 'CONFIG', - // key: 'test-json-config', - // rows: [ - // { - // values: [ - // { - // value: { - // json: { - // json: JSON.stringify({ - // farewell: 'Goodbye {{name}}!', - // greeting: 'Hello {{name}}!', - // nested: { - // message: 'Welcome to {{place}}!', - // }, - // }), - // }, - // }, - // }, - // ], - // }, - // ], - // schemaKey: '', - // valueType: 'JSON', - // } - - // const result = inferrer.getAllTemplateStrings(config) - - // expect(result).to.have.length(3) - // expect(result).to.contain('Hello {{name}}!') - // expect(result).to.contain('Goodbye {{name}}!') - // expect(result).to.contain('Welcome to {{place}}!') - // }) - - // it('should handle mixed string and JSON values', () => { - // const config: Config = { - // configType: 'CONFIG', - // key: 'mixed-config', - // rows: [ - // { - // values: [ - // { - // value: { - // string: 'Direct {{variable}}', - // }, - // }, - // ], - // }, - // { - // values: [ - // { - // value: { - // json: { - // json: JSON.stringify({ - // text: 'JSON {{variable}}', - // }), - // }, - // }, - // }, - // ], - // }, - // ], - // schemaKey: '', - // valueType: 'STRING', - // } - - // const result = inferrer.getAllTemplateStrings(config) - - // expect(result).to.have.length(2) - // expect(result).to.contain('Direct {{variable}}') - // expect(result).to.contain('JSON {{variable}}') - // }) - - // it('should handle empty values gracefully', () => { - // const config: Config = { - // configType: 'CONFIG', - // key: 'empty-config', - // rows: [ - // { - // values: [ - // { - // value: {}, // Empty value object - // }, - // ], - // }, - // ], - // schemaKey: '', - // valueType: 'STRING', - // } - - // const result = inferrer.getAllTemplateStrings(config) - - // expect(result).to.have.length(0) - // }) - // }) - - describe('getAllStringsAtLocation', () => { - let inferrer: SchemaInferrer - - beforeEach(() => { - inferrer = new SchemaInferrer(logger) - }) - - it('should get direct string values when location is empty', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test-config', - rows: [ - { - values: [ - { - value: { - string: 'Hello {{name}}!', - }, - }, - ], - }, - { - values: [ - { - value: { - string: 'Goodbye {{name}}!', - }, - }, - ], - }, - ], - valueType: 'STRING', - } - - // Using TypeScript's type system to access private method for testing - const result = (inferrer as any).getAllStringsAtLocation(config, []) - - expect(result).to.have.length(2) - expect(result).to.contain('Hello {{name}}!') - expect(result).to.contain('Goodbye {{name}}!') - }) - - it('should get nested string values from JSON when location is provided', () => { - const config: Config = { - configType: 'CONFIG', - key: 'test-json-config', - rows: [ - { - values: [ - { - value: { - json: { - json: JSON.stringify({ - nested: { - deeply: { - message: 'Hello from deep inside!', - }, - }, - }), - }, - }, - }, - ], - }, - { - values: [ - { - value: { - json: { - json: JSON.stringify({ - nested: { - deeply: { - message: 'Another deep message!', - }, - }, - }), - }, - }, - }, - ], - }, - ], - valueType: 'JSON', - } - - // Using TypeScript's type system to access private method for testing - const result = (inferrer as any).getAllStringsAtLocation(config, ['nested', 'deeply', 'message']) - - expect(result).to.have.length(2) - expect(result).to.contain('Hello from deep inside!') - expect(result).to.contain('Another deep message!') - }) - }) - - it('should merge two schemas with the same string property', () => { - const inferrer = new SchemaInferrer(logger) - // @ts-expect-error accessing private method for testing - const mergeSchemas = inferrer.mergeSchemas.bind(inferrer) - - const schemaA = z.object({ - name: z.string().optional(), - }) - - const schemaB = z.object({ - name: z.string().optional(), - }) - - const result = mergeSchemas(schemaA, schemaB) - expect(result._def.typeName).to.equal('ZodObject') - expect(ZodUtils.zodToString(result, 'test', 'user', SupportedLanguage.TypeScript)).to.equal( - 'z.object({name: z.string().optional()})', - ) - }) - - it('should merge two schemas with the same string property that are not optional', () => { - const inferrer = new SchemaInferrer(logger) - // @ts-expect-error accessing private method for testing - const mergeSchemas = inferrer.mergeSchemas.bind(inferrer) - - const schemaA = z.object({ - name: z.string(), - }) - - const schemaB = z.object({ - name: z.string(), - }) - - const result = mergeSchemas(schemaA, schemaB) - expect(result._def.typeName).to.equal('ZodObject') - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'optionalRequiredAccess({name: z.string()})', - ) - }) - - it('should merge two schemas with conflict', () => { - const inferrer = new SchemaInferrer(logger) - // @ts-expect-error accessing private method for testing - const mergeSchemas = inferrer.mergeSchemas.bind(inferrer) - - const schemaA = z.object({ - conflict: z.string(), - }) - - const schemaB = z.object({ - conflict: z.number(), - }) - - const result = mergeSchemas(schemaA, schemaB) - expect(result._def.typeName).to.equal('ZodObject') - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'optionalRequiredAccess({conflict: z.union([z.string(), z.number()])})', - ) - }) - it('should merge two schemas with conflict and optional', () => { - const inferrer = new SchemaInferrer(logger) - // @ts-expect-error accessing private method for testing - const mergeSchemas = inferrer.mergeSchemas.bind(inferrer) - - const schemaA = z.object({ - conflict: z.string().optional(), - }) - - const schemaB = z.object({ - conflict: z.number().optional(), - }) - - const result = mergeSchemas(schemaA, schemaB) - expect(result._def.typeName).to.equal('ZodObject') - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'optionalRequiredAccess({conflict: z.union([z.string(), z.number()]).optional()})', - ) - }) - it('should merge two schemas with a missing key to make other optional', () => { - const inferrer = new SchemaInferrer(logger) - // @ts-expect-error accessing private method for testing - const mergeSchemas = inferrer.mergeSchemas.bind(inferrer) - - const schemaA = z.object({}) - - const schemaB = z.object({ - placeholder: z.string(), - }) - - const result = mergeSchemas(schemaA, schemaB) - expect(result._def.typeName).to.equal('ZodObject') - expect(ZodUtils.zodToString(result, 'test', 'inferred', SupportedLanguage.React)).to.equal( - 'z.object({placeholder: z.string().optional()})', - ) - }) -}) diff --git a/test/codegen/zod-generator.test.ts b/test/codegen/zod-generator.test.ts deleted file mode 100644 index e9c204e..0000000 --- a/test/codegen/zod-generator.test.ts +++ /dev/null @@ -1,413 +0,0 @@ -import {expect} from 'chai' - -import {type Config, type ConfigFile, SupportedLanguage} from '../../src/codegen/types.js' -import {ZodGenerator} from '../../src/codegen/zod-generator.js' - -/** - * Helper function to compare strings with normalized line endings - */ -function expectToEqualWithNormalizedLineEndings(actual: string, expected: string): void { - expect(actual.trim().replaceAll('\r\n', '\n')).to.equal(expected) -} - -const logger = (category: string | unknown, message?: unknown) => { - console.log(category, message) -} - -// Simple tests using the actual filesystem and templates -describe('ZodGenerator', () => { - let mockConfigFile: ConfigFile - let mockBoolConfig: Config - let mockStringConfig: Config - let mockObjectConfig: Config - let mockObjectWithPlaceholderConfig: Config - let mockObjectWithPlaceholderConfigMultiValue: Config - let mockTemplateConfig: Config - - beforeEach(() => { - // Create a boolean feature flag config - mockBoolConfig = { - configType: 'FEATURE_FLAG', - key: 'example.feature.flag', - rows: [ - { - values: [ - { - value: { - bool: true, - }, - }, - ], - }, - ], - valueType: 'BOOL', - } - - // Create a string config - mockStringConfig = { - configType: 'CONFIG', - key: 'example.config.string', - rows: [ - { - values: [ - { - value: { - string: 'test-value', - }, - }, - ], - }, - ], - valueType: 'STRING', - } - - mockObjectConfig = { - configType: 'CONFIG', - key: 'example.config.object', - rows: [ - { - values: [ - { - value: { - json: {json: '{"name": "John", "age": 30}'}, - }, - }, - ], - }, - ], - valueType: 'JSON', - } - - mockObjectWithPlaceholderConfig = { - configType: 'CONFIG', - key: 'example.config.object', - rows: [ - { - values: [ - { - value: { - json: {json: '{"template": "template {{placeholder}}"}'}, - }, - }, - ], - }, - ], - valueType: 'JSON', - } - mockObjectWithPlaceholderConfigMultiValue = { - configType: 'CONFIG', - key: 'example.config.object', - rows: [ - { - values: [ - { - value: { - json: {json: '{"template": "template {{other_placeholder}}", "num": 20}'}, - }, - }, - { - value: { - json: {json: '{"template": "template {{placeholder}}"}'}, - }, - }, - ], - }, - ], - valueType: 'JSON', - } - // Create a template string config (function) - mockTemplateConfig = { - configType: 'CONFIG', - key: 'example.config.function', - rows: [ - { - values: [ - { - value: { - string: '{{name}}', - }, - }, - ], - }, - ], - valueType: 'STRING', - } - - // Create mock config file with sample configs - mockConfigFile = { - configs: [mockBoolConfig, mockStringConfig, mockObjectConfig, mockTemplateConfig], - } - }) - - describe('generate', () => { - it('should generate a output for configs', () => { - const generator = new ZodGenerator(SupportedLanguage.TypeScript, mockConfigFile, logger) - const output = generator.generate() - - expect(output).to.include('PrefabConfig') - }) - - it('should throw if method names conflict', () => { - mockConfigFile.configs = [ - ...mockConfigFile.configs, - { - configType: 'FEATURE_FLAG', - key: 'example-feature-flag', - rows: [ - { - values: [ - { - value: { - bool: true, - }, - }, - ], - }, - ], - valueType: 'BOOL', - }, - ] - const generator = new ZodGenerator(SupportedLanguage.TypeScript, mockConfigFile, logger) - - expect(() => generator.generate()).to.throw( - `Method 'exampleFeatureFlag' is already registered. Prefab key example.feature.flag conflicts with example-feature-flag`, - ) - }) - }) - - describe('generateAccessorMethod', () => { - it('should generate a boolean accessor method correctly', () => { - const generator = new ZodGenerator(SupportedLanguage.TypeScript, mockConfigFile, logger) - const accessorMethod = generator.generateAccessorMethod(mockBoolConfig) - - expect(accessorMethod.methodName).to.equal('exampleFeatureFlag') - expect(accessorMethod.key).to.equal('example.feature.flag') - expect(accessorMethod.isFunctionReturn).to.be.false - expect(accessorMethod.returnType).to.equal('boolean') - expect(accessorMethod.returnValue).to.equal('raw') - }) - - it('should generate a string accessor method correctly', () => { - const generator = new ZodGenerator(SupportedLanguage.TypeScript, mockConfigFile, logger) - const accessorMethod = generator.generateAccessorMethod(mockStringConfig) - - expect(accessorMethod.methodName).to.equal('exampleConfigString') - expect(accessorMethod.key).to.equal('example.config.string') - expect(accessorMethod.isFunctionReturn).to.be.false - expect(accessorMethod.returnType).to.equal('string') - expect(accessorMethod.returnValue).to.equal('raw') - }) - - it('should generate a template function accessor method correctly', () => { - const generator = new ZodGenerator(SupportedLanguage.TypeScript, mockConfigFile, logger) - const accessorMethod = generator.generateAccessorMethod(mockTemplateConfig) - - expect(accessorMethod.methodName).to.equal('exampleConfigFunction') - expect(accessorMethod.key).to.equal('example.config.function') - expect(accessorMethod.isFunctionReturn).to.be.true - expect(accessorMethod.returnType).to.equal('string') - expect(accessorMethod.returnValue).to.equal('(params: { name: string }) => Mustache.render(raw ?? "", params)') - expect(accessorMethod.params).to.equal('{ name: string }') - }) - }) - - describe('renderAccessorMethod', () => { - it('should render a boolean accessor method with the actual template', () => { - const generator = new ZodGenerator(SupportedLanguage.TypeScript, mockConfigFile, logger) - const result = generator.renderAccessorMethod(mockBoolConfig) - - // Use a single multiline string assertion for better readability - const expectedOutput = `exampleFeatureFlag(contexts?: Contexts | ContextObj): boolean { - const raw = this.get('example.feature.flag', contexts); - return raw; - }` - - // Normalize line endings before comparison - expectToEqualWithNormalizedLineEndings(result, expectedOutput) - }) - - it('should render a template function accessor method with the actual template', () => { - const generator = new ZodGenerator(SupportedLanguage.TypeScript, mockConfigFile, logger) - const result = generator.renderAccessorMethod(mockTemplateConfig) - - // Use a single multiline string assertion for better readability - const expectedOutput = `exampleConfigFunction(contexts?: Contexts | ContextObj): (params: { name: string }) => string { - const raw = this.get('example.config.function', contexts); - return (params: { name: string }) => Mustache.render(raw ?? "", params); - }` - - // Normalize line endings before comparison - expectToEqualWithNormalizedLineEndings(result, expectedOutput) - }) - - it('should render a JSON object accessor method with the actual template', () => { - const generator = new ZodGenerator(SupportedLanguage.TypeScript, mockConfigFile, logger) - const result = generator.renderAccessorMethod(mockObjectConfig) - - // Use a single multiline string assertion for better readability - const expectedOutput = `exampleConfigObject(contexts?: Contexts | ContextObj): { name: string; age: number } { - const raw = this.get('example.config.object', contexts); - return { "name": raw["name"], "age": raw["age"] }; - }` - - // Normalize line endings before comparison - expectToEqualWithNormalizedLineEndings(result, expectedOutput) - }) - }) - - describe('End-to-end config rendering', () => { - // This test shows how to test the rendering of a single config without mocking - it('should generate a complete accessor method for the complex config', () => { - // A more complex example with nested templates - const complexConfig: Config = { - configType: 'CONFIG', - key: 'example.greeting.template', - rows: [ - { - values: [ - { - value: { - string: 'Hello {{name}}! Welcome to {{company}}. Your ID is {{user.id}}.', - }, - }, - ], - }, - ], - valueType: 'STRING', - } - - // Create a new ConfigFile with just this config - const singleConfigFile: ConfigFile = { - configs: [complexConfig], - } - - // Create a new generator just for this config - const generator = new ZodGenerator(SupportedLanguage.TypeScript, singleConfigFile, logger) - - // Generate the accessor method - const accessorMethod = generator.renderAccessorMethod(complexConfig) - - // Use a single multiline string assertion for better readability - const expectedOutput = `exampleGreetingTemplate(contexts?: Contexts | ContextObj): (params: { name: string; company: string; user.id: string }) => string { - const raw = this.get('example.greeting.template', contexts); - return (params: { name: string; company: string; user.id: string }) => Mustache.render(raw ?? "", params); - }` - - // Normalize line endings before comparison - expectToEqualWithNormalizedLineEndings(accessorMethod, expectedOutput) - }) - }) - - describe('renderAccessorMethod python', () => { - it('should render a boolean accessor method with the actual template', () => { - const generator = new ZodGenerator(SupportedLanguage.Python, mockConfigFile, logger) - const result = generator.renderAccessorMethod(mockBoolConfig) - - // Use a single multiline string assertion for better readability - const expectedOutput = `def exampleFeatureFlag(self): - raw = self.get('example.feature.flag') - return raw` - - // Normalize line endings before comparison - expectToEqualWithNormalizedLineEndings(result, expectedOutput) - }) - it('should render a template method with the actual template', () => { - const generator = new ZodGenerator(SupportedLanguage.Python, mockConfigFile, logger) - const result = generator.renderAccessorMethod(mockTemplateConfig) - - // Use a single multiline string assertion for better readability - const expectedOutput = `def exampleConfigFunction(self): - raw = self.get('example.config.function') - return lambda params: pystache.render(raw, params)` - - // Normalize line endings before comparison - expectToEqualWithNormalizedLineEndings(result, expectedOutput) - }) - it('should render a JSON object with the actual template', () => { - const generator = new ZodGenerator(SupportedLanguage.Python, mockConfigFile, logger) - const result = generator.renderAccessorMethod(mockObjectConfig) - - // Use a single multiline string assertion for better readability - const expectedOutput = `def exampleConfigObject(self): - raw = self.get('example.config.object') - return { "name": raw["name"], "age": raw["age"] }` - - // Normalize line endings before comparison - expectToEqualWithNormalizedLineEndings(result, expectedOutput) - }) - }) - - describe('renderAccessorMethod for JSON with templates', () => { - it('should render a JSON object with template placeholders for TypeScript', () => { - const generator = new ZodGenerator(SupportedLanguage.TypeScript, mockConfigFile, logger) - const result = generator.renderAccessorMethod(mockObjectWithPlaceholderConfig) - - // Use a single multiline string assertion for better readability - const expectedOutput = `exampleConfigObject(contexts?: Contexts | ContextObj): { template: (params: { placeholder: string }) => string } { - const raw = this.get('example.config.object', contexts); - return { "template": (params: { placeholder: string }) => Mustache.render(raw["template"] ?? "", params) }; - }` - - // Normalize line endings before comparison - expectToEqualWithNormalizedLineEndings(result, expectedOutput) - }) - - it('should render a JSON object with multiple values and templates for TypeScript', () => { - const generator = new ZodGenerator(SupportedLanguage.TypeScript, mockConfigFile, logger) - const result = generator.renderAccessorMethod(mockObjectWithPlaceholderConfigMultiValue) - - // Use a single multiline string assertion for better readability - const expectedOutput = `exampleConfigObject(contexts?: Contexts | ContextObj): { template: (params: { other_placeholder: string; placeholder: string }) => string; num?: number } { - const raw = this.get('example.config.object', contexts); - return { "template": (params: { other_placeholder: string; placeholder: string }) => Mustache.render(raw["template"] ?? "", params), "num": raw["num"] }; - }` - - // Normalize line endings before comparison - expectToEqualWithNormalizedLineEndings(result, expectedOutput) - }) - }) - - describe('renderAccessorMethod python for JSON with templates', () => { - it('should render a JSON object with template placeholders for Python', () => { - const generator = new ZodGenerator(SupportedLanguage.Python, mockConfigFile, logger) - const result = generator.renderAccessorMethod(mockObjectWithPlaceholderConfig) - - // Use a single multiline string assertion for better readability - const expectedOutput = `def exampleConfigObject(self): - raw = self.get('example.config.object') - return { "template": lambda params: pystache.render(raw["template"], params) }` - - // Normalize line endings before comparison - expectToEqualWithNormalizedLineEndings(result, expectedOutput) - }) - - it('should render a JSON object with multiple values and templates for Python', () => { - const generator = new ZodGenerator(SupportedLanguage.Python, mockConfigFile, logger) - const result = generator.renderAccessorMethod(mockObjectWithPlaceholderConfigMultiValue) - - // Use a single multiline string assertion for better readability - const expectedOutput = `def exampleConfigObject(self): - raw = self.get('example.config.object') - return { "template": lambda params: pystache.render(raw["template"], params), "num": raw["num"] }` - - // Normalize line endings before comparison - expectToEqualWithNormalizedLineEndings(result, expectedOutput) - }) - }) - - describe('renderAccessorMethod ruby for JSON with templates', () => { - it('should render a JSON object with template placeholders for Ruby', () => { - const generator = new ZodGenerator(SupportedLanguage.Ruby, mockConfigFile, logger) - const result = generator.renderAccessorMethod(mockObjectWithPlaceholderConfig) - - // Use a single multiline string assertion for better readability - const expectedOutput = `def self.exampleConfigObject(default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED) - raw = self.get('example.config.object', default, jit_context) - return { "template": ->(params) { Mustache.render(raw["template"], params)} } - end` - - // Normalize line endings before comparison - expectToEqualWithNormalizedLineEndings(result, expectedOutput) - }) - }) -}) diff --git a/test/codegen/zod-utils.test.ts b/test/codegen/zod-utils.test.ts deleted file mode 100644 index 5266ec4..0000000 --- a/test/codegen/zod-utils.test.ts +++ /dev/null @@ -1,483 +0,0 @@ -import {expect} from '@oclif/test' -import {z} from 'zod' - -import {SupportedLanguage} from '../../src/codegen/types.js' -import {ZodUtils} from '../../src/codegen/zod-utils.js' - -describe('ZodUtils', () => { - describe('zodToString', () => { - it('should convert a ZodString to string representation', () => { - const schema = z.string() - expect(ZodUtils.zodToString(schema, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal('z.string()') - }) - - it('should convert a ZodBoolean to string representation', () => { - const schema = z.boolean() - expect(ZodUtils.zodToString(schema, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal('z.boolean()') - }) - - it('should convert a Zod integer to string representation', () => { - const schema = z.number().int() - expect(ZodUtils.zodToString(schema, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'z.number().int()', - ) - }) - - it('should convert a regular Zod number to string representation', () => { - const schema = z.number() - const result = ZodUtils.zodToString(schema, 'test', 'inferred', SupportedLanguage.TypeScript) - expect(result).to.equal('z.number()') - }) - - it('should convert a ZodObject to string representation', () => { - const schema = z.object({ - age: z.string(), - name: z.string(), - }) - const result = ZodUtils.zodToString(schema, 'test', 'inferred', SupportedLanguage.TypeScript) - expect(result).to.contain('optionalRequiredAccess({') - expect(result).to.contain('name: z.string()') - expect(result).to.contain('age: z.string()') - - const result2 = ZodUtils.zodToString(schema, 'test', 'user', SupportedLanguage.TypeScript) - expect(result2).to.contain('z.object({') - expect(result2).to.contain('name: z.string()') - expect(result2).to.contain('age: z.string()') - - const resultPython = ZodUtils.zodToString(schema, 'test', 'user', SupportedLanguage.Python) - expect(resultPython).to.contain('z.object({') - expect(resultPython).to.contain('name: z.string()') - expect(resultPython).to.contain('age: z.string()') - - const resultPython2 = ZodUtils.zodToString(schema, 'test', 'inferred', SupportedLanguage.Python) - expect(resultPython2).to.contain('z.object({') - expect(resultPython2).to.contain('name: z.string()') - expect(resultPython2).to.contain('age: z.string()') - }) - - it('should convert a ZodArray to string representation', () => { - const schema = z.array(z.string()) - expect(ZodUtils.zodToString(schema, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'z.array(z.string())', - ) - }) - - it('should convert a ZodOptional to string representation', () => { - const schema = z.string().optional() - expect(ZodUtils.zodToString(schema, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'z.string().optional()', - ) - }) - - it('should convert a union to string representation', () => { - const schema = z.union([z.string(), z.number()]) - expect(ZodUtils.zodToString(schema, 'test', 'inferred', SupportedLanguage.TypeScript)).to.equal( - 'z.union([z.string(), z.number()])', - ) - }) - }) - - describe('keyToMethodName', () => { - it('should convert simple keys to camelCase method names', () => { - expect(ZodUtils.keyToMethodName('test')).to.equal('test') - expect(ZodUtils.keyToMethodName('test_key')).to.equal('testKey') - }) - - it('should convert dotted keys to method names', () => { - expect(ZodUtils.keyToMethodName('user.profile')).to.equal('userProfile') - expect(ZodUtils.keyToMethodName('app.settings.theme')).to.equal('appSettingsTheme') - }) - - it('should ensure method names are valid identifiers', () => { - expect(ZodUtils.keyToMethodName('1test')).to.equal('_1test') - expect(ZodUtils.keyToMethodName('test-key')).to.equal('testKey') - }) - it('should convert simple keys', () => { - expect(ZodUtils.keyToMethodName('flag.tidelift')).to.equal('flagTidelift') - expect(ZodUtils.keyToMethodName('simple.config')).to.equal('simpleConfig') - }) - - it('should handle hyphens', () => { - expect(ZodUtils.keyToMethodName('flag.tide-lift')).to.equal('flagTideLift') - expect(ZodUtils.keyToMethodName('multi-word.key-name')).to.equal('multiWordKeyName') - }) - - it('should properly camelCase parts after the first one', () => { - expect(ZodUtils.keyToMethodName('first.second')).to.equal('firstSecond') - expect(ZodUtils.keyToMethodName('module.feature.enabled')).to.equal('moduleFeatureEnabled') - }) - - it('should deal with spaces', () => { - expect(ZodUtils.keyToMethodName('first second')).to.equal('firstSecond') - expect(ZodUtils.keyToMethodName('module feature.is-enabled')).to.equal('moduleFeatureIsEnabled') - }) - - it('should handle complex keys with special characters', () => { - // The key '234nas6234^&#$__///WHY_OH_WHY' will be processed by: - // 1. Adding an underscore for the numeric prefix: '_234nas6234' - // 2. Converting special chars to dots: '_234nas6234...__...WHY_OH_WHY' - // 3. Normalizing consecutive dots: '_234nas6234._.WHY_OH_WHY' - // 4. Splitting by dots: ['_234nas6234', '_', 'WHY_OH_WHY'] - // 5. Camel-casing parts: ['_234nas6234', '_', 'whyOhWhy'] - // 6. Joining with camelCase: '_234nas6234WhyOhWhy' - expect(ZodUtils.keyToMethodName('234nas6234^&#$__///WHY_OH_WHY')).to.equal('_234nas6234WhyOhWhy') - }) - }) - - describe('keyToSchemaName', () => { - it('should convert keys to schema variable names', () => { - expect(ZodUtils.keyToSchemaName('test')).to.equal('testSchema') - expect(ZodUtils.keyToSchemaName('app.settings')).to.equal('appSettingsSchema') - }) - }) - - describe('makeSafeIdentifier', () => { - it('should ensure identifiers start with a letter or underscore', () => { - expect(ZodUtils.makeSafeIdentifier('123abc')).to.equal('_123abc') - expect(ZodUtils.makeSafeIdentifier('_abc')).to.equal('_abc') - expect(ZodUtils.makeSafeIdentifier('abc')).to.equal('abc') - }) - - it('should replace invalid characters with underscores', () => { - expect(ZodUtils.makeSafeIdentifier('a-b-c')).to.equal('a_b_c') - expect(ZodUtils.makeSafeIdentifier('a.b.c')).to.equal('a_b_c') - expect(ZodUtils.makeSafeIdentifier('a@b#c')).to.equal('a_b_c') - }) - }) - - describe('simplifyFunctions', () => { - it('should keep primitive types unchanged', () => { - const stringSchema = z.string() - const numberSchema = z.number() - const booleanSchema = z.boolean() - - expect(ZodUtils.simplifyFunctions(stringSchema)).to.equal(stringSchema) - expect(ZodUtils.simplifyFunctions(numberSchema)).to.equal(numberSchema) - expect(ZodUtils.simplifyFunctions(booleanSchema)).to.equal(booleanSchema) - }) - - it('should replace function with its return type', () => { - const fnSchema = z.function().args(z.string()).returns(z.number()) - - const result = ZodUtils.simplifyFunctions(fnSchema) - expect(result._def.typeName).to.equal('ZodNumber') - }) - - it('should handle complex function schemas', () => { - const complexFnSchema = z - .function() - .args( - z.object({ - accent: z.string(), - role: z.boolean().optional(), - users: z.array( - z.object({ - language: z.string(), - name: z.string(), - }), - ), - }), - ) - .returns(z.string()) - - const result = ZodUtils.simplifyFunctions(complexFnSchema) - expect(result._def.typeName).to.equal('ZodString') - }) - - it('should handle objects with function properties', () => { - const objWithFn = z.object({ - getAge: z.function().args(z.void()).returns(z.number()), - name: z.string(), - }) - - const result = ZodUtils.simplifyFunctions(objWithFn) - expect(result._def.typeName).to.equal('ZodObject') - - const shape = result._def.shape() - expect(shape.name._def.typeName).to.equal('ZodString') - expect(shape.getAge._def.typeName).to.equal('ZodNumber') - }) - - it('should handle arrays of functions', () => { - const arrayOfFns = z.array(z.function().args(z.string()).returns(z.boolean())) - - const result = ZodUtils.simplifyFunctions(arrayOfFns) - expect(result._def.typeName).to.equal('ZodArray') - expect(result._def.type._def.typeName).to.equal('ZodBoolean') - }) - - it('should handle nested objects with functions', () => { - const nestedObj = z.object({ - user: z.object({ - getDetails: z - .function() - .args(z.void()) - .returns( - z.object({ - age: z.number(), - email: z.string(), - }), - ), - name: z.string(), - }), - }) - - const result = ZodUtils.simplifyFunctions(nestedObj) - expect(result._def.typeName).to.equal('ZodObject') - - const userShape = result._def.shape().user._def.shape() - expect(userShape.name._def.typeName).to.equal('ZodString') - expect(userShape.getDetails._def.typeName).to.equal('ZodObject') - - const detailsShape = userShape.getDetails._def.shape() - expect(detailsShape.age._def.typeName).to.equal('ZodNumber') - expect(detailsShape.email._def.typeName).to.equal('ZodString') - }) - - it('should handle optional functions', () => { - const optionalFn = z.function().args(z.string()).returns(z.number()).optional() - - const result = ZodUtils.simplifyFunctions(optionalFn) - expect(result._def.typeName).to.equal('ZodOptional') - expect(result._def.innerType._def.typeName).to.equal('ZodNumber') - }) - - it('should handle union types containing functions', () => { - const unionWithFn = z.union([z.string(), z.function().args(z.void()).returns(z.boolean())]) - - const result = ZodUtils.simplifyFunctions(unionWithFn) - expect(result._def.typeName).to.equal('ZodUnion') - - const {options} = result._def - expect(options[0]._def.typeName).to.equal('ZodString') - expect(options[1]._def.typeName).to.equal('ZodBoolean') - }) - }) - - describe('generateReturnValueCode', () => { - it('should return "raw" for primitive types', () => { - expect(ZodUtils.generateReturnValueCode(z.string(), '', SupportedLanguage.TypeScript)).to.equal('raw') - expect(ZodUtils.generateReturnValueCode(z.number(), '', SupportedLanguage.TypeScript)).to.equal('raw') - expect(ZodUtils.generateReturnValueCode(z.boolean(), '', SupportedLanguage.TypeScript)).to.equal('raw') - }) - - it('should handle arrays with primitive elements', () => { - const arraySchema = z.array(z.string()) - expect(ZodUtils.generateReturnValueCode(arraySchema, '', SupportedLanguage.TypeScript)).to.equal('raw') - }) - - it('should handle simple objects', () => { - const objSchema = z.object({ - age: z.number(), - name: z.string(), - }) - - const result = ZodUtils.generateReturnValueCode(objSchema, '', SupportedLanguage.TypeScript) - expect(result).to.equal('{ "age": raw["age"], "name": raw["name"] }') - }) - - it('should handle simple objects in python', () => { - const objSchema = z.object({ - age: z.number(), - name: z.string(), - }) - - const result = ZodUtils.generateReturnValueCode(objSchema, '', SupportedLanguage.Python) - expect(result).to.equal('{ "age": raw["age"], "name": raw["name"] }') - }) - - it('should handle placeholder in object', () => { - const nestedSchema = z.object({ - message: z - .function() - .args(z.object({name: z.string()})) - .returns(z.number()), - }) - const result = ZodUtils.generateReturnValueCode(nestedSchema, '', SupportedLanguage.TypeScript) - expect(result).to.equal( - '{ "message": (params: { name: string }) => Mustache.render(raw["message"] ?? "", params) }', - ) - }) - - it('should handle placeholder in typescript', () => { - const placeholderSchema = z - .function() - .args(z.object({name: z.string()})) - .returns(z.number()) - const result = ZodUtils.generateReturnValueCode(placeholderSchema, '', SupportedLanguage.TypeScript) - expect(result).to.equal('(params: { name: string }) => Mustache.render(raw ?? "", params)') - }) - - it('should handle placeholder in Python', () => { - const placeholderSchema = z - .function() - .args(z.object({name: z.string()})) - .returns(z.number()) - const result = ZodUtils.generateReturnValueCode(placeholderSchema, '', SupportedLanguage.Python) - expect(result).to.equal('lambda params: pystache.render(raw, params)') - }) - - it('should handle placeholder in object in python', () => { - const nestedSchema = z.object({ - message: z - .function() - .args(z.object({name: z.string()})) - .returns(z.number()), - }) - const result = ZodUtils.generateReturnValueCode(nestedSchema, '', SupportedLanguage.Python) - expect(result).to.equal('{ "message": lambda params: pystache.render(raw["message"], params) }') - }) - - it('should handle deep nested function placeholders', () => { - const deepNestedSchema = z.object({ - data: z.object({ - greeting: z - .function() - .args(z.object({name: z.string(), title: z.string()})) - .returns(z.string()), - }), - }) - - const result = ZodUtils.generateReturnValueCode(deepNestedSchema, '', SupportedLanguage.TypeScript) - expect(result).to.equal( - '{ "data": { "greeting": (params: { name: string; title: string }) => Mustache.render(raw["data"]["greeting"] ?? "", params) } }', - ) - }) - - it('should handle multiple function placeholders in object', () => { - const multiSchema = z.object({ - model: z.string(), - systemMessage: z - .function() - .args(z.object({placeholders: z.string()})) - .returns(z.string()), - temperature: z.number(), - userMessage: z - .function() - .args( - z.object({ - extractedFiltersAsText: z.string(), - userMessage: z.string(), - }), - ) - .returns(z.string()), - }) - - const result = ZodUtils.generateReturnValueCode(multiSchema, '', SupportedLanguage.TypeScript) - expect(result).to.contain( - '"systemMessage": (params: { placeholders: string }) => Mustache.render(raw["systemMessage"] ?? "", params)', - ) - expect(result).to.contain( - '"userMessage": (params: { extractedFiltersAsText: string; userMessage: string }) => Mustache.render(raw["userMessage"] ?? "", params)', - ) - expect(result).to.contain('"model": raw["model"]') - expect(result).to.contain('"temperature": raw["temperature"]') - }) - }) - - it('should handle nested objects', () => { - const nestedSchema = z.object({ - user: z.object({ - name: z.string(), - profile: z.object({ - bio: z.string(), - }), - }), - }) - - const result = ZodUtils.generateReturnValueCode(nestedSchema, '', SupportedLanguage.TypeScript) - expect(result).to.equal( - '{ "user": { "name": raw["user"]["name"], "profile": { "bio": raw["user"]["profile"]["bio"] } } }', - ) - }) - - it('should handle arrays of objects', () => { - const arrayOfObjSchema = z.array( - z.object({ - id: z.number(), - name: z.string(), - }), - ) - - const result = ZodUtils.generateReturnValueCode(arrayOfObjSchema, '', SupportedLanguage.TypeScript) - expect(result).to.equal('raw') - }) - - it('should handle arrays of strings', () => { - const arrayOfStringSchema = z.array(z.string()) - - let result = ZodUtils.generateReturnValueCode(arrayOfStringSchema, '', SupportedLanguage.TypeScript) - expect(result).to.equal('raw') - result = ZodUtils.generateReturnValueCode(arrayOfStringSchema, '', SupportedLanguage.Python) - expect(result).to.equal('raw') - }) - - it('should handle optional fields', () => { - const optionalSchema = z.object({ - age: z.number().optional(), - name: z.string(), - }) - - const result = ZodUtils.generateReturnValueCode(optionalSchema, '', SupportedLanguage.TypeScript) - expect(result).to.equal('{ "age": raw["age"], "name": raw["name"] }') - }) - - it('should handle functions by using their return type', () => { - const fnSchema = z.function().args(z.string()).returns(z.number()) - - expect(ZodUtils.generateReturnValueCode(fnSchema, '', SupportedLanguage.TypeScript)).to.equal( - '(params: string) => Mustache.render(raw ?? "", params)', - ) - }) -}) - -describe('paramsOf', () => { - it('should return undefined for non-function schemas', () => { - const stringSchema = z.string() - const result = ZodUtils.paramsOf(stringSchema) - expect(result).to.be.undefined - }) - - it('should extract arguments schema from function schema', () => { - const argsSchema = z.object({age: z.number(), name: z.string()}) - const fnSchema = z.function().args(argsSchema).returns(z.boolean()) - - const result = ZodUtils.paramsOf(fnSchema) - expect(result).to.not.be.undefined - if (result) { - expect(ZodUtils.zodToString(result, '', 'user', SupportedLanguage.TypeScript)).to.equal( - 'z.object({age: z.number(), name: z.string()})', - ) - } - }) - - it('should remove optional arguments schema from function schema', () => { - const argsSchema = z.object({name: z.string(), optional: z.string().optional()}) - const fnSchema = z.function().args(argsSchema).returns(z.string()) - - const result = ZodUtils.paramsOf(fnSchema) - expect(result).to.not.be.undefined - if (result) { - expect(ZodUtils.zodToString(result, '', 'user', SupportedLanguage.TypeScript)).to.equal( - 'z.object({name: z.string(), optional: z.string()})', - ) - } - }) -}) - -describe('zodTypeToTypescript', () => { - it('should return the correct typescript type for a zod type', () => { - const result = ZodUtils.zodTypeToTypescript(z.string()) - expect(result).to.equal('string') - }) - - it('should return the correct typescript type for an optional zod type', () => { - const result = ZodUtils.zodTypeToTypescript(z.string().optional()) - expect(result).to.equal('string?') - }) - - it('should return the correct typescript type for an array of zod types', () => { - const result = ZodUtils.zodTypeToTypescript(z.object({age: z.number().optional(), name: z.string()})) - expect(result).to.equal('{ age?: number; name: string }') - }) -}) diff --git a/test/commands/generate.test.ts b/test/commands/generate.test.ts index 2d14843..1402e9a 100644 --- a/test/commands/generate.test.ts +++ b/test/commands/generate.test.ts @@ -45,13 +45,6 @@ describe('generate', () => { expect(ctx.stdout).to.include('Generating react code for configs') }) - test - .stdout() - .command(['generate', '--target', 'python-pydantic']) - .it('generates Python code', (ctx) => { - expect(ctx.stdout).to.include('Generating python code for configs') - }) - test .stdout() .command(['generate', '--target', 'invalid']) From 7634b53d0b1f220c67f81e0b6acb0c0a547cca52 Mon Sep 17 00:00:00 2001 From: Mark Faga Date: Mon, 16 Jun 2025 21:20:18 -0400 Subject: [PATCH 3/4] chore: remove superfluous console.log --- src/codegen/code-generators/node-typescript-generator.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/codegen/code-generators/node-typescript-generator.ts b/src/codegen/code-generators/node-typescript-generator.ts index 9781e7b..fe6a311 100644 --- a/src/codegen/code-generators/node-typescript-generator.ts +++ b/src/codegen/code-generators/node-typescript-generator.ts @@ -62,8 +62,6 @@ export class NodeTypeScriptGenerator extends BaseTypescriptGenerator { methodName = `_${methodName}` } - console.log(config.key, methodName) - if (uniqueMethods[methodName]) { throw new Error( `Method '${methodName}' is already registered. Prefab key ${config.key} conflicts with '${uniqueMethods[methodName]}'!`, From 6d10542cc8b1ea994b2024479eea15ec927f0816 Mon Sep 17 00:00:00 2001 From: Mark Faga Date: Tue, 17 Jun 2025 09:37:58 -0400 Subject: [PATCH 4/4] feat: improve test readability --- test/codegen/schema-extractor.test.ts | 105 +++++++++++++++----------- 1 file changed, 63 insertions(+), 42 deletions(-) diff --git a/test/codegen/schema-extractor.test.ts b/test/codegen/schema-extractor.test.ts index 9b0ea94..6084dcf 100644 --- a/test/codegen/schema-extractor.test.ts +++ b/test/codegen/schema-extractor.test.ts @@ -1,12 +1,21 @@ import {expect} from 'chai' +import {oneLineTrim} from 'common-tags' import {z} from 'zod' +import {ZodToStringMapper} from '../../src/codegen/language-mappers/zod-to-string-mapper.js' import {SchemaExtractor} from '../../src/codegen/schema-extractor.js' import {type Config, type ConfigFile} from '../../src/codegen/types.js' // Custom duration type map returning a string const durationTypeMap = () => z.string() +function expectSchemaMatchesString(schema: z.ZodTypeAny, expectedString: string): void { + const schemaString = new ZodToStringMapper().resolveType(schema).split(' ').join('') + const expectedStringResult = expectedString.split(' ').join('') + + expect(oneLineTrim(schemaString)).to.equal(oneLineTrim(expectedStringResult)) +} + describe('SchemaExtractor', () => { let mockLog: (category: string | unknown, message?: unknown) => void let schemaExtractor: SchemaExtractor @@ -54,10 +63,15 @@ describe('SchemaExtractor', () => { const result = schemaExtractor.execute({config, configFile}) - expect(result._def.typeName).equal('ZodObject') - expect(Object.keys(result._def.shape()).sort()).to.deep.equal(['age', 'name']) - expect(result._def.shape().age._def.typeName).equal('ZodNumber') - expect(result._def.shape().name._def.typeName).equal('ZodString') + expectSchemaMatchesString( + result, + ` + z.object({ + name: z.string(); + age: z.number().int() + }) + `, + ) }) it('should infer schema when no user-defined schema is available', () => { @@ -84,9 +98,7 @@ describe('SchemaExtractor', () => { } const result = schemaExtractor.execute({config, configFile}) - - // Verify the result is a string schema - expect(result._def.typeName).to.equal('ZodString') + expectSchemaMatchesString(result, 'z.string()') }) it('should infer a union of schema when multiple schemas are found', () => { @@ -135,16 +147,17 @@ describe('SchemaExtractor', () => { const result = schemaExtractor.execute({config, configFile}) - expect(result._def.typeName).to.equal('ZodUnion') - expect(result._def.options.length).to.equal(2) - expect(result._def.options[0]._def.typeName).to.equal('ZodObject') - expect(Object.keys(result._def.options[0]._def.shape()).sort()).to.deep.equal([ - 'enterprise', - 'premium', - 'standard', - ]) - expect(result._def.options[1]._def.typeName).to.equal('ZodObject') - expect(Object.keys(result._def.options[1]._def.shape()).sort()).to.deep.equal(['freemium', 'premium', 'standard']) + expectSchemaMatchesString( + result, + ` + z.union( + [ + z.object({enterprise: z.number(); premium: z.number(); standard: z.number()}), + z.object({freemium: z.number(); premium: z.number(); standard: z.number()}) + ] + ) + `, + ) }) it('should use custom duration type map when provided', () => { @@ -172,8 +185,7 @@ describe('SchemaExtractor', () => { const result = schemaExtractor.execute({config, configFile, durationTypeMap}) - // Verify the result uses our custom duration type - expect(result._def.typeName).to.equal('ZodString') + expectSchemaMatchesString(result, `z.string()`) }) it('should replace strings with Mustache templates when found', () => { @@ -201,14 +213,20 @@ describe('SchemaExtractor', () => { const result = schemaExtractor.execute({config, configFile}) - // Verify the result is a function schema that takes a name string param and returns a string - expect(result._def.typeName).to.equal('ZodFunction') - expect(result._def.args._def.typeName).to.equal('ZodTuple') - expect(result._def.args._def.items.length).to.equal(1) - expect(result._def.args._def.items[0]._def.typeName).to.equal('ZodObject') - expect(Object.keys(result._def.args._def.items[0]._def.shape())).to.deep.equal(['name']) - expect(result._def.args._def.items[0]._def.shape().name._def.typeName).to.equal('ZodString') - expect(result._def.returns._def.typeName).to.equal('ZodString') + expectSchemaMatchesString( + result, + ` + z.function() + .args( + z.object({ + name: z.string() + }) + ) + .returns( + z.string() + ) + `, + ) }) it('should replace strings with a union of Mustache templates when found', () => { @@ -241,22 +259,25 @@ describe('SchemaExtractor', () => { const result = schemaExtractor.execute({config, configFile}) - // Verify the result is a function schema that takes a name string param and returns a string - expect(result._def.typeName).to.equal('ZodFunction') - expect(result._def.args._def.typeName).to.equal('ZodTuple') - expect(result._def.args._def.items.length).to.equal(1) - expect(result._def.args._def.items[0]._def.typeName).to.equal('ZodUnion') - expect(result._def.args._def.items[0].options.length).to.equal(2) - expect(result._def.args._def.items[0].options[1]._def.typeName).to.equal('ZodObject') - expect(Object.keys(result._def.args._def.items[0].options[0]._def.shape()).sort()).to.deep.equal(['name']) - expect(result._def.args._def.items[0]._def.options[0]._def.shape().name._def.typeName).to.equal('ZodString') - expect(Object.keys(result._def.args._def.items[0].options[1]._def.shape()).sort()).to.deep.equal([ - 'differentName', - ]) - expect(result._def.args._def.items[0]._def.options[1]._def.shape().differentName._def.typeName).to.equal( - 'ZodString', + expectSchemaMatchesString( + result, + ` + z.function() + .args( + z.union([ + z.object({ + name: z.string() + }), + z.object({ + differentName: z.string() + }) + ]) + ) + .returns( + z.string() + ) + `, ) - expect(result._def.returns._def.typeName).to.equal('ZodString') }) }) })