diff --git a/dev/openapi-ts.config.ts b/dev/openapi-ts.config.ts index 49437a31d..83f61bba0 100644 --- a/dev/openapi-ts.config.ts +++ b/dev/openapi-ts.config.ts @@ -41,12 +41,12 @@ export default defineConfig(() => { // 'circular.yaml', // 'dutchie.json', // 'invalid', - // 'full.yaml', + 'full.yaml', // 'openai.yaml', // 'opencode.yaml', // 'sdk-instance.yaml', // 'string-with-format.yaml', - 'transformers.json', + // 'transformers.json', // 'type-format.yaml', // 'validators.yaml', // 'validators-circular-ref.json', @@ -396,6 +396,14 @@ export default defineConfig(() => { }, }, '~resolvers': { + object: { + // base({ $, additional, pipes, shape }) { + // if (additional === undefined) { + // return pipes.push($('v').attr('looseObject').call(shape)); + // } + // return; + // }, + }, string: { formats: { // date: ({ $, pipes }) => pipes.push($('v').attr('isoDateTime').call()), @@ -407,7 +415,7 @@ export default defineConfig(() => { { // case: 'snake_case', // comments: false, - compatibilityVersion: 3, + compatibilityVersion: 4, dates: { // local: true, // offset: true, @@ -456,6 +464,15 @@ export default defineConfig(() => { }, }, '~resolvers': { + object: { + base({ $, additional, shape }) { + if (!additional) { + // return $('z').attr('object').call(shape).attr('passthrough').call() + return $('z').attr('looseObject').call(shape); + } + return; + }, + }, string: { formats: { // date: ({ $ }) => $('z').attr('date').call(), diff --git a/packages/openapi-ts/src/plugins/valibot/types.d.ts b/packages/openapi-ts/src/plugins/valibot/types.d.ts index 6aed5ae4d..69e2e185b 100644 --- a/packages/openapi-ts/src/plugins/valibot/types.d.ts +++ b/packages/openapi-ts/src/plugins/valibot/types.d.ts @@ -1,6 +1,8 @@ +import type ts from 'typescript'; + import type { IR } from '~/ir/types'; import type { DefinePlugin, Plugin } from '~/plugins'; -import type { CallTsDsl, DollarTsDsl } from '~/ts-dsl'; +import type { CallTsDsl, DollarTsDsl, ObjectTsDsl } from '~/ts-dsl'; import type { StringCase, StringName } from '~/types/case'; import type { IApi } from './api'; @@ -317,7 +319,7 @@ export type Config = Plugin.Name<'valibot'> & }; }; -export type FormatResolverArgs = DollarTsDsl & { +type SharedResolverArgs = DollarTsDsl & { /** * The current builder state being processed by this resolver. * @@ -330,10 +332,42 @@ export type FormatResolverArgs = DollarTsDsl & { */ pipes: Array; plugin: ValibotPlugin['Instance']; +}; + +export type FormatResolverArgs = SharedResolverArgs & { + schema: IR.SchemaObject; +}; + +export type ObjectBaseResolverArgs = SharedResolverArgs & { + /** Null = never */ + additional?: ts.Expression | null; schema: IR.SchemaObject; + shape: ObjectTsDsl; }; type Resolvers = Plugin.Resolvers<{ + /** + * Resolvers for object schemas. + * + * Allows customization of how object types are rendered. + * + * Example path: `~resolvers.object.base` + * + * Returning `undefined` from a resolver will apply the default + * generation behavior for the object schema. + */ + object?: { + /** + * Controls how object schemas are constructed. + * + * Called with the fully assembled shape (properties) and any additional + * property schema, allowing the resolver to choose the correct Valibot + * base constructor and modify the schema chain if needed. + * + * Returning `undefined` will execute the default resolver logic. + */ + base?: (args: ObjectBaseResolverArgs) => CallTsDsl | undefined; + }; /** * Resolvers for string schemas. * diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/object.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/object.ts index 42f74911e..d9f917a2f 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/object.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/object.ts @@ -1,15 +1,60 @@ -import ts from 'typescript'; +import type ts from 'typescript'; import type { SchemaWithType } from '~/plugins'; import { toRef } from '~/plugins/shared/utils/refs'; -import { tsc } from '~/tsc'; -import { numberRegExp } from '~/utils/regexp'; +import { $, type CallTsDsl } from '~/ts-dsl'; import { pipesToAst } from '../../shared/pipesToAst'; import type { Ast, IrSchemaToAstOptions } from '../../shared/types'; +import type { ObjectBaseResolverArgs } from '../../types'; import { identifiers } from '../constants'; import { irSchemaToAst } from '../plugin'; +function defaultObjectBaseResolver({ + additional, + pipes, + plugin, + shape, +}: ObjectBaseResolverArgs): number { + const v = plugin.referenceSymbol({ + category: 'external', + resource: 'valibot.v', + }); + + // Handle `additionalProperties: { type: 'never' }` → v.strictObject() + if (additional === null) { + return pipes.push( + $(v.placeholder).attr(identifiers.schemas.strictObject).call(shape), + ); + } + + // Handle additionalProperties as schema → v.record() or v.objectWithRest() + if (additional) { + if (shape.isEmpty) { + return pipes.push( + $(v.placeholder) + .attr(identifiers.schemas.record) + .call( + $(v.placeholder).attr(identifiers.schemas.string).call(), + additional, + ), + ); + } + + // If there are named properties, use v.objectWithRest() to validate both + return pipes.push( + $(v.placeholder) + .attr(identifiers.schemas.objectWithRest) + .call(shape, additional), + ); + } + + // Default case → v.object() + return pipes.push( + $(v.placeholder).attr(identifiers.schemas.object).call(shape), + ); +} + export const objectToAst = ({ plugin, schema, @@ -18,10 +63,11 @@ export const objectToAst = ({ schema: SchemaWithType<'object'>; }): Omit => { const result: Partial> = {}; + const pipes: Array = []; // TODO: parser - handle constants - const properties: Array = []; + const shape = $.object().pretty(); const required = schema.required ?? []; for (const name in schema.properties) { @@ -37,130 +83,40 @@ export const objectToAst = ({ path: toRef([...state.path.value, 'properties', name]), }, }); - if (propertyAst.hasLazyExpression) { - result.hasLazyExpression = true; - } + if (propertyAst.hasLazyExpression) result.hasLazyExpression = true; - numberRegExp.lastIndex = 0; - let propertyName; - if (numberRegExp.test(name)) { - // For numeric literals, we'll handle negative numbers by using a string literal - // instead of trying to use a PrefixUnaryExpression - propertyName = name.startsWith('-') - ? ts.factory.createStringLiteral(name) - : ts.factory.createNumericLiteral(name); - } else { - propertyName = name; - } - // TODO: parser - abstract safe property name logic - if ( - ((name.match(/^[0-9]/) && name.match(/\D+/g)) || name.match(/\W/g)) && - !name.startsWith("'") && - !name.endsWith("'") - ) { - propertyName = `'${name}'`; - } - properties.push( - tsc.propertyAssignment({ - initializer: pipesToAst({ pipes: propertyAst.pipes, plugin }), - name: propertyName, - }), - ); - } - - const v = plugin.referenceSymbol({ - category: 'external', - resource: 'valibot.v', - }); - - // Handle additionalProperties: false (which becomes type: 'never' in IR) - // Use v.strictObject() to forbid additional properties - if ( - schema.additionalProperties && - typeof schema.additionalProperties === 'object' && - schema.additionalProperties.type === 'never' - ) { - result.pipes = [ - tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: v.placeholder, - name: identifiers.schemas.strictObject, - }), - parameters: [ - ts.factory.createObjectLiteralExpression(properties, true), - ], - }), - ]; - return result as Omit; + shape.prop(name, pipesToAst({ pipes: propertyAst.pipes, plugin })); } - // Handle additionalProperties with a schema (not just true/false) - // This supports objects with dynamic keys (e.g., Record) - if ( - schema.additionalProperties && - typeof schema.additionalProperties === 'object' && - schema.additionalProperties.type !== undefined - ) { - const additionalAst = irSchemaToAst({ - plugin, - schema: schema.additionalProperties, - state: { - ...state, - path: toRef([...state.path.value, 'additionalProperties']), - }, - }); - if (additionalAst.hasLazyExpression) { - result.hasLazyExpression = true; - } - - // If there are no named properties, use v.record() directly - if (!Object.keys(properties).length) { - result.pipes = [ - tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: v.placeholder, - name: identifiers.schemas.record, - }), - parameters: [ - tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: v.placeholder, - name: identifiers.schemas.string, - }), - parameters: [], - }), - pipesToAst({ pipes: additionalAst.pipes, plugin }), - ], - }), - ]; - return result as Omit; + let additional: ts.Expression | null | undefined; + if (schema.additionalProperties && schema.additionalProperties.type) { + if (schema.additionalProperties.type === 'never') { + additional = null; + } else { + const additionalAst = irSchemaToAst({ + plugin, + schema: schema.additionalProperties, + state: { + ...state, + path: toRef([...state.path.value, 'additionalProperties']), + }, + }); + if (additionalAst.hasLazyExpression) result.hasLazyExpression = true; + additional = pipesToAst({ pipes: additionalAst.pipes, plugin }); } - - // If there are named properties, use v.objectWithRest() to validate both - // The rest parameter is the schema for each additional property value - result.pipes = [ - tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: v.placeholder, - name: identifiers.schemas.objectWithRest, - }), - parameters: [ - ts.factory.createObjectLiteralExpression(properties, true), - pipesToAst({ pipes: additionalAst.pipes, plugin }), - ], - }), - ]; - return result as Omit; } - result.pipes = [ - tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: v.placeholder, - name: identifiers.schemas.object, - }), - parameters: [ts.factory.createObjectLiteralExpression(properties, true)], - }), - ]; + const args: ObjectBaseResolverArgs = { + $, + additional, + pipes, + plugin, + schema, + shape, + }; + const resolver = plugin.config['~resolvers']?.object?.base; + if (!resolver?.(args)) defaultObjectBaseResolver(args); + + result.pipes = [pipesToAst({ pipes, plugin })]; return result as Omit; }; diff --git a/packages/openapi-ts/src/plugins/zod/mini/toAst/object.ts b/packages/openapi-ts/src/plugins/zod/mini/toAst/object.ts index e526a984f..6f5e9b696 100644 --- a/packages/openapi-ts/src/plugins/zod/mini/toAst/object.ts +++ b/packages/openapi-ts/src/plugins/zod/mini/toAst/object.ts @@ -1,14 +1,34 @@ -import ts from 'typescript'; +import type ts from 'typescript'; import type { SchemaWithType } from '~/plugins'; import { toRef } from '~/plugins/shared/utils/refs'; -import { tsc } from '~/tsc'; -import { numberRegExp } from '~/utils/regexp'; +import type { CallTsDsl } from '~/ts-dsl'; +import { $ } from '~/ts-dsl'; import { identifiers } from '../../constants'; import type { Ast, IrSchemaToAstOptions } from '../../shared/types'; +import type { ObjectBaseResolverArgs } from '../../types'; import { irSchemaToAst } from '../plugin'; +function defaultObjectBaseResolver({ + additional, + plugin, + shape, +}: ObjectBaseResolverArgs): CallTsDsl { + const z = plugin.referenceSymbol({ + category: 'external', + resource: 'zod.z', + }); + + if (additional) { + return $(z.placeholder) + .attr(identifiers.record) + .call($(z.placeholder).attr(identifiers.string).call(), additional); + } + + return $(z.placeholder).attr(identifiers.object).call(shape); +} + export const objectToAst = ({ plugin, schema, @@ -16,17 +36,11 @@ export const objectToAst = ({ }: IrSchemaToAstOptions & { schema: SchemaWithType<'object'>; }): Omit => { - const z = plugin.referenceSymbol({ - category: 'external', - resource: 'zod.z', - }); - const result: Partial> = {}; // TODO: parser - handle constants - const properties: Array = - []; + const shape = $.object().pretty(); const required = schema.required ?? []; for (const name in schema.properties) { @@ -46,47 +60,14 @@ export const objectToAst = ({ result.hasLazyExpression = true; } - numberRegExp.lastIndex = 0; - let propertyName; - if (numberRegExp.test(name)) { - // For numeric literals, we'll handle negative numbers by using a string literal - // instead of trying to use a PrefixUnaryExpression - propertyName = name.startsWith('-') - ? ts.factory.createStringLiteral(name) - : ts.factory.createNumericLiteral(name); - } else { - propertyName = name; - } - // TODO: parser - abstract safe property name logic - if ( - ((name.match(/^[0-9]/) && name.match(/\D+/g)) || name.match(/\W/g)) && - !name.startsWith("'") && - !name.endsWith("'") - ) { - propertyName = `'${name}'`; - } - if (propertyAst.hasLazyExpression) { - properties.push( - tsc.getAccessorDeclaration({ - name: propertyName, - statements: [ - tsc.returnStatement({ - expression: propertyAst.expression, - }), - ], - }), - ); + shape.getter(name, $(propertyAst.expression).return()); } else { - properties.push( - tsc.propertyAssignment({ - initializer: propertyAst.expression, - name: propertyName, - }), - ); + shape.prop(name, propertyAst.expression); } } + let additional: ts.Expression | null | undefined; if ( schema.additionalProperties && (!schema.properties || !Object.keys(schema.properties).length) @@ -99,35 +80,21 @@ export const objectToAst = ({ path: toRef([...state.path.value, 'additionalProperties']), }, }); - result.expression = tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: z.placeholder, - name: identifiers.record, - }), - parameters: [ - tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: z.placeholder, - name: identifiers.string, - }), - parameters: [], - }), - additionalAst.expression, - ], - }); - if (additionalAst.hasLazyExpression) { - result.hasLazyExpression = true; - } - return result as Omit; + if (additionalAst.hasLazyExpression) result.hasLazyExpression = true; + additional = additionalAst.expression; } - result.expression = tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: z.placeholder, - name: identifiers.object, - }), - parameters: [ts.factory.createObjectLiteralExpression(properties, true)], - }); + const args: ObjectBaseResolverArgs = { + $, + additional, + chain: undefined, + plugin, + schema, + shape, + }; + const resolver = plugin.config['~resolvers']?.object?.base; + const chain = resolver?.(args) ?? defaultObjectBaseResolver(args); + result.expression = chain.$render(); return result as Omit; }; diff --git a/packages/openapi-ts/src/plugins/zod/types.d.ts b/packages/openapi-ts/src/plugins/zod/types.d.ts index 13b2191d3..aff27f7c2 100644 --- a/packages/openapi-ts/src/plugins/zod/types.d.ts +++ b/packages/openapi-ts/src/plugins/zod/types.d.ts @@ -1,6 +1,8 @@ +import type ts from 'typescript'; + import type { IR } from '~/ir/types'; import type { DefinePlugin, Plugin } from '~/plugins'; -import type { CallTsDsl, DollarTsDsl } from '~/ts-dsl'; +import type { CallTsDsl, DollarTsDsl, ObjectTsDsl } from '~/ts-dsl'; import type { StringCase, StringName } from '~/types/case'; import type { IApi } from './api'; @@ -743,7 +745,7 @@ export type Config = Plugin.Name<'zod'> & }; }; -export type FormatResolverArgs = DollarTsDsl & { +type SharedResolverArgs = DollarTsDsl & { /** * The current fluent builder chain under construction for this resolver. * @@ -753,12 +755,44 @@ export type FormatResolverArgs = DollarTsDsl & { * This chain can be extended, transformed, or replaced entirely to customize * the resulting output of the resolver. */ - chain: CallTsDsl; + chain?: CallTsDsl; plugin: ZodPlugin['Instance']; +}; + +export type FormatResolverArgs = Required & { + schema: IR.SchemaObject; +}; + +export type ObjectBaseResolverArgs = SharedResolverArgs & { + /** Null = never */ + additional?: ts.Expression | null; schema: IR.SchemaObject; + shape: ObjectTsDsl; }; type Resolvers = Plugin.Resolvers<{ + /** + * Resolvers for object schemas. + * + * Allows customization of how object types are rendered. + * + * Example path: `~resolvers.object.base` + * + * Returning `undefined` from a resolver will apply the default + * generation behavior for the object schema. + */ + object?: { + /** + * Controls how object schemas are constructed. + * + * Called with the fully assembled shape (properties) and any additional + * property schema, allowing the resolver to choose the correct Zod + * base constructor and modify the schema chain if needed. + * + * Returning `undefined` will execute the default resolver logic. + */ + base?: (args: ObjectBaseResolverArgs) => CallTsDsl | undefined; + }; /** * Resolvers for string schemas. * diff --git a/packages/openapi-ts/src/plugins/zod/v3/toAst/object.ts b/packages/openapi-ts/src/plugins/zod/v3/toAst/object.ts index 2bb3dfcb3..3189357a9 100644 --- a/packages/openapi-ts/src/plugins/zod/v3/toAst/object.ts +++ b/packages/openapi-ts/src/plugins/zod/v3/toAst/object.ts @@ -1,14 +1,32 @@ -import ts from 'typescript'; +import type ts from 'typescript'; import type { SchemaWithType } from '~/plugins'; import { toRef } from '~/plugins/shared/utils/refs'; -import { tsc } from '~/tsc'; -import { numberRegExp } from '~/utils/regexp'; +import type { CallTsDsl } from '~/ts-dsl'; +import { $ } from '~/ts-dsl'; import { identifiers } from '../../constants'; import type { Ast, IrSchemaToAstOptions } from '../../shared/types'; +import type { ObjectBaseResolverArgs } from '../../types'; import { irSchemaToAst } from '../plugin'; +function defaultObjectBaseResolver({ + additional, + plugin, + shape, +}: ObjectBaseResolverArgs): CallTsDsl { + const z = plugin.referenceSymbol({ + category: 'external', + resource: 'zod.z', + }); + + if (additional) { + return $(z.placeholder).attr(identifiers.record).call(additional); + } + + return $(z.placeholder).attr(identifiers.object).call(shape); +} + export const objectToAst = ({ plugin, schema, @@ -18,16 +36,11 @@ export const objectToAst = ({ }): Omit & { anyType?: string; } => { - const z = plugin.referenceSymbol({ - category: 'external', - resource: 'zod.z', - }); - let hasLazyExpression = false; // TODO: parser - handle constants - const properties: Array = []; + const shape = $.object().pretty(); const required = schema.required ?? []; for (const name in schema.properties) { @@ -44,37 +57,13 @@ export const objectToAst = ({ }, }); - if (propertyExpression.hasLazyExpression) { - hasLazyExpression = true; - } + if (propertyExpression.hasLazyExpression) hasLazyExpression = true; - numberRegExp.lastIndex = 0; - let propertyName; - if (numberRegExp.test(name)) { - // For numeric literals, we'll handle negative numbers by using a string literal - // instead of trying to use a PrefixUnaryExpression - propertyName = name.startsWith('-') - ? ts.factory.createStringLiteral(name) - : ts.factory.createNumericLiteral(name); - } else { - propertyName = name; - } - // TODO: parser - abstract safe property name logic - if ( - ((name.match(/^[0-9]/) && name.match(/\D+/g)) || name.match(/\W/g)) && - !name.startsWith("'") && - !name.endsWith("'") - ) { - propertyName = `'${name}'`; - } - properties.push( - tsc.propertyAssignment({ - initializer: propertyExpression.expression, - name: propertyName, - }), - ); + shape.prop(name, propertyExpression.expression); } + let additional: ts.Expression | null | undefined; + const result: Partial> = {}; if ( schema.additionalProperties && (!schema.properties || !Object.keys(schema.properties).length) @@ -87,30 +76,25 @@ export const objectToAst = ({ path: toRef([...state.path.value, 'additionalProperties']), }, }); - const expression = tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: z.placeholder, - name: identifiers.record, - }), - parameters: [additionalAst.expression], - }); - return { - anyType: 'AnyZodObject', - expression, - hasLazyExpression: additionalAst.hasLazyExpression, - }; + hasLazyExpression = additionalAst.hasLazyExpression || hasLazyExpression; + additional = additionalAst.expression; } - const expression = tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: z.placeholder, - name: identifiers.object, - }), - parameters: [ts.factory.createObjectLiteralExpression(properties, true)], - }); + const args: ObjectBaseResolverArgs = { + $, + additional, + chain: undefined, + plugin, + schema, + shape, + }; + const resolver = plugin.config['~resolvers']?.object?.base; + const chain = resolver?.(args) ?? defaultObjectBaseResolver(args); + result.expression = chain.$render(); + return { anyType: 'AnyZodObject', - expression, + expression: result.expression!, hasLazyExpression, }; }; diff --git a/packages/openapi-ts/src/plugins/zod/v4/toAst/object.ts b/packages/openapi-ts/src/plugins/zod/v4/toAst/object.ts index 49b932a5f..f88ec2e33 100644 --- a/packages/openapi-ts/src/plugins/zod/v4/toAst/object.ts +++ b/packages/openapi-ts/src/plugins/zod/v4/toAst/object.ts @@ -1,14 +1,34 @@ -import ts from 'typescript'; +import type ts from 'typescript'; import type { SchemaWithType } from '~/plugins'; import { toRef } from '~/plugins/shared/utils/refs'; -import { tsc } from '~/tsc'; -import { numberRegExp } from '~/utils/regexp'; +import type { CallTsDsl } from '~/ts-dsl'; +import { $ } from '~/ts-dsl'; import { identifiers } from '../../constants'; import type { Ast, IrSchemaToAstOptions } from '../../shared/types'; +import type { ObjectBaseResolverArgs } from '../../types'; import { irSchemaToAst } from '../plugin'; +function defaultObjectBaseResolver({ + additional, + plugin, + shape, +}: ObjectBaseResolverArgs): CallTsDsl { + const z = plugin.referenceSymbol({ + category: 'external', + resource: 'zod.z', + }); + + if (additional) { + return $(z.placeholder) + .attr(identifiers.record) + .call($(z.placeholder).attr(identifiers.string).call(), additional); + } + + return $(z.placeholder).attr(identifiers.object).call(shape); +} + export const objectToAst = ({ plugin, schema, @@ -19,16 +39,10 @@ export const objectToAst = ({ const result: Partial> = {}; // TODO: parser - handle constants - const properties: Array = - []; + const shape = $.object().pretty(); const required = schema.required ?? []; - const z = plugin.referenceSymbol({ - category: 'external', - resource: 'zod.z', - }); - for (const name in schema.properties) { const property = schema.properties[name]!; const isRequired = required.includes(name); @@ -46,47 +60,14 @@ export const objectToAst = ({ result.hasLazyExpression = true; } - numberRegExp.lastIndex = 0; - let propertyName; - if (numberRegExp.test(name)) { - // For numeric literals, we'll handle negative numbers by using a string literal - // instead of trying to use a PrefixUnaryExpression - propertyName = name.startsWith('-') - ? ts.factory.createStringLiteral(name) - : ts.factory.createNumericLiteral(name); - } else { - propertyName = name; - } - // TODO: parser - abstract safe property name logic - if ( - ((name.match(/^[0-9]/) && name.match(/\D+/g)) || name.match(/\W/g)) && - !name.startsWith("'") && - !name.endsWith("'") - ) { - propertyName = `'${name}'`; - } - if (propertyAst.hasLazyExpression) { - properties.push( - tsc.getAccessorDeclaration({ - name: propertyName, - statements: [ - tsc.returnStatement({ - expression: propertyAst.expression, - }), - ], - }), - ); + shape.getter(name, $(propertyAst.expression).return()); } else { - properties.push( - tsc.propertyAssignment({ - initializer: propertyAst.expression, - name: propertyName, - }), - ); + shape.prop(name, propertyAst.expression); } } + let additional: ts.Expression | null | undefined; if ( schema.additionalProperties && (!schema.properties || !Object.keys(schema.properties).length) @@ -99,46 +80,23 @@ export const objectToAst = ({ path: toRef([...state.path.value, 'additionalProperties']), }, }); - result.expression = tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: z.placeholder, - name: identifiers.record, - }), - parameters: [ - tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: z.placeholder, - name: identifiers.string, - }), - parameters: [], - }), - additionalAst.expression, - ], - }); - if (additionalAst.hasLazyExpression) { - result.hasLazyExpression = true; - } - - // Return with typeName for circular references - if (result.hasLazyExpression) { - return { - ...result, - typeName: 'ZodType', - } as Ast; - } - - return result as Omit; + if (additionalAst.hasLazyExpression) result.hasLazyExpression = true; + additional = additionalAst.expression; } - result.expression = tsc.callExpression({ - functionName: tsc.propertyAccessExpression({ - expression: z.placeholder, - name: identifiers.object, - }), - parameters: [ts.factory.createObjectLiteralExpression(properties, true)], - }); - - // Return with typeName for circular references (AnyZodObject doesn't exist in Zod v4, use ZodType) + const args: ObjectBaseResolverArgs = { + $, + additional, + chain: undefined, + plugin, + schema, + shape, + }; + const resolver = plugin.config['~resolvers']?.object?.base; + const chain = resolver?.(args) ?? defaultObjectBaseResolver(args); + result.expression = chain.$render(); + + // Return with typeName for circular references if (result.hasLazyExpression) { return { ...result, diff --git a/packages/openapi-ts/src/ts-dsl/object.ts b/packages/openapi-ts/src/ts-dsl/object.ts index d3858fc54..4e16f2ea9 100644 --- a/packages/openapi-ts/src/ts-dsl/object.ts +++ b/packages/openapi-ts/src/ts-dsl/object.ts @@ -1,13 +1,27 @@ import ts from 'typescript'; +import { numberRegExp } from '~/utils/regexp'; + import type { MaybeTsDsl, WithString } from './base'; import { TsDsl } from './base'; +import { GetterTsDsl } from './getter'; +import { SetterTsDsl } from './setter'; export class ObjectTsDsl extends TsDsl { private static readonly DEFAULT_THRESHOLD = 3; private layout: boolean | number = ObjectTsDsl.DEFAULT_THRESHOLD; private props: Array< + | { + expr: WithString>; + kind: 'getter'; + name: string; + } | { expr: MaybeTsDsl; kind: 'prop'; name: string } + | { + expr: WithString>; + kind: 'setter'; + name: string; + } | { expr: MaybeTsDsl; kind: 'spread' } > = []; @@ -17,6 +31,12 @@ export class ObjectTsDsl extends TsDsl { return this; } + /** Adds a getter property (e.g. `{ get foo() { ... } }`). */ + getter(name: string, expr: WithString>): this { + this.props.push({ expr, kind: 'getter', name }); + return this; + } + /** Returns true if object has at least one property or spread. */ hasProps(): boolean { return this.props.length > 0; @@ -45,6 +65,12 @@ export class ObjectTsDsl extends TsDsl { return this; } + /** Adds a setter property (e.g. `{ set foo(v) { ... } }`). */ + setter(name: string, expr: WithString>): this { + this.props.push({ expr, kind: 'setter', name }); + return this; + } + /** Adds a spread property (e.g. `{ ...options }`). */ spread(expr: MaybeTsDsl): this { this.props.push({ expr, kind: 'spread' }); @@ -55,10 +81,38 @@ export class ObjectTsDsl extends TsDsl { $render(): ts.ObjectLiteralExpression { const props = this.props.map((p) => { const node = this.$node(p.expr); - if (p.kind === 'spread') return ts.factory.createSpreadAssignment(node); - return ts.isIdentifier(node) && node.text === p.name - ? ts.factory.createShorthandPropertyAssignment(p.name) - : ts.factory.createPropertyAssignment(p.name, node); + if (p.kind === 'spread') { + if (ts.isStatement(node)) { + throw new Error( + 'Invalid spread: object spread must be an expression, not a statement.', + ); + } + return ts.factory.createSpreadAssignment(node); + } + if (p.kind === 'getter') { + const getter = new GetterTsDsl( + this.safePropertyName(p.name) as string, + ).do(node); + return this.$node(getter); + } + if (p.kind === 'setter') { + const setter = new SetterTsDsl( + this.safePropertyName(p.name) as string, + ).do(node); + return this.$node(setter); + } + if (ts.isIdentifier(node) && node.text === p.name) { + return ts.factory.createShorthandPropertyAssignment(p.name); + } + if (ts.isStatement(node)) { + throw new Error( + 'Invalid property: object property value must be an expression, not a statement.', + ); + } + return ts.factory.createPropertyAssignment( + this.safePropertyName(p.name), + node, + ); }); const multiLine = @@ -68,4 +122,26 @@ export class ObjectTsDsl extends TsDsl { return ts.factory.createObjectLiteralExpression(props, multiLine); } + + private safePropertyName(name: string): string | ts.PropertyName { + let propertyName: string | ts.PropertyName; + numberRegExp.lastIndex = 0; + if (numberRegExp.test(name)) { + // For numeric literals, we'll handle negative numbers by using a string literal + // instead of trying to use a PrefixUnaryExpression + propertyName = name.startsWith('-') + ? ts.factory.createStringLiteral(name) + : ts.factory.createNumericLiteral(name); + } else { + propertyName = name; + } + if ( + ((name.match(/^[0-9]/) && name.match(/\D+/g)) || name.match(/\W/g)) && + !name.startsWith("'") && + !name.endsWith("'") + ) { + propertyName = `'${name}'`; + } + return propertyName; + } }