From 15c9b1e957bdcd260a9d5caac7d76e2b7cc8ae51 Mon Sep 17 00:00:00 2001 From: Mark Faga Date: Tue, 10 Jun 2025 13:22:19 -0400 Subject: [PATCH] feat: implement new typescript code generators --- .eslintrc.json | 4 + package.json | 4 + src/codegen/code-generators/base-generator.ts | 2 +- .../base-typescript-generator.ts | 44 ++ .../node-typescript-generator.ts | 105 ++++ .../react-typescript-generator.ts | 123 +++++ .../language-mappers/json-to-zod-mapper.ts | 71 +++ .../language-mappers/zod-base-mapper.ts | 82 +++ .../language-mappers/zod-to-string-mapper.ts | 103 ++++ .../zod-to-typescript-mapper.ts | 130 +++++ .../zod-to-typescript-return-value-mapper.ts | 148 ++++++ src/codegen/python/generator.ts | 1 - src/codegen/schema-evaluator.ts | 4 +- src/codegen/schema-extractor.ts | 294 +++++++++++ src/codegen/types.ts | 20 + src/commands/generate.ts | 11 +- .../node-typescript-generator.test.ts | 464 ++++++++++++++++ .../react-typescript-generator.test.ts | 497 ++++++++++++++++++ .../json-to-zod-mapper.test.ts | 90 ++++ .../zod-to-string-mapper.test.ts | 213 ++++++++ .../zod-to-typescript-mapper.test.ts | 255 +++++++++ ...-to-typescript-return-value-mapper.test.ts | 398 ++++++++++++++ test/codegen/schema-extractor.test.ts | 262 +++++++++ yarn.lock | 22 + 24 files changed, 3343 insertions(+), 4 deletions(-) create mode 100644 src/codegen/code-generators/base-typescript-generator.ts create mode 100644 src/codegen/code-generators/node-typescript-generator.ts create mode 100644 src/codegen/code-generators/react-typescript-generator.ts create mode 100644 src/codegen/language-mappers/json-to-zod-mapper.ts create mode 100644 src/codegen/language-mappers/zod-base-mapper.ts create mode 100644 src/codegen/language-mappers/zod-to-string-mapper.ts create mode 100644 src/codegen/language-mappers/zod-to-typescript-mapper.ts create mode 100644 src/codegen/language-mappers/zod-to-typescript-return-value-mapper.ts create mode 100644 src/codegen/schema-extractor.ts create mode 100644 test/codegen/code-generators/node-typescript-generator.test.ts create mode 100644 test/codegen/code-generators/react-typescript-generator.test.ts create mode 100644 test/codegen/language-mappers/json-to-zod-mapper.test.ts create mode 100644 test/codegen/language-mappers/zod-to-string-mapper.test.ts create mode 100644 test/codegen/language-mappers/zod-to-typescript-mapper.test.ts create mode 100644 test/codegen/language-mappers/zod-to-typescript-return-value-mapper.test.ts create mode 100644 test/codegen/schema-extractor.test.ts diff --git a/.eslintrc.json b/.eslintrc.json index 124c901..a8b62ce 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -9,7 +9,11 @@ "no-throw-literal": "off", "no-warning-comments": "off", "unicorn/consistent-destructuring": "off", + "unicorn/no-array-for-each": "off", + "unicorn/no-array-reduce": "off", "unicorn/prefer-ternary": "off", + "unicorn/switch-case-braces": "off", + "padding-line-between-statements": "off", "prefer-destructuring": "off" }, "overrides": [ diff --git a/package.json b/package.json index 1b5366d..2103b62 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "acorn": "^8.10.0", "acorn-walk": "^8.2.0", "chalk": "^5.4.1", + "common-tags": "^1.8.2", "fuzzy": "^0.1.3", "inquirer-autocomplete-standalone": "^0.8.1", + "lodash.camelcase": "^4.3.0", "mustache": "^4.2.0", "zod": "^3.22.4" }, @@ -21,6 +23,8 @@ "@oclif/prettier-config": "^0.2.1", "@oclif/test": "^3", "@types/chai": "^4", + "@types/common-tags": "^1.8.4", + "@types/lodash.camelcase": "^4.3.9", "@types/mocha": "^10", "@types/mustache": "^4.2.5", "@types/node": "^18", diff --git a/src/codegen/code-generators/base-generator.ts b/src/codegen/code-generators/base-generator.ts index 60b8814..c2be36c 100644 --- a/src/codegen/code-generators/base-generator.ts +++ b/src/codegen/code-generators/base-generator.ts @@ -1,6 +1,6 @@ import {type ConfigFile} from '../types.js' -interface BaseGeneratorArgs { +export interface BaseGeneratorArgs { configFile: ConfigFile log: (category: string | unknown, message?: unknown) => void } diff --git a/src/codegen/code-generators/base-typescript-generator.ts b/src/codegen/code-generators/base-typescript-generator.ts new file mode 100644 index 0000000..7484f6f --- /dev/null +++ b/src/codegen/code-generators/base-typescript-generator.ts @@ -0,0 +1,44 @@ +import {z} from 'zod' + +import {ZodToTypescriptMapper} from '../language-mappers/zod-to-typescript-mapper.js' +import {SchemaExtractor} from '../schema-extractor.js' +import {BaseGenerator, BaseGeneratorArgs} from './base-generator.js' + +export abstract class BaseTypescriptGenerator extends BaseGenerator { + protected MUSTACHE_IMPORT = "import Mustache from 'mustache'" + private schemaExtractor: SchemaExtractor + + constructor({configFile, log}: BaseGeneratorArgs) { + super({configFile, log}) + this.schemaExtractor = new SchemaExtractor(log) + } + + protected configurations() { + return this.configFile.configs + .filter((config) => config.configType === 'FEATURE_FLAG' || config.configType === 'CONFIG') + .filter((config) => config.rows.length > 0) + .sort((a, b) => a.key.localeCompare(b.key)) + .map((config) => { + const schema = this.schemaExtractor.execute({ + config, + configFile: this.configFile, + durationTypeMap: this.durationTypeMap, + }) + + return { + configType: config.configType, + hasFunction: schema && new ZodToTypescriptMapper().resolveType(schema).includes('=>'), + key: config.key, + schema, + sendToClientSdk: config.sendToClientSdk ?? false, + } + }) + } + + protected durationTypeMap(): z.ZodTypeAny { + return z.number() + } + + abstract get filename(): string + abstract generate(): string +} diff --git a/src/codegen/code-generators/node-typescript-generator.ts b/src/codegen/code-generators/node-typescript-generator.ts new file mode 100644 index 0000000..9781e7b --- /dev/null +++ b/src/codegen/code-generators/node-typescript-generator.ts @@ -0,0 +1,105 @@ +import {stripIndent} from 'common-tags' +import camelCase from 'lodash.camelcase' + +import {ZodToTypescriptMapper, type ZodToTypescriptMapperTarget} from '../language-mappers/zod-to-typescript-mapper.js' +import {ZodToTypescriptReturnValueMapper} from '../language-mappers/zod-to-typescript-return-value-mapper.js' +import {BaseTypescriptGenerator} from './base-typescript-generator.js' + +export class NodeTypeScriptGenerator extends BaseTypescriptGenerator { + get filename(): string { + return 'prefab-server.ts' + } + + generate(): string { + return stripIndent` + /* eslint-disable */ + // AUTOGENERATED by prefab-cli's 'gen' command + import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' + ${this.additionalDependencies().join('\n') || '// No additional dependencies required'} + + type ContextObj = Record> + + declare namespace PrefabTypeGeneration { + export type NodeServerConfigurationRaw = { + ${this.generateSchemaTypes('raw').join('\n ') || '// No types generated'} + } + + export type NodeServerConfigurationAccessor = { + ${this.generateSchemaTypes().join('\n ') || '// No types generated'} + } + } + + export class PrefabTypesafeNode { + constructor(private prefab: Prefab) { } + + get(key: K, contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationRaw[K] { + return this.prefab.get(key, contexts) as PrefabTypeGeneration.NodeServerConfigurationRaw[K] + } + + ${this.generateAccessorMethods().join('\n\n ') || '// No methods generated'} + } + ` + } + + private additionalDependencies(): string[] { + const dependencies: string[] = [] + const hasFunctions = this.configurations().some((c) => c.hasFunction) + + if (hasFunctions) { + dependencies.push(this.MUSTACHE_IMPORT) + } + + return dependencies + } + + private generateAccessorMethods(): string[] { + const uniqueMethods: Record = {} + const schemaTypes = this.configurations().map((config) => { + let methodName = camelCase(config.key) + + // If the method name starts with a digit, prefix it with an underscore to ensure method name is valid + if (/^\d/.test(methodName)) { + 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]}'!`, + ) + } + + uniqueMethods[methodName] = config.key + + if (config.hasFunction) { + const returnValue = new ZodToTypescriptReturnValueMapper().resolveType(config.schema) + + return stripIndent` + ${methodName}(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['${config.key}'] { + const raw = this.get('${config.key}', contexts) + return ${returnValue} + } + ` + } + + return stripIndent` + ${methodName}(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['${config.key}'] { + return this.get('${config.key}', contexts) + } + ` + }) + + return schemaTypes + } + + private generateSchemaTypes(target: ZodToTypescriptMapperTarget = 'accessor'): string[] { + const schemaTypes = this.configurations().flatMap((config) => { + const mapper = new ZodToTypescriptMapper({fieldName: config.key, target}) + + return mapper.renderField(config.schema) + }) + + return schemaTypes + } +} diff --git a/src/codegen/code-generators/react-typescript-generator.ts b/src/codegen/code-generators/react-typescript-generator.ts new file mode 100644 index 0000000..aec1f55 --- /dev/null +++ b/src/codegen/code-generators/react-typescript-generator.ts @@ -0,0 +1,123 @@ +import {stripIndent} from 'common-tags' +import camelCase from 'lodash.camelcase' +import {z} from 'zod' + +import {ZodToTypescriptMapper, type ZodToTypescriptMapperTarget} from '../language-mappers/zod-to-typescript-mapper.js' +import {ZodToTypescriptReturnValueMapper} from '../language-mappers/zod-to-typescript-return-value-mapper.js' +import {BaseTypescriptGenerator} from './base-typescript-generator.js' + +export class ReactTypeScriptGenerator extends BaseTypescriptGenerator { + get filename(): string { + return 'prefab-client.ts' + } + + protected durationTypeMap(): z.ZodTypeAny { + return z.object({ms: z.number(), seconds: z.number()}) + } + + generate(): string { + return 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" + ${this.additionalDependencies().join('\n') || '// No additional dependencies required'} + + declare namespace PrefabTypeGeneration { + export type ReactHookConfigurationRaw = { + ${this.generateSchemaTypes('raw').join('\n ') || '// No types generated'} + } + + export type ReactHookConfigurationAccessor = { + ${this.generateSchemaTypes().join('\n ') || '// No types generated'} + } + } + + export class PrefabTypesafeReact { + constructor(private prefab: Prefab) { } + + get(key: K): PrefabTypeGeneration.ReactHookConfigurationRaw[K] { + return this.prefab.get(key) as PrefabTypeGeneration.ReactHookConfigurationRaw[K] + } + + ${this.generateAccessorMethods().join('\n\n ') || '// No methods generated'} + } + + export const usePrefab = createPrefabHook(PrefabTypesafeReact) + ` + } + + private additionalDependencies(): string[] { + const dependencies: string[] = [] + const hasFunctions = this.filteredConfigurations().some((c) => c.hasFunction) + + if (hasFunctions) { + dependencies.push(this.MUSTACHE_IMPORT) + } + + return dependencies + } + + private filteredConfigurations() { + return this.configurations().filter( + (config) => config.configType === 'FEATURE_FLAG' || config.sendToClientSdk === true, + ) + } + + private generateAccessorMethods(): string[] { + const uniqueMethods: Record = {} + const schemaTypes = this.filteredConfigurations().map((config) => { + let methodName = camelCase(config.key) + + // If the method name starts with a digit, prefix it with an underscore to ensure method name is valid + if (/^\d/.test(methodName)) { + methodName = `_${methodName}` + } + + if (uniqueMethods[methodName]) { + throw new Error( + `Method '${methodName}' is already registered. Prefab key ${config.key} conflicts with '${uniqueMethods[methodName]}'!`, + ) + } + + uniqueMethods[methodName] = config.key + + if (config.configType === 'FEATURE_FLAG') { + return stripIndent` + get ${methodName}(): boolean { + return this.prefab.isEnabled('${config.key}') + } + ` + } + + if (config.hasFunction) { + const returnValue = new ZodToTypescriptReturnValueMapper().resolveType(config.schema) + + return stripIndent` + ${methodName}(): PrefabTypeGeneration.ReactHookConfigurationAccessor['${config.key}'] { + const raw = this.get('${config.key}') + return ${returnValue} + } + ` + } + + return stripIndent` + get ${methodName}(): PrefabTypeGeneration.ReactHookConfigurationAccessor['${config.key}'] { + return this.get('${config.key}') + } + ` + }) + + return schemaTypes + } + + private generateSchemaTypes(target: ZodToTypescriptMapperTarget = 'accessor'): string[] { + const schemaTypes = this.filteredConfigurations().map((config) => { + const mapper = new ZodToTypescriptMapper({fieldName: config.key, target}) + + return mapper.renderField(config.schema) + }) + + return schemaTypes + } +} diff --git a/src/codegen/language-mappers/json-to-zod-mapper.ts b/src/codegen/language-mappers/json-to-zod-mapper.ts new file mode 100644 index 0000000..31f72df --- /dev/null +++ b/src/codegen/language-mappers/json-to-zod-mapper.ts @@ -0,0 +1,71 @@ +import {z} from 'zod' + +export class JsonToZodMapper { + resolve(data: unknown): z.ZodTypeAny { + if (Array.isArray(data)) { + if (data.length > 0) { + // Check if all elements in the array have the same type + const firstItem = data[0] + + const isHomogeneous = data.every((item) => { + const itemsMatch = typeof item === typeof firstItem + + // Special handling for objects and arrays + if (typeof firstItem === 'object') { + if (Array.isArray(item)) { + return Array.isArray(firstItem) + } + + return !Array.isArray(firstItem) + } + + return itemsMatch + }) + + // For homogeneous arrays, use the first element's type + if (isHomogeneous) { + return z.array(this.resolve(data[0])) + } + + // Explicitly do not handle mixed-type arrays + // They could be tuples or heterogeneous arrays + // Instead, we return an array of unknowns + } + + return z.array(z.unknown()) + } + + if (typeof data === 'object' && data !== null) { + const shape: Record = {} + const dataRecord = data as Record + for (const key in dataRecord) { + if (Object.hasOwn(dataRecord, key)) { + shape[key] = this.resolve(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() + } + + console.warn(`Unknown json type:`, data) + + // If the type is not recognized, default to 'any' + return z.any() + } +} diff --git a/src/codegen/language-mappers/zod-base-mapper.ts b/src/codegen/language-mappers/zod-base-mapper.ts new file mode 100644 index 0000000..37a33f4 --- /dev/null +++ b/src/codegen/language-mappers/zod-base-mapper.ts @@ -0,0 +1,82 @@ +import {z} from 'zod' + +import {ZodTypeSupported} from '../types.js' + +export abstract class ZodBaseMapper { + resolveType(type: ZodTypeSupported): string { + const def = type._def + + switch (def.typeName) { + case 'ZodAny': + return this.any() + case 'ZodArray': { + const internalType = this.resolveType(def.type) + return this.array(internalType) + } + case 'ZodBoolean': + return this.boolean() + case 'ZodEnum': { + const values = def.values.map((v) => v) + return this.enum(values) + } + case 'ZodFunction': { + const args = this.functionArguments(def.args) + const returns = this.functionReturns(def.returns) + + return this.function(args, returns) + } + case 'ZodNull': + return this.null() + case 'ZodNumber': { + const isInteger = def.checks.some((check) => check.kind === 'int') + return this.number(isInteger) + } + case 'ZodObject': { + const shape = def.shape() + const props = Object.entries(shape) + return this.object(props) + } + case 'ZodOptional': { + const internalType = this.resolveType(def.innerType) + return this.optional(internalType) + } + case 'ZodString': + return this.string() + case 'ZodTuple': { + const items = def.items.map((item) => this.resolveType(item)) + + return this.tuple(items) + } + case 'ZodUndefined': + return this.undefined() + case 'ZodUnion': { + const options = def.options.map((option) => this.resolveType(option)) + return this.union(options) + } + case 'ZodUnknown': + return this.unknown() + default: + console.warn(`Unknown zod type:`, type) + + // If the type is not recognized, default to 'any' + return this.any() + } + } + + protected abstract any(): string + protected abstract array(wrappedType: string): string + protected abstract boolean(): string + protected abstract enum(values: string[]): string + protected abstract function(args: string, returns: string): string + protected abstract functionArguments(value?: z.ZodTuple): string + protected abstract functionReturns(value: z.ZodTypeAny): string + protected abstract null(): string + protected abstract number(isInteger: boolean): string + protected abstract object(properties: [string, z.ZodTypeAny][]): string + protected abstract optional(wrappedType: string): string + protected abstract string(): string + protected abstract tuple(wrappedTypes: string[]): string + protected abstract undefined(): string + protected abstract union(wrappedTypes: string[]): string + protected abstract unknown(): string +} diff --git a/src/codegen/language-mappers/zod-to-string-mapper.ts b/src/codegen/language-mappers/zod-to-string-mapper.ts new file mode 100644 index 0000000..ca0b4eb --- /dev/null +++ b/src/codegen/language-mappers/zod-to-string-mapper.ts @@ -0,0 +1,103 @@ +import {z} from 'zod' + +import {ZodTypeSupported} from '../types.js' +import {ZodBaseMapper} from './zod-base-mapper.js' + +export class ZodToStringMapper extends ZodBaseMapper { + any() { + return 'z.any()' + } + + array(wrappedType: string) { + return `z.array(${wrappedType})` + } + + boolean() { + return 'z.boolean()' + } + + enum(values: string[]) { + return `z.enum([${values.map((v) => `'${v}'`).join(',')}])` + } + + function(args: string, returns: string) { + return `z.function().args(${args}).returns(${returns})` + } + + functionArguments(value?: z.ZodTuple): string { + if (!value) { + return '' + } + + const args = value._def.items.map((item) => { + const mapper = new ZodToStringMapper() + return mapper.resolveType(item) + }) + + return args.join(', ') + } + + functionReturns(value: z.ZodTypeAny): string { + const mapper = new ZodToStringMapper() + return mapper.resolveType(value) + } + + null() { + return 'z.null()' + } + + number(isInteger: boolean = false) { + const base = 'z.number()' + + if (isInteger) { + return `${base}.int()` + } + + return base + } + + object(properties: [string, z.ZodTypeAny][]) { + const props = properties + .map(([key, type]) => { + const mapper = new ZodToStringMapper() + return mapper.renderField(type, key) + }) + .join('; ') + + return `z.object({${props}})` + } + + optional(wrappedType: string) { + return `${wrappedType}.optional()` + } + + renderField(type: ZodTypeSupported, key?: string): string { + const resolved = this.resolveType(type) + + if (key) { + return `${key}: ${resolved}` + } + + return resolved + } + + string() { + return 'z.string()' + } + + tuple(wrappedTypes: string[]) { + return `z.tuple([${wrappedTypes.join(', ')}])` + } + + undefined() { + return 'z.undefined()' + } + + union(wrappedTypes: string[]) { + return `z.union([${wrappedTypes.join(', ')}])` + } + + unknown() { + return 'z.unknown()' + } +} diff --git a/src/codegen/language-mappers/zod-to-typescript-mapper.ts b/src/codegen/language-mappers/zod-to-typescript-mapper.ts new file mode 100644 index 0000000..2e31f30 --- /dev/null +++ b/src/codegen/language-mappers/zod-to-typescript-mapper.ts @@ -0,0 +1,130 @@ +import {z} from 'zod' + +import {ZodTypeSupported} from '../types.js' +import {ZodBaseMapper} from './zod-base-mapper.js' + +export type ZodToTypescriptMapperTarget = 'accessor' | 'raw' + +export class ZodToTypescriptMapper extends ZodBaseMapper { + private fieldName: string | undefined + private optionalProperty: boolean + private target: ZodToTypescriptMapperTarget + + constructor({fieldName, target}: {fieldName?: string; target?: ZodToTypescriptMapperTarget} = {}) { + super() + this.fieldName = fieldName + this.optionalProperty = false + this.target = target ?? 'accessor' + } + + any() { + return 'any' + } + + array(wrappedType: string) { + return `Array<${wrappedType}>` + } + + boolean() { + return 'boolean' + } + + enum(values: string[]) { + return values.map((v) => `'${v}'`).join(' | ') + } + + function(args: string, returns: string) { + // When in raw mode, we return a string type for functions, + // as this is what comes back from the server directly + if (this.target === 'raw') { + return 'string | undefined' + } + + return `(...params: ${args}) => ${returns}` + } + + functionArguments(value?: z.ZodTuple): string { + if (!value) { + return '' + } + + const mapper = new ZodToTypescriptMapper() + return mapper.resolveType(value) + } + + functionReturns(value: z.ZodTypeAny): string { + const mapper = new ZodToTypescriptMapper() + return mapper.resolveType(value) + } + + null() { + return 'null' + } + + number() { + return 'number' + } + + object(properties: [string, z.ZodTypeAny][]) { + const props = properties + .map(([fieldName, type]) => { + const mapper = new ZodToTypescriptMapper({fieldName, target: this.target}) + return mapper.renderField(type) + }) + .join('; ') + + return `{ ${props} }` + } + + optional(wrappedType: string) { + // In TypeScript, we hoist the optional flag to the field definition when operating directly on a field + if (this.fieldName) { + this.optionalProperty = true + return wrappedType + } + + // Fallback to a union type w/undefined for inline optional definitions + return this.union([wrappedType, 'undefined']) + } + + renderField(type: ZodTypeSupported): string { + if (!this.fieldName) { + throw new Error('Field name must be set to render a field.') + } + + // Must invoke resolveType to ensure the type is fully resolved, + // which always guarantees that the optional flag is set correctly. + const resolved = this.resolveType(type) + + return `"${this.fieldName}"${this.optionalProperty ? '?' : ''}: ${resolved}` + } + + string() { + return 'string' + } + + tuple(wrappedTypes: string[]) { + return `[${wrappedTypes.join(', ')}]` + } + + undefined() { + return 'undefined' + } + + union(wrappedTypes: string[]) { + return wrappedTypes + .map((t) => { + // If the type includes an arrow function, we need to wrap it in parentheses + if (t.includes('=>')) { + return `(${t})` + } + + return t + }) + .join(' | ') + } + + unknown() { + return 'unknown' + } +} diff --git a/src/codegen/language-mappers/zod-to-typescript-return-value-mapper.ts b/src/codegen/language-mappers/zod-to-typescript-return-value-mapper.ts new file mode 100644 index 0000000..858c081 --- /dev/null +++ b/src/codegen/language-mappers/zod-to-typescript-return-value-mapper.ts @@ -0,0 +1,148 @@ +import {z} from 'zod' + +import {ZodTypeSupported} from '../types.js' +import {ZodBaseMapper} from './zod-base-mapper.js' + +export class ZodToTypescriptReturnValueMapper extends ZodBaseMapper { + private fieldName: string | undefined + private FUNCTION_ARGUMENTS_NAME = 'params' + private returnTypePropertyPath: string[] + + constructor({fieldName, returnTypePropertyPath}: {fieldName?: string; returnTypePropertyPath?: string[]} = {}) { + super() + this.fieldName = fieldName + this.returnTypePropertyPath = returnTypePropertyPath ?? [] + } + + any() { + return `raw${this.printPropertyPath()}` + } + + array() { + // Explicity not supporting naviagtion into array items, as the number of items is not known at compile time + return `raw${this.printPath(this.returnTypePropertyPath)}` + } + + boolean() { + return `raw${this.printPropertyPath()}` + } + + enum() { + return `raw${this.printPropertyPath()}` + } + + function(args: string, returns: string) { + return `(${args}) => ${returns}` + } + + functionArguments(value?: z.ZodTuple): string { + if (!value) { + return '' + } + + // Everything is already typed, so return top level params object + return this.FUNCTION_ARGUMENTS_NAME + } + + // Mustache handles rendering of function values + functionReturns(): string { + return `Mustache.render(raw${this.printPropertyPath()} ?? "", ${this.FUNCTION_ARGUMENTS_NAME})` + } + + null() { + return `raw${this.printPropertyPath()}` + } + + number() { + return `raw${this.printPropertyPath()}` + } + + object(properties: [string, z.ZodTypeAny][]) { + const props = properties + .map(([fieldName, type]) => { + const mapper = new ZodToTypescriptReturnValueMapper({ + fieldName, + returnTypePropertyPath: [...this.returnTypePropertyPath, fieldName], + }) + return mapper.renderField(type) + }) + .join(', ') + + return `{ ${props} }` + } + + optional(wrappedType: string) { + // In TypeScript, we hoist the optional flag to the field definition when operating directly on a field + if (this.fieldName) { + return wrappedType + } + + // Fallback to a union type w/undefined for inline optional definitions + return this.union([wrappedType, 'undefined']) + } + + renderField(type: ZodTypeSupported): string { + if (!this.fieldName) { + throw new Error('Field name must be set in the resolution context to render a field.') + } + + // Must invoke resolveType to ensure the type is fully resolved, + // which always guarantees that the optional flag is set correctly. + const resolved = this.resolveType(type) + + return `"${this.fieldName}": ${resolved}` + } + + string() { + return `raw${this.printPropertyPath()}` + } + + tuple(wrappedTypes: string[]) { + const tupleNavigation = wrappedTypes.map((wt, index) => { + let massagedWrappedType = wt + + if (massagedWrappedType !== 'raw') { + // Remove trailing ! from the wrapped type + massagedWrappedType = massagedWrappedType.replace(/!$/, '') + } + + return `${massagedWrappedType}?.[${index}]!` + }) + + return `[${tupleNavigation.join(`, `)}]` + } + + undefined() { + return `raw${this.printPropertyPath()}` + } + + union(wrappedTypes: string[]) { + // If we have functions in the union, force the return type to be a function type + const functionWrappedTypes = wrappedTypes.filter((t) => t.includes('=>')) + if (functionWrappedTypes.length > 0) { + return functionWrappedTypes[0] + } + + return `raw${this.printPropertyPath()}` + } + + unknown() { + return `raw${this.printPropertyPath()}` + } + + private printPath(paths: string[]): string { + if (paths.length === 0) { + return '' + } + + // Always safe navigate the path to ensure we don't throw an error if the property doesn't exist + const path = paths.reduce((acc, part) => `${acc}?.['${part}']`, '') + + // To satisfy TypeScript's type system, tell it the value is always defined + return `${path}!` + } + + private printPropertyPath(): string { + return this.printPath(this.returnTypePropertyPath) + } +} diff --git a/src/codegen/python/generator.ts b/src/codegen/python/generator.ts index 06197e6..38c9b06 100644 --- a/src/codegen/python/generator.ts +++ b/src/codegen/python/generator.ts @@ -15,7 +15,6 @@ export function generatePythonClientCode( configFile.configs .filter((config) => config.configType === 'FEATURE_FLAG' || config.configType === 'CONFIG') .filter((config) => config.rows.length > 0) - // eslint-disable-next-line unicorn/no-array-for-each .forEach((config) => { const {schema: inferredSchema} = schemaInferrer.zodForConfig(config, configFile, SupportedLanguage.Python) generator.registerMethod( diff --git a/src/codegen/schema-evaluator.ts b/src/codegen/schema-evaluator.ts index f080f1a..f7d1b7d 100644 --- a/src/codegen/schema-evaluator.ts +++ b/src/codegen/schema-evaluator.ts @@ -5,6 +5,8 @@ import * as acorn from 'acorn' import * as walk from 'acorn-walk' import {z} from 'zod' +import {ZodTypeSupported} from './types.js' + /** * Options for the secure schema validator */ @@ -211,7 +213,7 @@ export function validateAst(ast: any, parentMap: WeakMap): {error?: st export function secureEvaluateSchema( schemaString: string, options: SecureSchemaValidatorOptions = {}, -): {error?: string; schema?: z.ZodType; success: boolean} { +): {error?: string; schema?: ZodTypeSupported; success: boolean} { const mergedOptions = {...DEFAULT_OPTIONS, ...options} const trimmedSchema = schemaString.trim() diff --git a/src/codegen/schema-extractor.ts b/src/codegen/schema-extractor.ts new file mode 100644 index 0000000..6327818 --- /dev/null +++ b/src/codegen/schema-extractor.ts @@ -0,0 +1,294 @@ +import {z} from 'zod' + +import {JsonToZodMapper} from './language-mappers/json-to-zod-mapper.js' +import {ZodToStringMapper} from './language-mappers/zod-to-string-mapper.js' +import {MustacheExtractor} from './mustache-extractor.js' +import {secureEvaluateSchema} from './schema-evaluator.js' +import {type Config, type ConfigFile} from './types.js' + +export type DurationTypeMap = () => z.ZodTypeAny + +export class SchemaExtractor { + constructor(private log: (category: string | unknown, message?: unknown) => void) {} + + execute({ + config, + configFile, + durationTypeMap, + }: { + config: Config + configFile: ConfigFile + durationTypeMap?: DurationTypeMap + }) { + const userDefinedSchema = this.resolveUserSchema(config, configFile) + + const schemaWithoutMustache = userDefinedSchema ?? this.inferFromConfig(config, durationTypeMap) + + return this.replaceStringsWithMustache(schemaWithoutMustache, config) + } + + private createZodFunctionFromMustacheStrings(strings: string[]): z.ZodTypeAny { + if (strings.length === 0) { + return z.string() + } + + if (strings.length > 1) { + // De-dup exact schemas + const schemas: Record = {} + strings.forEach((str) => { + const schema = MustacheExtractor.extractSchema(str, this.log) + const stringSchema = new ZodToStringMapper().renderField(schema) + + schemas[stringSchema] = schema + }) + + const schemaResults = Object.values(schemas) + + // If the schema is empty (no properties), just return basic string + const noSchemasPresent = schemaResults.every((s) => Object.keys(s._def.shape()).length === 0) + if (noSchemasPresent) { + return z.string() + } + + // Return an explicit function if we have only one schema defined + if (schemaResults.length === 1) { + return z.function().args(schemaResults[0]).returns(z.string()) + } + + // Return a function with a union of schemas if we have multiple defined + // @ts-expect-error It's not clear why the type is not compatible here.... z.ZodTypeAny[] vs. [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]] + return z.function().args(z.union(schemaResults)).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()) + } + + 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 inferFromConfig(config: Config, durationTypeMap?: DurationTypeMap): 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 durationTypeMap?.() ?? z.number() + } + + case 'JSON': { + const jsonValues = this.getAllJsonValues(config) + this.log('JSON values:', JSON.stringify(jsonValues, null, 2)) + + if (jsonValues.length > 0) { + try { + // De-dup exact schemas + const schemas: Record = {} + jsonValues.forEach((json) => { + const schema = new JsonToZodMapper().resolve(json) + const stringSchema = new ZodToStringMapper().renderField(schema) + + schemas[stringSchema] = schema + }) + + const schemaResults = Object.values(schemas) + + // Return a single schema explicitly if we have only one defined + if (schemaResults.length === 1) { + return schemaResults[0] + } + + // Return a union of schemas if we have multiple defined + // @ts-expect-error It's not clear why the type is not compatible here.... z.ZodTypeAny[] vs. [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]] + return z.union(schemaResults) + } 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 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 + } + + // @ts-expect-error OK with not explicitly returning undefined + private resolveUserSchema(config: Config, configFile: ConfigFile) { + const {schemaKey} = config + + const schemaConfig = schemaKey + ? configFile.configs.find((c) => c.key === schemaKey && c.configType === 'SCHEMA') + : undefined + + if (schemaConfig) { + 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}`) + } + } + } + } + } +} diff --git a/src/codegen/types.ts b/src/codegen/types.ts index aa1f2bc..7cc6b71 100644 --- a/src/codegen/types.ts +++ b/src/codegen/types.ts @@ -1,3 +1,5 @@ +import {z} from 'zod' + export enum SupportedLanguage { Python = 'python', React = 'react', @@ -37,3 +39,21 @@ export interface Config { export interface ConfigFile { configs: Config[] } + +export type SupportedZodTypes = + | z.ZodAnyDef + | z.ZodArrayDef + | z.ZodBooleanDef + | z.ZodEnumDef + | z.ZodFunctionDef + | z.ZodNullDef + | z.ZodNumberDef + | z.ZodObjectDef + | z.ZodOptionalDef + | z.ZodStringDef + | z.ZodTupleDef + | z.ZodUndefinedDef + | z.ZodUnionDef + | z.ZodUnknownDef + +export type ZodTypeSupported = z.ZodType diff --git a/src/commands/generate.ts b/src/commands/generate.ts index af53b80..abe7b2b 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -3,6 +3,8 @@ import {Flags} from '@oclif/core' import type {JsonObj} from '../result.js' import {BaseGenerator} from '../codegen/code-generators/base-generator.js' +import {NodeTypeScriptGenerator} from '../codegen/code-generators/node-typescript-generator.js' +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' @@ -74,7 +76,14 @@ export default class Generate extends APICommand { } private resolveGenerator(language: SupportedLanguage, configFile: ConfigFile): BaseGenerator { - return new ZodGenerator(language, configFile, this.verboseLog.bind(this)) + switch (language) { + case SupportedLanguage.TypeScript: + 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 { diff --git a/test/codegen/code-generators/node-typescript-generator.test.ts b/test/codegen/code-generators/node-typescript-generator.test.ts new file mode 100644 index 0000000..56efa33 --- /dev/null +++ b/test/codegen/code-generators/node-typescript-generator.test.ts @@ -0,0 +1,464 @@ +import {expect} from 'chai' +import {stripIndent} from 'common-tags' + +import {NodeTypeScriptGenerator} from '../../../src/codegen/code-generators/node-typescript-generator.js' +import {type ConfigFile} from '../../../src/codegen/types.js' + +const mockLog: (category: string | unknown, message?: unknown) => void = () => {} +const defaultMockConfigFile: ConfigFile = { + configs: [], +} + +describe('NodeTypeScriptGenerator', () => { + describe('filename', () => { + it('should return the correct filename', () => { + const generator = new NodeTypeScriptGenerator({ + configFile: defaultMockConfigFile, + log: mockLog, + }) + + expect(generator.filename).to.equal('prefab-server.ts') + }) + }) + + describe('generate', () => { + it('should generate basic code structure when no configs exist', () => { + const generator = new NodeTypeScriptGenerator({ + configFile: defaultMockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + expect(result).to.equal(stripIndent` + // AUTOGENERATED by prefab-cli's 'gen' command + import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' + // No additional dependencies required + + type ContextObj = Record> + + declare namespace PrefabTypeGeneration { + export type NodeServerConfigurationRaw = { + // No types generated + } + + export type NodeServerConfigurationAccessor = { + // No types generated + } + } + + export class PrefabTypesafeNode { + constructor(private prefab: Prefab) { } + + get(key: K, contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationRaw[K] { + return this.prefab.get(key, contexts) as PrefabTypeGeneration.NodeServerConfigurationRaw[K] + } + + // No methods generated + }`) + }) + + it('should include Mustache import when function configs exist', () => { + // Use mustache syntax for the template to ensure a function is generated + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: 'some-key', + rows: [{values: [{value: {string: 'Hello, {{name}}!'}}]}], + valueType: 'STRING', + }, + ], + } + const generator = new NodeTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + // Verify Mustache import is included + expect(result).to.include("import Mustache from 'mustache'") + }) + + it('should generate type definitions for configs and feature flags, not schema, or empty configurations', () => { + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: 'config1', + rows: [{values: [{value: {string: 'Some value'}}]}], + valueType: 'STRING', + }, + { + configType: 'CONFIG', + key: 'emptyConfig', + rows: [], + valueType: 'STRING', + }, + { + configType: 'FEATURE_FLAG', + key: 'flag1', + rows: [{values: [{value: {bool: true}}]}], + valueType: 'BOOL', + }, + { + configType: 'FEATURE_FLAG', + key: 'emptyFlag', + rows: [], + valueType: 'BOOL', + }, + { + configType: 'SCHEMA', + key: 'schema1', + rows: [{values: [{value: {bool: true}}]}], + valueType: 'BOOL', + }, + ], + } + + const generator = new NodeTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + expect(result).to.equal(stripIndent` + // AUTOGENERATED by prefab-cli's 'gen' command + import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' + // No additional dependencies required + + type ContextObj = Record> + + declare namespace PrefabTypeGeneration { + export type NodeServerConfigurationRaw = { + "config1": string + "flag1": boolean + } + + export type NodeServerConfigurationAccessor = { + "config1": string + "flag1": boolean + } + } + + export class PrefabTypesafeNode { + constructor(private prefab: Prefab) { } + + get(key: K, contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationRaw[K] { + return this.prefab.get(key, contexts) as PrefabTypeGeneration.NodeServerConfigurationRaw[K] + } + + config1(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['config1'] { + return this.get('config1', contexts) + } + + flag1(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['flag1'] { + return this.get('flag1', contexts) + } + } + `) + }) + + it('should generate function accessor methods for function configs', () => { + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: 'config1', + rows: [{values: [{value: {string: 'Hello, {{name}}!'}}]}], + valueType: 'STRING', + }, + ], + } + + const generator = new NodeTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + expect(result).to.equal(stripIndent` + // AUTOGENERATED by prefab-cli's 'gen' command + import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' + import Mustache from 'mustache' + + type ContextObj = Record> + + declare namespace PrefabTypeGeneration { + export type NodeServerConfigurationRaw = { + "config1": string | undefined + } + + export type NodeServerConfigurationAccessor = { + "config1": (...params: [{ "name": string }]) => string + } + } + + export class PrefabTypesafeNode { + constructor(private prefab: Prefab) { } + + get(key: K, contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationRaw[K] { + return this.prefab.get(key, contexts) as PrefabTypeGeneration.NodeServerConfigurationRaw[K] + } + + config1(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['config1'] { + const raw = this.get('config1', contexts) + return (params) => Mustache.render(raw ?? "", params) + } + } + `) + }) + + it('should generate durations correctly', () => { + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: 'config1', + rows: [{values: [{value: {int: 1}}]}], + valueType: 'DURATION', + }, + ], + } + + const generator = new NodeTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + expect(result).to.equal(stripIndent` + // AUTOGENERATED by prefab-cli's 'gen' command + import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' + // No additional dependencies required + + type ContextObj = Record> + + declare namespace PrefabTypeGeneration { + export type NodeServerConfigurationRaw = { + "config1": number + } + + export type NodeServerConfigurationAccessor = { + "config1": number + } + } + + export class PrefabTypesafeNode { + constructor(private prefab: Prefab) { } + + get(key: K, contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationRaw[K] { + return this.prefab.get(key, contexts) as PrefabTypeGeneration.NodeServerConfigurationRaw[K] + } + + config1(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['config1'] { + return this.get('config1', contexts) + } + } + `) + }) + + it('should prefix method names with underscore when they start with a digit', () => { + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: '1config', + rows: [{values: [{value: {string: 'Some value'}}]}], + valueType: 'STRING', + }, + { + configType: 'FEATURE_FLAG', + key: '1flag', + rows: [{values: [{value: {bool: true}}]}], + valueType: 'BOOL', + }, + ], + } + + const generator = new NodeTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + expect(result).to.equal(stripIndent` + // AUTOGENERATED by prefab-cli's 'gen' command + import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' + // No additional dependencies required + + type ContextObj = Record> + + declare namespace PrefabTypeGeneration { + export type NodeServerConfigurationRaw = { + "1config": string + "1flag": boolean + } + + export type NodeServerConfigurationAccessor = { + "1config": string + "1flag": boolean + } + } + + export class PrefabTypesafeNode { + constructor(private prefab: Prefab) { } + + get(key: K, contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationRaw[K] { + return this.prefab.get(key, contexts) as PrefabTypeGeneration.NodeServerConfigurationRaw[K] + } + + _1Config(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['1config'] { + return this.get('1config', contexts) + } + + _1Flag(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['1flag'] { + return this.get('1flag', contexts) + } + } + `) + }) + + it('should properly camelCase various key formats', () => { + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: 'snake_case_key', + rows: [{values: [{value: {string: 'Some value'}}]}], + valueType: 'STRING', + }, + { + configType: 'FEATURE_FLAG', + key: 'kebab-case-key', + rows: [{values: [{value: {bool: true}}]}], + valueType: 'BOOL', + }, + { + configType: 'CONFIG', + key: 'dot.notation.key', + rows: [{values: [{value: {string: 'Some value'}}]}], + valueType: 'STRING', + }, + { + configType: 'FEATURE_FLAG', + key: 'UPPER_CASE_KEY', + rows: [{values: [{value: {bool: true}}]}], + valueType: 'BOOL', + }, + { + configType: 'CONFIG', + key: 'key_with_$_special', + rows: [{values: [{value: {string: 'Some value'}}]}], + valueType: 'STRING', + }, + { + configType: 'FEATURE_FLAG', + key: 'key/with/slashes', + rows: [{values: [{value: {bool: true}}]}], + valueType: 'BOOL', + }, + ], + } + + const generator = new NodeTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + expect(result).to.equal(stripIndent` + // AUTOGENERATED by prefab-cli's 'gen' command + import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node' + // No additional dependencies required + + type ContextObj = Record> + + declare namespace PrefabTypeGeneration { + export type NodeServerConfigurationRaw = { + "dot.notation.key": string + "kebab-case-key": boolean + "key_with_$_special": string + "key/with/slashes": boolean + "snake_case_key": string + "UPPER_CASE_KEY": boolean + } + + export type NodeServerConfigurationAccessor = { + "dot.notation.key": string + "kebab-case-key": boolean + "key_with_$_special": string + "key/with/slashes": boolean + "snake_case_key": string + "UPPER_CASE_KEY": boolean + } + } + + export class PrefabTypesafeNode { + constructor(private prefab: Prefab) { } + + get(key: K, contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationRaw[K] { + return this.prefab.get(key, contexts) as PrefabTypeGeneration.NodeServerConfigurationRaw[K] + } + + dotNotationKey(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['dot.notation.key'] { + return this.get('dot.notation.key', contexts) + } + + kebabCaseKey(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['kebab-case-key'] { + return this.get('kebab-case-key', contexts) + } + + keyWithSpecial(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['key_with_$_special'] { + return this.get('key_with_$_special', contexts) + } + + keyWithSlashes(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['key/with/slashes'] { + return this.get('key/with/slashes', contexts) + } + + snakeCaseKey(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['snake_case_key'] { + return this.get('snake_case_key', contexts) + } + + upperCaseKey(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['UPPER_CASE_KEY'] { + return this.get('UPPER_CASE_KEY', contexts) + } + }`) + }) + + it('should throw an error when method names conflict', () => { + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: 'keyWithSlashes', + rows: [{values: [{value: {string: 'Some value'}}]}], + valueType: 'STRING', + }, + { + configType: 'CONFIG', + key: 'key/with/slashes', + rows: [{values: [{value: {string: 'Some value'}}]}], + valueType: 'STRING', + }, + ], + } + + const generator = new NodeTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + expect(() => generator.generate()).to.throw( + "Method 'keyWithSlashes' is already registered. Prefab key keyWithSlashes conflicts with 'key/with/slashes'!", + ) + }) + }) +}) diff --git a/test/codegen/code-generators/react-typescript-generator.test.ts b/test/codegen/code-generators/react-typescript-generator.test.ts new file mode 100644 index 0000000..68d05a0 --- /dev/null +++ b/test/codegen/code-generators/react-typescript-generator.test.ts @@ -0,0 +1,497 @@ +import {expect} from 'chai' +import {stripIndent} from 'common-tags' + +import {ReactTypeScriptGenerator} from '../../../src/codegen/code-generators/react-typescript-generator.js' +import {type ConfigFile} from '../../../src/codegen/types.js' + +const mockLog: (category: string | unknown, message?: unknown) => void = () => {} +const defaultMockConfigFile: ConfigFile = { + configs: [], +} + +describe('ReactTypeScriptGenerator', () => { + describe('filename', () => { + it('should return the correct filename', () => { + const generator = new ReactTypeScriptGenerator({ + configFile: defaultMockConfigFile, + log: mockLog, + }) + + expect(generator.filename).to.equal('prefab-client.ts') + }) + }) + + describe('generate', () => { + it('should generate basic code structure when no configs exist', () => { + const generator = new ReactTypeScriptGenerator({ + configFile: defaultMockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + expect(result).to.equal(stripIndent` + // AUTOGENERATED by prefab-cli's 'gen' command + import { Prefab } from "@prefab-cloud/prefab-cloud-js" + import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react" + // No additional dependencies required + + declare namespace PrefabTypeGeneration { + export type ReactHookConfigurationRaw = { + // No types generated + } + + export type ReactHookConfigurationAccessor = { + // No types generated + } + } + + export class PrefabTypesafeReact { + constructor(private prefab: Prefab) { } + + get(key: K): PrefabTypeGeneration.ReactHookConfigurationRaw[K] { + return this.prefab.get(key) as PrefabTypeGeneration.ReactHookConfigurationRaw[K] + } + + // No methods generated + } + + export const usePrefab = createPrefabHook(PrefabTypesafeReact) + `) + }) + + it('should include Mustache import when function configs exist', () => { + // Use mustache syntax for the template to ensure a function is generated + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: 'some-key', + rows: [{values: [{value: {string: 'Hello, {{name}}!'}}]}], + sendToClientSdk: true, + valueType: 'STRING', + }, + ], + } + const generator = new ReactTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + // Verify Mustache import is included + expect(result).to.include("import Mustache from 'mustache'") + }) + + it('should generate type definitions for configs and feature flags, not schema, empty configurations, or non-client configs', () => { + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: 'config1', + rows: [{values: [{value: {string: 'Some value'}}]}], + sendToClientSdk: true, + valueType: 'STRING', + }, + { + configType: 'CONFIG', + key: 'emptyConfig', + rows: [], + sendToClientSdk: true, + valueType: 'STRING', + }, + { + configType: 'CONFIG', + key: 'nonClientConfig', + rows: [], + sendToClientSdk: false, + valueType: 'STRING', + }, + { + configType: 'FEATURE_FLAG', + key: 'flag1', + rows: [{values: [{value: {bool: true}}]}], + sendToClientSdk: true, + valueType: 'BOOL', + }, + { + configType: 'FEATURE_FLAG', + key: 'emptyFlag', + rows: [], + sendToClientSdk: true, + valueType: 'BOOL', + }, + { + configType: 'SCHEMA', + key: 'schema1', + rows: [{values: [{value: {bool: true}}]}], + sendToClientSdk: true, + valueType: 'BOOL', + }, + ], + } + + const generator = new ReactTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + expect(result).to.equal(stripIndent` + // AUTOGENERATED by prefab-cli's 'gen' command + import { Prefab } from "@prefab-cloud/prefab-cloud-js" + import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react" + // No additional dependencies required + + declare namespace PrefabTypeGeneration { + export type ReactHookConfigurationRaw = { + "config1": string + "flag1": boolean + } + + export type ReactHookConfigurationAccessor = { + "config1": string + "flag1": boolean + } + } + + export class PrefabTypesafeReact { + constructor(private prefab: Prefab) { } + + get(key: K): PrefabTypeGeneration.ReactHookConfigurationRaw[K] { + return this.prefab.get(key) as PrefabTypeGeneration.ReactHookConfigurationRaw[K] + } + + get config1(): PrefabTypeGeneration.ReactHookConfigurationAccessor['config1'] { + return this.get('config1') + } + + get flag1(): boolean { + return this.prefab.isEnabled('flag1') + } + } + + export const usePrefab = createPrefabHook(PrefabTypesafeReact) + `) + }) + + it('should generate function accessor methods for function configs', () => { + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: 'config1', + rows: [{values: [{value: {string: 'Hello, {{name}}!'}}]}], + sendToClientSdk: true, + valueType: 'STRING', + }, + ], + } + + const generator = new ReactTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + expect(result).to.equal(stripIndent` + // AUTOGENERATED by prefab-cli's 'gen' command + import { Prefab } from "@prefab-cloud/prefab-cloud-js" + import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react" + import Mustache from 'mustache' + + declare namespace PrefabTypeGeneration { + export type ReactHookConfigurationRaw = { + "config1": string | undefined + } + + export type ReactHookConfigurationAccessor = { + "config1": (...params: [{ "name": string }]) => string + } + } + + export class PrefabTypesafeReact { + constructor(private prefab: Prefab) { } + + get(key: K): PrefabTypeGeneration.ReactHookConfigurationRaw[K] { + return this.prefab.get(key) as PrefabTypeGeneration.ReactHookConfigurationRaw[K] + } + + config1(): PrefabTypeGeneration.ReactHookConfigurationAccessor['config1'] { + const raw = this.get('config1') + return (params) => Mustache.render(raw ?? "", params) + } + } + + export const usePrefab = createPrefabHook(PrefabTypesafeReact) + `) + }) + + it('should generate durations correctly', () => { + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: 'config1', + rows: [{values: [{value: {int: 1}}]}], + sendToClientSdk: true, + valueType: 'DURATION', + }, + ], + } + + const generator = new ReactTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + expect(result).to.equal(stripIndent` + // AUTOGENERATED by prefab-cli's 'gen' command + import { Prefab } from "@prefab-cloud/prefab-cloud-js" + import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react" + // No additional dependencies required + + declare namespace PrefabTypeGeneration { + export type ReactHookConfigurationRaw = { + "config1": { "ms": number; "seconds": number } + } + + export type ReactHookConfigurationAccessor = { + "config1": { "ms": number; "seconds": number } + } + } + + export class PrefabTypesafeReact { + constructor(private prefab: Prefab) { } + + get(key: K): PrefabTypeGeneration.ReactHookConfigurationRaw[K] { + return this.prefab.get(key) as PrefabTypeGeneration.ReactHookConfigurationRaw[K] + } + + get config1(): PrefabTypeGeneration.ReactHookConfigurationAccessor['config1'] { + return this.get('config1') + } + } + + export const usePrefab = createPrefabHook(PrefabTypesafeReact) + `) + }) + + it('should prefix method names with underscore when they start with a digit', () => { + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: '1config', + rows: [{values: [{value: {string: 'Some value'}}]}], + sendToClientSdk: true, + valueType: 'STRING', + }, + { + configType: 'FEATURE_FLAG', + key: '1flag', + rows: [{values: [{value: {bool: true}}]}], + sendToClientSdk: true, + valueType: 'BOOL', + }, + ], + } + + const generator = new ReactTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + expect(result).to.equal(stripIndent` + // AUTOGENERATED by prefab-cli's 'gen' command + import { Prefab } from "@prefab-cloud/prefab-cloud-js" + import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react" + // No additional dependencies required + + declare namespace PrefabTypeGeneration { + export type ReactHookConfigurationRaw = { + "1config": string + "1flag": boolean + } + + export type ReactHookConfigurationAccessor = { + "1config": string + "1flag": boolean + } + } + + export class PrefabTypesafeReact { + constructor(private prefab: Prefab) { } + + get(key: K): PrefabTypeGeneration.ReactHookConfigurationRaw[K] { + return this.prefab.get(key) as PrefabTypeGeneration.ReactHookConfigurationRaw[K] + } + + get _1Config(): PrefabTypeGeneration.ReactHookConfigurationAccessor['1config'] { + return this.get('1config') + } + + get _1Flag(): boolean { + return this.prefab.isEnabled('1flag') + } + } + + export const usePrefab = createPrefabHook(PrefabTypesafeReact) + `) + }) + + it('should properly camelCase various key formats', () => { + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: 'snake_case_key', + rows: [{values: [{value: {string: 'Some value'}}]}], + sendToClientSdk: true, + valueType: 'STRING', + }, + { + configType: 'FEATURE_FLAG', + key: 'kebab-case-key', + rows: [{values: [{value: {bool: true}}]}], + sendToClientSdk: true, + valueType: 'BOOL', + }, + { + configType: 'CONFIG', + key: 'dot.notation.key', + rows: [{values: [{value: {string: 'Some value'}}]}], + sendToClientSdk: true, + valueType: 'STRING', + }, + { + configType: 'FEATURE_FLAG', + key: 'UPPER_CASE_KEY', + rows: [{values: [{value: {bool: true}}]}], + sendToClientSdk: true, + valueType: 'BOOL', + }, + { + configType: 'CONFIG', + key: 'key_with_$_special', + rows: [{values: [{value: {string: 'Some value'}}]}], + sendToClientSdk: true, + valueType: 'STRING', + }, + { + configType: 'FEATURE_FLAG', + key: 'key/with/slashes', + rows: [{values: [{value: {bool: true}}]}], + sendToClientSdk: true, + valueType: 'BOOL', + }, + ], + } + + const generator = new ReactTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + const result = generator.generate() + + expect(result).to.equal(stripIndent` + // AUTOGENERATED by prefab-cli's 'gen' command + import { Prefab } from "@prefab-cloud/prefab-cloud-js" + import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react" + // No additional dependencies required + + declare namespace PrefabTypeGeneration { + export type ReactHookConfigurationRaw = { + "dot.notation.key": string + "kebab-case-key": boolean + "key_with_$_special": string + "key/with/slashes": boolean + "snake_case_key": string + "UPPER_CASE_KEY": boolean + } + + export type ReactHookConfigurationAccessor = { + "dot.notation.key": string + "kebab-case-key": boolean + "key_with_$_special": string + "key/with/slashes": boolean + "snake_case_key": string + "UPPER_CASE_KEY": boolean + } + } + + export class PrefabTypesafeReact { + constructor(private prefab: Prefab) { } + + get(key: K): PrefabTypeGeneration.ReactHookConfigurationRaw[K] { + return this.prefab.get(key) as PrefabTypeGeneration.ReactHookConfigurationRaw[K] + } + + get dotNotationKey(): PrefabTypeGeneration.ReactHookConfigurationAccessor['dot.notation.key'] { + return this.get('dot.notation.key') + } + + get kebabCaseKey(): boolean { + return this.prefab.isEnabled('kebab-case-key') + } + + get keyWithSpecial(): PrefabTypeGeneration.ReactHookConfigurationAccessor['key_with_$_special'] { + return this.get('key_with_$_special') + } + + get keyWithSlashes(): boolean { + return this.prefab.isEnabled('key/with/slashes') + } + + get snakeCaseKey(): PrefabTypeGeneration.ReactHookConfigurationAccessor['snake_case_key'] { + return this.get('snake_case_key') + } + + get upperCaseKey(): boolean { + return this.prefab.isEnabled('UPPER_CASE_KEY') + } + } + + export const usePrefab = createPrefabHook(PrefabTypesafeReact) + `) + }) + + it('should throw an error when method names conflict', () => { + const mockConfigFile: ConfigFile = { + configs: [ + { + configType: 'CONFIG', + key: 'keyWithSlashes', + rows: [{values: [{value: {string: 'Some value'}}]}], + sendToClientSdk: true, + valueType: 'STRING', + }, + { + configType: 'CONFIG', + key: 'key/with/slashes', + rows: [{values: [{value: {string: 'Some value'}}]}], + sendToClientSdk: true, + valueType: 'STRING', + }, + ], + } + + const generator = new ReactTypeScriptGenerator({ + configFile: mockConfigFile, + log: mockLog, + }) + + expect(() => generator.generate()).to.throw( + "Method 'keyWithSlashes' is already registered. Prefab key keyWithSlashes conflicts with 'key/with/slashes'!", + ) + }) + }) +}) diff --git a/test/codegen/language-mappers/json-to-zod-mapper.test.ts b/test/codegen/language-mappers/json-to-zod-mapper.test.ts new file mode 100644 index 0000000..d63bd87 --- /dev/null +++ b/test/codegen/language-mappers/json-to-zod-mapper.test.ts @@ -0,0 +1,90 @@ +import {expect} from 'chai' + +import {JsonToZodMapper} from '../../../src/codegen/language-mappers/json-to-zod-mapper.js' + +describe('JsonToZodMapper', () => { + const mapper = new JsonToZodMapper() + + describe('resolve', () => { + it('should resolve homogeneous array of numbers', () => { + const input = [1, 2, 3] + + const result = mapper.resolve(input) + + expect(result._def.typeName).equal('ZodArray') + expect(result._def.type._def.typeName).equal('ZodNumber') + }) + + it('should resolve homogeneous array of strings', () => { + const input = ['a', 'b', 'c'] + + const result = mapper.resolve(input) + + expect(result._def.typeName).equal('ZodArray') + expect(result._def.type._def.typeName).equal('ZodString') + }) + + it('should resolve homogeneous array of booleans', () => { + const input = [true, false, true] + + const result = mapper.resolve(input) + + expect(result._def.typeName).equal('ZodArray') + expect(result._def.type._def.typeName).equal('ZodBoolean') + }) + + it('should resolve heterogeneous array to unknown', () => { + const input = [1, 'a', true] + + const result = mapper.resolve(input) + + expect(result._def.typeName).equal('ZodArray') + expect(result._def.type._def.typeName).equal('ZodUnknown') + }) + + it('should resolve object with primitive types', () => { + const input = {age: 30, isActive: true, name: 'Alice'} + + const result = mapper.resolve(input) + + expect(result._def.typeName).equal('ZodObject') + expect(Object.keys(result._def.shape()).sort()).to.deep.equal(['age', 'isActive', 'name']) + expect(result._def.shape().age._def.typeName).equal('ZodNumber') + expect(result._def.shape().isActive._def.typeName).equal('ZodBoolean') + expect(result._def.shape().name._def.typeName).equal('ZodString') + }) + + it('should resolve deeply nested objects', () => { + const input = {isActive: true, user: {age: 30, name: 'Alice'}} + + const result = mapper.resolve(input) + + expect(result._def.typeName).equal('ZodObject') + expect(Object.keys(result._def.shape()).sort()).to.deep.equal(['isActive', 'user']) + expect(result._def.shape().isActive._def.typeName).equal('ZodBoolean') + expect(result._def.shape().user._def.typeName).equal('ZodObject') + expect(Object.keys(result._def.shape().user._def.shape()).sort()).to.deep.equal(['age', 'name']) + expect(result._def.shape().user._def.shape().age._def.typeName).equal('ZodNumber') + expect(result._def.shape().user._def.shape().name._def.typeName).equal('ZodString') + }) + + it('should resolve array of objects', () => { + const input = [{name: 'Alice'}, {name: 'Bob'}] + + const result = mapper.resolve(input) + + expect(result._def.typeName).equal('ZodArray') + expect(result._def.type._def.typeName).equal('ZodObject') + expect(Object.keys(result._def.type._def.shape())).to.deep.equal(['name']) + expect(result._def.type._def.shape().name._def.typeName).equal('ZodString') + }) + + it('should resolve null values', () => { + const input = null + + const result = mapper.resolve(input) + + expect(result._def.typeName).to.deep.equal('ZodNull') + }) + }) +}) diff --git a/test/codegen/language-mappers/zod-to-string-mapper.test.ts b/test/codegen/language-mappers/zod-to-string-mapper.test.ts new file mode 100644 index 0000000..f85a12e --- /dev/null +++ b/test/codegen/language-mappers/zod-to-string-mapper.test.ts @@ -0,0 +1,213 @@ +import {expect} from 'chai' + +import {ZodToStringMapper} from '../../../src/codegen/language-mappers/zod-to-string-mapper.js' +import {secureEvaluateSchema} from '../../../src/codegen/schema-evaluator.js' + +describe('ZodToStringMapper', () => { + describe('renderField', () => { + it('Can successfully parse strings', () => { + const zodAst = secureEvaluateSchema(`z.string()`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.string()') + }) + + it('Can successfully parse numbers', () => { + const zodAst = secureEvaluateSchema(`z.number()`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.number()') + }) + + it('Can successfully parse integer numbers', () => { + const zodAst = secureEvaluateSchema(`z.number().int()`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.number().int()') + }) + + it('Can successfully parse booleans', () => { + const zodAst = secureEvaluateSchema(`z.boolean()`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.boolean()') + }) + + it('Can successfully parse any', () => { + const zodAst = secureEvaluateSchema(`z.any()`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.any()') + }) + + it('Can successfully parse an array wrapped type', () => { + const zodAst = secureEvaluateSchema(`z.array(z.string())`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.array(z.string())') + }) + + it('Can successfully parse an array chained type', () => { + const zodAst = secureEvaluateSchema(`z.string().array()`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.array(z.string())') + }) + + it('Can successfully parse an enum', () => { + const zodAst = secureEvaluateSchema(`z.enum(["first", "second"])`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("z.enum(['first','second'])") + }) + + it('Can successfully parse null', () => { + const zodAst = secureEvaluateSchema(`z.null()`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.null()') + }) + + it('Can successfully parse undefined', () => { + const zodAst = secureEvaluateSchema(`z.undefined()`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.undefined()') + }) + + it('Can successfully parse unknown', () => { + const zodAst = secureEvaluateSchema(`z.unknown()`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.unknown()') + }) + + it('Can successfully parse unions', () => { + const zodAst = secureEvaluateSchema(`z.union([z.string(), z.number()])`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.union([z.string(), z.number()])') + }) + + it('Can successfully parse unions defined with or chaining', () => { + const zodAst = secureEvaluateSchema(`z.string().or(z.number())`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.union([z.string(), z.number()])') + }) + + it('Can successfully parse tuples', () => { + const zodAst = secureEvaluateSchema(`z.tuple([z.string(), z.number()])`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.tuple([z.string(), z.number()])') + }) + + it('Can successfully parse objects', () => { + const zodAst = secureEvaluateSchema(`z.object({ name: z.string(), age: z.number() })`) + const mapper = new ZodToStringMapper() + const rendered = mapper.renderField(zodAst.schema!) + expect(rendered).to.equal('z.object({name: z.string(); age: z.number()})') + }) + + it('Can successfully parse an optional wrapped type', () => { + const zodAst = secureEvaluateSchema(`z.optional(z.string())`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.string().optional()') + }) + + it('Can successfully parse an optional chained type', () => { + const zodAst = secureEvaluateSchema(`z.string().optional()`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.string().optional()') + }) + + it('Can successfully parse functions', () => { + const zodAst = secureEvaluateSchema(`z.function().args(z.string(), z.number()).returns(z.boolean())`) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('z.function().args(z.string(), z.number()).returns(z.boolean())') + }) + + it('Can successfully complex combinations of types', () => { + const zodString = ` + z.object({ + name: z.string(), + age: z.number().int(), + topLevel: z.function().args(z.boolean().optional(), z.any()).returns(z.string()), + more: z.object({ + details: z.string(), + count: z.number().int(), + exec: z.function().args(z.string()).returns(z.boolean().optional()), + }), + tags: z.array(z.string()).optional(), + isActive: z.boolean().default(true), + }) + ` + + const zodAst = secureEvaluateSchema(zodString) + + const mapper = new ZodToStringMapper() + + const rendered = mapper.renderField(zodAst.schema!) + + // NOTE: isActive is set to `any` because of the default value, which is not currently supported in the mapper. + expect(rendered).to.equal( + 'z.object({name: z.string(); age: z.number().int(); topLevel: z.function().args(z.boolean().optional(), z.any()).returns(z.string()); more: z.object({details: z.string(); count: z.number().int(); exec: z.function().args(z.string()).returns(z.boolean().optional())}); tags: z.array(z.string()).optional(); isActive: z.any()})', + ) + }) + }) +}) diff --git a/test/codegen/language-mappers/zod-to-typescript-mapper.test.ts b/test/codegen/language-mappers/zod-to-typescript-mapper.test.ts new file mode 100644 index 0000000..a52b509 --- /dev/null +++ b/test/codegen/language-mappers/zod-to-typescript-mapper.test.ts @@ -0,0 +1,255 @@ +import {expect} from 'chai' + +import {ZodToTypescriptMapper} from '../../../src/codegen/language-mappers/zod-to-typescript-mapper.js' +import {secureEvaluateSchema} from '../../../src/codegen/schema-evaluator.js' + +describe('ZodToTypescriptMapper', () => { + describe('renderField', () => { + it('Can successfully parse strings', () => { + const zodAst = secureEvaluateSchema(`z.string()`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": string') + }) + + it('Can successfully parse numbers', () => { + const zodAst = secureEvaluateSchema(`z.number()`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": number') + }) + + it('Can successfully parse integer numbers', () => { + const zodAst = secureEvaluateSchema(`z.number().int()`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": number') + }) + + it('Can successfully parse booleans', () => { + const zodAst = secureEvaluateSchema(`z.boolean()`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": boolean') + }) + + it('Can successfully parse any', () => { + const zodAst = secureEvaluateSchema(`z.any()`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": any') + }) + + it('Can successfully parse an array wrapped type', () => { + const zodAst = secureEvaluateSchema(`z.array(z.string())`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": Array') + }) + + it('Can successfully parse an array chained type', () => { + const zodAst = secureEvaluateSchema(`z.string().array()`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": Array') + }) + + it('Can successfully parse an enum', () => { + const zodAst = secureEvaluateSchema(`z.enum(["first", "second"])`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": 'first' | 'second'") + }) + + it('Can successfully parse null', () => { + const zodAst = secureEvaluateSchema(`z.null()`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": null') + }) + + it('Can successfully parse undefined', () => { + const zodAst = secureEvaluateSchema(`z.undefined()`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": undefined') + }) + + it('Can successfully parse unknown', () => { + const zodAst = secureEvaluateSchema(`z.unknown()`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": unknown') + }) + + it('Can successfully parse unions', () => { + const zodAst = secureEvaluateSchema(`z.union([z.string(), z.number()])`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": string | number') + }) + + it('Can successfully parse unions defined with or chaining', () => { + const zodAst = secureEvaluateSchema(`z.string().or(z.number())`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": string | number') + }) + + it('Can successfully parse tuples', () => { + const zodAst = secureEvaluateSchema(`z.tuple([z.string(), z.number()])`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": [string, number]') + }) + + it('Can successfully parse objects', () => { + const zodAst = secureEvaluateSchema(`z.object({ name: z.string(), age: z.number() })`) + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + const rendered = mapper.renderField(zodAst.schema!) + expect(rendered).to.equal('"someKey": { "name": string; "age": number }') + }) + + it('Can successfully parse an optional wrapped type', () => { + const zodAst = secureEvaluateSchema(`z.optional(z.string())`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey"?: string') + }) + + it('Can successfully parse an optional chained type', () => { + const zodAst = secureEvaluateSchema(`z.string().optional()`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey"?: string') + }) + + describe('when the target = "accessor"', () => { + it('Can successfully parse functions', () => { + const zodAst = secureEvaluateSchema(`z.function().args(z.string(), z.number()).returns(z.boolean())`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": (...params: [string, number]) => boolean') + }) + + it('Can successfully complex combinations of types', () => { + const zodString = ` + z.object({ + name: z.string(), + age: z.number().int(), + topLevel: z.function().args(z.boolean().optional(), z.any()).returns(z.string()), + more: z.object({ + details: z.string(), + count: z.number().int(), + exec: z.function().args(z.string()).returns(z.boolean().optional()), + }), + tags: z.array(z.string()).optional(), + isActive: z.boolean().default(true), + }) + ` + + const zodAst = secureEvaluateSchema(zodString) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + // NOTE: isActive is set to `any` because of the default value, which is not currently supported in the mapper. + expect(rendered).to.equal( + '"someKey": { "name": string; "age": number; "topLevel": (...params: [boolean | undefined, any]) => string; "more": { "details": string; "count": number; "exec": (...params: [string]) => boolean | undefined }; "tags"?: Array; "isActive": any }', + ) + }) + }) + + describe('when the target = "raw"', () => { + it('Can successfully parse functions', () => { + const zodAst = secureEvaluateSchema(`z.function().args(z.string(), z.number()).returns(z.boolean())`) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey', target: 'raw'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": string | undefined') + }) + + it('Can successfully complex combinations of types', () => { + const zodString = ` + z.object({ + name: z.string(), + age: z.number().int(), + topLevel: z.function().args(z.boolean().optional(), z.any()).returns(z.string()), + more: z.object({ + details: z.string(), + count: z.number().int(), + exec: z.function().args(z.string()).returns(z.boolean().optional()), + }), + tags: z.array(z.string()).optional(), + isActive: z.boolean().default(true), + }) + ` + + const zodAst = secureEvaluateSchema(zodString) + + const mapper = new ZodToTypescriptMapper({fieldName: 'someKey', target: 'raw'}) + + const rendered = mapper.renderField(zodAst.schema!) + + // NOTE: isActive is set to `any` because of the default value, which is not currently supported in the mapper. + expect(rendered).to.equal( + '"someKey": { "name": string; "age": number; "topLevel": string | undefined; "more": { "details": string; "count": number; "exec": string | undefined }; "tags"?: Array; "isActive": any }', + ) + }) + }) + }) +}) diff --git a/test/codegen/language-mappers/zod-to-typescript-return-value-mapper.test.ts b/test/codegen/language-mappers/zod-to-typescript-return-value-mapper.test.ts new file mode 100644 index 0000000..7b09302 --- /dev/null +++ b/test/codegen/language-mappers/zod-to-typescript-return-value-mapper.test.ts @@ -0,0 +1,398 @@ +import {expect} from 'chai' + +import {ZodToTypescriptReturnValueMapper} from '../../../src/codegen/language-mappers/zod-to-typescript-return-value-mapper.js' +import {secureEvaluateSchema} from '../../../src/codegen/schema-evaluator.js' + +describe('ZodToTypescriptReturnValueMapper', () => { + describe('renderField', () => { + const returnTypePropertyPath = ['first', 'second', 'third'] + + it('Can successfully parse strings', () => { + const zodAst = secureEvaluateSchema(`z.string()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse strings with property paths', () => { + const zodAst = secureEvaluateSchema(`z.string()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse numbers', () => { + const zodAst = secureEvaluateSchema(`z.number()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse numbers with property paths', () => { + const zodAst = secureEvaluateSchema(`z.number()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse integer numbers', () => { + const zodAst = secureEvaluateSchema(`z.number().int()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse integer numbers with property paths', () => { + const zodAst = secureEvaluateSchema(`z.number().int()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse booleans', () => { + const zodAst = secureEvaluateSchema(`z.boolean()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse booleans with property paths', () => { + const zodAst = secureEvaluateSchema(`z.boolean()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse any', () => { + const zodAst = secureEvaluateSchema(`z.any()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse any with property paths', () => { + const zodAst = secureEvaluateSchema(`z.any()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse an array wrapped type', () => { + const zodAst = secureEvaluateSchema(`z.array(z.string())`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse an array wrapped type with property paths', () => { + const zodAst = secureEvaluateSchema(`z.array(z.string())`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse an array chained type', () => { + const zodAst = secureEvaluateSchema(`z.string().array()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse an array chained type with property paths', () => { + const zodAst = secureEvaluateSchema(`z.string().array()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse an enum', () => { + const zodAst = secureEvaluateSchema(`z.enum(["first", "second"])`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse an enum with property paths', () => { + const zodAst = secureEvaluateSchema(`z.enum(["first", "second"])`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse null', () => { + const zodAst = secureEvaluateSchema(`z.null()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse null with property paths', () => { + const zodAst = secureEvaluateSchema(`z.null()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse undefined', () => { + const zodAst = secureEvaluateSchema(`z.undefined()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse undefined with property paths', () => { + const zodAst = secureEvaluateSchema(`z.undefined()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse unknown', () => { + const zodAst = secureEvaluateSchema(`z.unknown()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse unknown with property paths', () => { + const zodAst = secureEvaluateSchema(`z.unknown()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse unions', () => { + const zodAst = secureEvaluateSchema(`z.union([z.string(), z.number()])`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse unions defined with or chaining with property paths', () => { + const zodAst = secureEvaluateSchema(`z.string().or(z.number())`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse unions defined with or chaining', () => { + const zodAst = secureEvaluateSchema(`z.string().or(z.number())`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse unions defined with or chaining with property paths', () => { + const zodAst = secureEvaluateSchema(`z.string().or(z.number())`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse tuples', () => { + const zodAst = secureEvaluateSchema(`z.tuple([z.string(), z.number()])`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": [raw?.[0]!, raw?.[1]!]') + }) + + it('Can successfully parse tuples with property paths', () => { + const zodAst = secureEvaluateSchema(`z.tuple([z.string(), z.number()])`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal( + "\"someKey\": [raw?.['first']?.['second']?.['third']?.[0]!, raw?.['first']?.['second']?.['third']?.[1]!]", + ) + }) + + it('Can successfully parse objects', () => { + const zodAst = secureEvaluateSchema(`z.object({ name: z.string(), age: z.number() })`) + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + const rendered = mapper.renderField(zodAst.schema!) + expect(rendered).to.equal('"someKey": { "name": raw?.[\'name\']!, "age": raw?.[\'age\']! }') + }) + + it('Can successfully parse objects with property paths', () => { + const zodAst = secureEvaluateSchema(`z.object({ name: z.string(), age: z.number() })`) + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + const rendered = mapper.renderField(zodAst.schema!) + expect(rendered).to.equal( + "\"someKey\": { \"name\": raw?.['first']?.['second']?.['third']?.['name']!, \"age\": raw?.['first']?.['second']?.['third']?.['age']! }", + ) + }) + + it('Can successfully parse an optional wrapped type', () => { + const zodAst = secureEvaluateSchema(`z.optional(z.string())`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse an optional wrapped type with property paths', () => { + const zodAst = secureEvaluateSchema(`z.optional(z.string())`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse an optional chained type', () => { + const zodAst = secureEvaluateSchema(`z.string().optional()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": raw') + }) + + it('Can successfully parse an optional chained type with property paths', () => { + const zodAst = secureEvaluateSchema(`z.string().optional()`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal("\"someKey\": raw?.['first']?.['second']?.['third']!") + }) + + it('Can successfully parse functions', () => { + const zodAst = secureEvaluateSchema(`z.function().args(z.string(), z.number()).returns(z.boolean())`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal('"someKey": (params) => Mustache.render(raw ?? "", params)') + }) + + it('Can successfully parse functions with property paths', () => { + const zodAst = secureEvaluateSchema(`z.function().args(z.string(), z.number()).returns(z.boolean())`) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey', returnTypePropertyPath}) + + const rendered = mapper.renderField(zodAst.schema!) + + expect(rendered).to.equal( + "\"someKey\": (params) => Mustache.render(raw?.['first']?.['second']?.['third']! ?? \"\", params)", + ) + }) + + it('Can successfully complex combinations of types', () => { + const zodString = ` + z.object({ + name: z.string(), + age: z.number().int(), + topLevel: z.function().args(z.boolean().optional(), z.any()).returns(z.string()), + more: z.object({ + details: z.string(), + count: z.number().int(), + exec: z.function().args(z.string()).returns(z.boolean().optional()), + }), + tags: z.array(z.string()).optional(), + isActive: z.boolean().default(true), + }) + ` + + const zodAst = secureEvaluateSchema(zodString) + + const mapper = new ZodToTypescriptReturnValueMapper({fieldName: 'someKey'}) + + const rendered = mapper.renderField(zodAst.schema!) + + // NOTE: isActive is set to `any` because of the default value, which is not currently supported in the mapper. + expect(rendered).to.equal( + '"someKey": { "name": raw?.[\'name\']!, "age": raw?.[\'age\']!, "topLevel": (params) => Mustache.render(raw?.[\'topLevel\']! ?? "", params), "more": { "details": raw?.[\'more\']?.[\'details\']!, "count": raw?.[\'more\']?.[\'count\']!, "exec": (params) => Mustache.render(raw?.[\'more\']?.[\'exec\']! ?? "", params) }, "tags": raw?.[\'tags\']!, "isActive": raw?.[\'isActive\']! }', + ) + }) + }) +}) diff --git a/test/codegen/schema-extractor.test.ts b/test/codegen/schema-extractor.test.ts new file mode 100644 index 0000000..9b0ea94 --- /dev/null +++ b/test/codegen/schema-extractor.test.ts @@ -0,0 +1,262 @@ +import {expect} from 'chai' +import {z} from 'zod' + +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() + +describe('SchemaExtractor', () => { + let mockLog: (category: string | unknown, message?: unknown) => void + let schemaExtractor: SchemaExtractor + + beforeEach(() => { + mockLog = () => {} + schemaExtractor = new SchemaExtractor(mockLog) + }) + + describe('execute', () => { + it('should use user-defined schema when available', () => { + const schemaConfig: Config = { + configType: 'SCHEMA', + key: 'user_schema', + rows: [ + { + values: [ + { + value: { + schema: { + schema: 'z.object({ name: z.string(), age: z.number().int() })', + schemaType: 'zod', + }, + }, + }, + ], + }, + ], + valueType: 'JSON', + } + + // Configuration is empty, so we know user-defined schema will be used in our assertion + const config: Config = { + configType: 'CONFIG', + key: 'user_config', + rows: [], + schemaKey: 'user_schema', + valueType: 'JSON', + } + + // Create the config file containing both configs + const configFile: ConfigFile = { + configs: [config, schemaConfig], + } + + 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') + }) + + it('should infer schema when no user-defined schema is available', () => { + // Create a config without a schema reference + const config: Config = { + configType: 'CONFIG', + key: 'string_config', + rows: [ + { + values: [ + { + value: { + string: 'test value', + }, + }, + ], + }, + ], + valueType: 'STRING', + } + + const configFile: ConfigFile = { + configs: [config], + } + + const result = schemaExtractor.execute({config, configFile}) + + // Verify the result is a string schema + expect(result._def.typeName).to.equal('ZodString') + }) + + it('should infer a union of schema when multiple schemas are found', () => { + // Create a config without a schema reference + const config: Config = { + configType: 'CONFIG', + key: 'json_config', + rows: [ + { + values: [ + { + value: { + json: { + json: JSON.stringify({ + enterprise: 5000, + premium: 500, + standard: 100, + }), + }, + }, + }, + ], + }, + { + values: [ + { + value: { + json: { + json: JSON.stringify({ + freemium: 0, + premium: 500, + standard: 100, + }), + }, + }, + }, + ], + }, + ], + valueType: 'JSON', + } + + const configFile: ConfigFile = { + configs: [config], + } + + 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']) + }) + + it('should use custom duration type map when provided', () => { + // Create a config with DURATION type + const config: Config = { + configType: 'CONFIG', + key: 'duration_config', + rows: [ + { + values: [ + { + value: { + int: 5000, // 5 seconds in ms + }, + }, + ], + }, + ], + valueType: 'DURATION', + } + + const configFile: ConfigFile = { + configs: [config], + } + + const result = schemaExtractor.execute({config, configFile, durationTypeMap}) + + // Verify the result uses our custom duration type + expect(result._def.typeName).to.equal('ZodString') + }) + + it('should replace strings with Mustache templates when found', () => { + // Create a config with a Mustache template + const config: Config = { + configType: 'CONFIG', + key: 'template_config', + rows: [ + { + values: [ + { + value: { + string: 'Hello, {{name}}!', + }, + }, + ], + }, + ], + valueType: 'STRING', + } + + const configFile: ConfigFile = { + configs: [config], + } + + 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') + }) + + it('should replace strings with a union of Mustache templates when found', () => { + // Create a config with a Mustache template + const config: Config = { + configType: 'CONFIG', + key: 'template_config', + rows: [ + { + values: [ + { + value: { + string: 'Hello, {{name}}!', + }, + }, + { + value: { + string: 'Hello, {{differentName}}!', + }, + }, + ], + }, + ], + valueType: 'STRING', + } + + const configFile: ConfigFile = { + configs: [config], + } + + 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', + ) + expect(result._def.returns._def.typeName).to.equal('ZodString') + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 17ceec9..9415c1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2202,6 +2202,11 @@ dependencies: "@types/node" "*" +"@types/common-tags@^1.8.4": + version "1.8.4" + resolved "https://registry.yarnpkg.com/@types/common-tags/-/common-tags-1.8.4.tgz#3b31fcb5952cd326a55cabe9dbe6c5be3c1671a0" + integrity sha512-S+1hLDJPjWNDhcGxsxEbepzaxWqURP/o+3cP4aa2w7yBXgdcmKGQtZzP8JbyfOd0m+33nh+8+kvxYE2UJtBDkg== + "@types/cookie@^0.6.0": version "0.6.0" resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5" @@ -2232,6 +2237,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/lodash.camelcase@^4.3.9": + version "4.3.9" + resolved "https://registry.yarnpkg.com/@types/lodash.camelcase/-/lodash.camelcase-4.3.9.tgz#da7b65013d6914fecb8759d5220a6ca9b658ee23" + integrity sha512-ys9/hGBfsKxzmFI8hckII40V0ASQ83UM2pxfQRghHAwekhH4/jWtjz/3/9YDy7ZpUd/H0k2STSqmPR28dnj7Zg== + dependencies: + "@types/lodash" "*" + "@types/lodash@*": version "4.17.16" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.16.tgz#94ae78fab4a38d73086e962d0b65c30d816bfb0a" @@ -3206,6 +3218,11 @@ common-ancestor-path@^1.0.1: resolved "https://registry.yarnpkg.com/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz#4f7d2d1394d91b7abdf51871c62f71eadb0182a7" integrity sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w== +common-tags@^1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" + integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -5299,6 +5316,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"