diff --git a/.changeset/young-items-brake.md b/.changeset/young-items-brake.md new file mode 100644 index 0000000000..bbfd55d40a --- /dev/null +++ b/.changeset/young-items-brake.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +**plugin(valibot)**: add `enum` resolver diff --git a/.changeset/young-mitems-brake.md b/.changeset/young-mitems-brake.md new file mode 100644 index 0000000000..1997baa797 --- /dev/null +++ b/.changeset/young-mitems-brake.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +**plugin(zod)**: add `enum` resolver diff --git a/docs/openapi-ts/plugins/concepts/resolvers.md b/docs/openapi-ts/plugins/concepts/resolvers.md index fcb7b18d98..62e7ff2278 100644 --- a/docs/openapi-ts/plugins/concepts/resolvers.md +++ b/docs/openapi-ts/plugins/concepts/resolvers.md @@ -16,6 +16,7 @@ This page demonstrates resolvers through a few common scenarios. 1. [Handle arbitrary schema formats](#example-1) 2. [Validate high precision numbers](#example-2) 3. [Replace default base](#example-3) +4. [Create permissive enums](#example-4) ## Terminology @@ -174,6 +175,57 @@ export const vUser = v.object({ ::: +## Example 4 + +### Create permissive enums + +By default, enum schemas are strict and will reject unknown values. + +```js +export const zStatus = z.enum(['active', 'inactive', 'pending']); +``` + +You might want to accept unknown enum values, for example when the API adds new values that haven't been added to the spec yet. You can use the enum resolver to create a permissive union. + +```js +{ + name: 'zod', + '~resolvers': { + enum(ctx) { + const { $, symbols } = ctx; + const { z } = symbols; + const { allStrings, enumMembers, literalMembers } = ctx.nodes.items(ctx); + + if (!allStrings || !enumMembers.length) { + return; + } + + const enumSchema = $(z).attr('enum').call($.array(...enumMembers)); + return $(z).attr('union').call( + $.array(enumSchema, $(z).attr('string').call()) + ); + } + } +} +``` + +This resolver creates a union that accepts both the known enum values and any other string. + +::: code-group + +```js [after] +export const zStatus = z.union([ + z.enum(['active', 'inactive', 'pending']), + z.string(), +]); +``` + +```js [before] +export const zStatus = z.enum(['active', 'inactive', 'pending']); +``` + +::: + ## Feedback We welcome feedback on the Resolvers API. [Open a GitHub issue](https://github.com/hey-api/openapi-ts/issues) to request support for additional plugins. diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/enum-resolver-permissive/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/enum-resolver-permissive/zod.gen.ts new file mode 100644 index 0000000000..60ea6ed961 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/enum-resolver-permissive/zod.gen.ts @@ -0,0 +1,9 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4-mini'; + +export const zFoo = z.union([z.enum(['foo', 'bar']), z.string()]); + +export const zBar = z.union([z.enum(['foo', 'bar']), z.string()]); + +export const zBaz = z.union([z.enum(['foo', 'bar']), z.string()]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/enum-resolver-permissive/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/enum-resolver-permissive/zod.gen.ts new file mode 100644 index 0000000000..8e2f231223 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/enum-resolver-permissive/zod.gen.ts @@ -0,0 +1,9 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zFoo = z.union([z.enum(['foo', 'bar']), z.string()]); + +export const zBar = z.union([z.enum(['foo', 'bar']), z.string()]); + +export const zBaz = z.union([z.enum(['foo', 'bar']), z.string()]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/enum-resolver-permissive/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/enum-resolver-permissive/zod.gen.ts new file mode 100644 index 0000000000..1b3cccee95 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/enum-resolver-permissive/zod.gen.ts @@ -0,0 +1,9 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v4'; + +export const zFoo = z.union([z.enum(['foo', 'bar']), z.string()]); + +export const zBar = z.union([z.enum(['foo', 'bar']), z.string()]); + +export const zBaz = z.union([z.enum(['foo', 'bar']), z.string()]); diff --git a/packages/openapi-ts-tests/zod/v3/test/3.1.x.test.ts b/packages/openapi-ts-tests/zod/v3/test/3.1.x.test.ts index c8d0d0b1c3..84fec469bf 100644 --- a/packages/openapi-ts-tests/zod/v3/test/3.1.x.test.ts +++ b/packages/openapi-ts-tests/zod/v3/test/3.1.x.test.ts @@ -145,6 +145,37 @@ for (const zodVersion of zodVersions) { }), description: 'validator schemas with string constraints union', }, + { + config: createConfig({ + input: 'enum-null.json', + output: 'enum-resolver-permissive', + plugins: [ + { + compatibilityVersion: zodVersion.compatibilityVersion, + name: 'zod', + '~resolvers': { + enum(ctx) { + const { $, symbols } = ctx; + const { z } = symbols; + const { allStrings, enumMembers } = ctx.nodes.items(ctx); + + if (!allStrings || !enumMembers.length) { + return; + } + + const enumSchema = $(z) + .attr('enum') + .call($.array(...enumMembers)); + return $(z) + .attr('union') + .call($.array(enumSchema, $(z).attr('string').call())); + }, + }, + }, + ], + }), + description: 'generates permissive enums with enum resolver', + }, ]; it.each(scenarios)('$description', async ({ config }) => { diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/enum-resolver-permissive/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/enum-resolver-permissive/zod.gen.ts new file mode 100644 index 0000000000..5e88b6e80e --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/enum-resolver-permissive/zod.gen.ts @@ -0,0 +1,9 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/mini'; + +export const zFoo = z.union([z.enum(['foo', 'bar']), z.string()]); + +export const zBar = z.union([z.enum(['foo', 'bar']), z.string()]); + +export const zBaz = z.union([z.enum(['foo', 'bar']), z.string()]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/enum-resolver-permissive/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/enum-resolver-permissive/zod.gen.ts new file mode 100644 index 0000000000..ad12dc37a6 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/enum-resolver-permissive/zod.gen.ts @@ -0,0 +1,9 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v3'; + +export const zFoo = z.union([z.enum(['foo', 'bar']), z.string()]); + +export const zBar = z.union([z.enum(['foo', 'bar']), z.string()]); + +export const zBaz = z.union([z.enum(['foo', 'bar']), z.string()]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/enum-resolver-permissive/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/enum-resolver-permissive/zod.gen.ts new file mode 100644 index 0000000000..8e2f231223 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/enum-resolver-permissive/zod.gen.ts @@ -0,0 +1,9 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zFoo = z.union([z.enum(['foo', 'bar']), z.string()]); + +export const zBar = z.union([z.enum(['foo', 'bar']), z.string()]); + +export const zBaz = z.union([z.enum(['foo', 'bar']), z.string()]); diff --git a/packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts b/packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts index 03877b95f9..fda634c2ed 100644 --- a/packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts +++ b/packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts @@ -152,6 +152,37 @@ for (const zodVersion of zodVersions) { }), description: 'validator schemas with string constraints union', }, + { + config: createConfig({ + input: 'enum-null.json', + output: 'enum-resolver-permissive', + plugins: [ + { + compatibilityVersion: zodVersion.compatibilityVersion, + name: 'zod', + '~resolvers': { + enum(ctx) { + const { $, symbols } = ctx; + const { z } = symbols; + const { allStrings, enumMembers } = ctx.nodes.items(ctx); + + if (!allStrings || !enumMembers.length) { + return; + } + + const enumSchema = $(z) + .attr('enum') + .call($.array(...enumMembers)); + return $(z) + .attr('union') + .call($.array(enumSchema, $(z).attr('string').call())); + }, + }, + }, + ], + }), + description: 'generates permissive enums with enum resolver', + }, ]; it.each(scenarios)('$description', async ({ config }) => { diff --git a/packages/openapi-ts/src/plugins/valibot/resolvers/index.ts b/packages/openapi-ts/src/plugins/valibot/resolvers/index.ts index 9a8db84543..777e574f29 100644 --- a/packages/openapi-ts/src/plugins/valibot/resolvers/index.ts +++ b/packages/openapi-ts/src/plugins/valibot/resolvers/index.ts @@ -1,4 +1,5 @@ export type { + EnumResolverContext, NumberResolverContext, ObjectResolverContext, Resolvers, diff --git a/packages/openapi-ts/src/plugins/valibot/resolvers/types.d.ts b/packages/openapi-ts/src/plugins/valibot/resolvers/types.d.ts index b2f19152d3..55cf68d589 100644 --- a/packages/openapi-ts/src/plugins/valibot/resolvers/types.d.ts +++ b/packages/openapi-ts/src/plugins/valibot/resolvers/types.d.ts @@ -14,6 +14,14 @@ import type { Ast, PluginState } from '../shared/types'; import type { ValibotPlugin } from '../types'; export type Resolvers = Plugin.Resolvers<{ + /** + * Resolver for enum schemas. + * + * Allows customization of how enum types are rendered. + * + * Returning `undefined` will execute the default resolver logic. + */ + enum?: (ctx: EnumResolverContext) => PipeResult | undefined; /** * Resolver for number schemas. * @@ -97,6 +105,42 @@ interface BaseContext extends DollarTsDsl { }; } +export interface EnumResolverContext extends BaseContext { + /** + * Nodes used to build different parts of the enum schema. + */ + nodes: { + /** + * Returns the base enum expression (v.picklist([...])). + */ + base: (ctx: EnumResolverContext) => PipeResult; + /** + * Returns parsed enum items with metadata about the enum members. + */ + items: (ctx: EnumResolverContext) => { + /** + * String literal values for use with v.picklist([...]). + */ + enumMembers: Array>; + /** + * Whether the enum includes a null value. + */ + isNullable: boolean; + }; + /** + * Returns a nullable wrapper if the enum includes null, undefined otherwise. + */ + nullable: (ctx: EnumResolverContext) => PipeResult | undefined; + }; + schema: SchemaWithType<'enum'>; + /** + * Utility functions for enum schema processing. + */ + utils: { + state: Refs; + }; +} + export interface NumberResolverContext extends BaseContext { /** * Nodes used to build different parts of the number schema. diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/enum.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/enum.ts index 85f2aaadb9..b5454bf73f 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/enum.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/enum.ts @@ -1,23 +1,23 @@ import type { SchemaWithType } from '~/plugins'; import { $ } from '~/ts-dsl'; +import type { EnumResolverContext } from '../../resolvers'; +import type { Pipe, PipeResult } from '../../shared/pipes'; +import { pipes } from '../../shared/pipes'; import type { IrSchemaToAstOptions } from '../../shared/types'; import { identifiers } from '../constants'; import { unknownToAst } from './unknown'; -export const enumToAst = ({ - plugin, - schema, - state, -}: IrSchemaToAstOptions & { - schema: SchemaWithType<'enum'>; -}): ReturnType => { +function itemsNode( + ctx: EnumResolverContext, +): ReturnType { + const { schema } = ctx; + const enumMembers: Array> = []; let isNullable = false; for (const item of schema.items ?? []) { - // Zod supports only string enums if (item.type === 'string' && typeof item.const === 'string') { enumMembers.push($.literal(item.const)); } else if (item.type === 'null' || item.const === null) { @@ -25,6 +25,67 @@ export const enumToAst = ({ } } + return { + enumMembers, + isNullable, + }; +} + +function baseNode(ctx: EnumResolverContext): PipeResult { + const { symbols } = ctx; + const { v } = symbols; + const { enumMembers } = ctx.nodes.items(ctx); + return $(v) + .attr(identifiers.schemas.picklist) + .call($.array(...enumMembers)); +} + +function nullableNode(ctx: EnumResolverContext): PipeResult | undefined { + const { symbols } = ctx; + const { v } = symbols; + const { isNullable } = ctx.nodes.items(ctx); + if (!isNullable) return; + const currentNode = ctx.pipes.toNode(ctx.pipes.current, ctx.plugin); + return $(v).attr(identifiers.schemas.nullable).call(currentNode); +} + +function enumResolver(ctx: EnumResolverContext): PipeResult { + const { enumMembers } = ctx.nodes.items(ctx); + + if (!enumMembers.length) { + return ctx.pipes.current; + } + + const baseExpression = ctx.nodes.base(ctx); + ctx.pipes.push(ctx.pipes.current, baseExpression); + + const nullableExpression = ctx.nodes.nullable(ctx); + if (nullableExpression) { + return nullableExpression; + } + + return ctx.pipes.current; +} + +export const enumToAst = ({ + plugin, + schema, + state, +}: IrSchemaToAstOptions & { + schema: SchemaWithType<'enum'>; +}): Pipe => { + const v = plugin.external('valibot.v'); + + const { enumMembers } = itemsNode({ + $, + nodes: { base: baseNode, items: itemsNode, nullable: nullableNode }, + pipes: { ...pipes, current: [] }, + plugin, + schema, + symbols: { v }, + utils: { state }, + }); + if (!enumMembers.length) { return unknownToAst({ plugin, @@ -35,17 +96,28 @@ export const enumToAst = ({ }); } - const v = plugin.external('valibot.v'); - - let resultExpression = $(v) - .attr(identifiers.schemas.picklist) - .call($.array(...enumMembers)); - - if (isNullable) { - resultExpression = $(v) - .attr(identifiers.schemas.nullable) - .call(resultExpression); - } + const ctx: EnumResolverContext = { + $, + nodes: { + base: baseNode, + items: itemsNode, + nullable: nullableNode, + }, + pipes: { + ...pipes, + current: [], + }, + plugin, + schema, + symbols: { + v, + }, + utils: { + state, + }, + }; - return resultExpression; + const resolver = plugin.config['~resolvers']?.enum; + const node = resolver?.(ctx) ?? enumResolver(ctx); + return ctx.pipes.toNode(node, plugin); }; diff --git a/packages/openapi-ts/src/plugins/zod/mini/toAst/enum.ts b/packages/openapi-ts/src/plugins/zod/mini/toAst/enum.ts index 11a247b3d6..fe2e09918c 100644 --- a/packages/openapi-ts/src/plugins/zod/mini/toAst/enum.ts +++ b/packages/openapi-ts/src/plugins/zod/mini/toAst/enum.ts @@ -2,28 +2,24 @@ import type { SchemaWithType } from '~/plugins'; import { $ } from '~/ts-dsl'; import { identifiers } from '../../constants'; +import type { EnumResolverContext } from '../../resolvers'; +import type { Chain } from '../../shared/chain'; import type { Ast, IrSchemaToAstOptions } from '../../shared/types'; import { unknownToAst } from './unknown'; -export const enumToAst = ({ - plugin, - schema, - state, -}: IrSchemaToAstOptions & { - schema: SchemaWithType<'enum'>; -}): Omit => { - const z = plugin.external('zod.z'); - - const result: Partial> = {}; +function itemsNode( + ctx: EnumResolverContext, +): ReturnType { + const { schema, symbols } = ctx; + const { z } = symbols; const enumMembers: Array> = []; - const literalMembers: Array> = []; + const literalMembers: Array = []; let isNullable = false; let allStrings = true; for (const item of schema.items ?? []) { - // Zod supports string, number, and boolean enums if (item.type === 'string' && typeof item.const === 'string') { const literal = $.literal(item.const); enumMembers.push(literal); @@ -44,33 +40,112 @@ export const enumToAst = ({ } } - if (!literalMembers.length) { - return unknownToAst({ - plugin, - schema: { - type: 'unknown', - }, - state, - }); - } + return { + allStrings, + enumMembers, + isNullable, + literalMembers, + }; +} + +function baseNode(ctx: EnumResolverContext): Chain { + const { symbols } = ctx; + const { z } = symbols; + const { allStrings, enumMembers, literalMembers } = ctx.nodes.items(ctx); - // Use z.enum() for pure string enums, z.union() for mixed or non-string types if (allStrings && enumMembers.length > 0) { - result.expression = $(z) + return $(z) .attr(identifiers.enum) .call($.array(...enumMembers)); } else if (literalMembers.length === 1) { - // For single-member unions, use the member directly instead of wrapping in z.union() - result.expression = literalMembers[0]!; + return literalMembers[0]!; } else { - result.expression = $(z) + return $(z) .attr(identifiers.union) .call($.array(...literalMembers)); } +} + +function nullableNode(ctx: EnumResolverContext): Chain | undefined { + const { chain, symbols } = ctx; + const { z } = symbols; + const { isNullable } = ctx.nodes.items(ctx); + if (!isNullable) return; + return $(z).attr(identifiers.nullable).call(chain.current); +} + +function enumResolver(ctx: EnumResolverContext): Chain { + const { literalMembers } = ctx.nodes.items(ctx); + + if (!literalMembers.length) { + return ctx.chain.current; + } + + const baseExpression = ctx.nodes.base(ctx); + ctx.chain.current = baseExpression; - if (isNullable) { - result.expression = $(z).attr(identifiers.nullable).call(result.expression); + const nullableExpression = ctx.nodes.nullable(ctx); + if (nullableExpression) { + ctx.chain.current = nullableExpression; } - return result as Omit; + return ctx.chain.current; +} + +export const enumToAst = ({ + plugin, + schema, + state, +}: IrSchemaToAstOptions & { + schema: SchemaWithType<'enum'>; +}): Omit => { + const z = plugin.external('zod.z'); + + const { literalMembers } = itemsNode({ + $, + chain: { current: $(z) }, + nodes: { base: baseNode, items: itemsNode, nullable: nullableNode }, + plugin, + schema, + symbols: { z }, + utils: { ast: {}, state }, + }); + + if (!literalMembers.length) { + return unknownToAst({ + plugin, + schema: { + type: 'unknown', + }, + state, + }); + } + + const ctx: EnumResolverContext = { + $, + chain: { + current: $(z), + }, + nodes: { + base: baseNode, + items: itemsNode, + nullable: nullableNode, + }, + plugin, + schema, + symbols: { + z, + }, + utils: { + ast: {}, + state, + }, + }; + + const resolver = plugin.config['~resolvers']?.enum; + const node = resolver?.(ctx) ?? enumResolver(ctx); + + return { + expression: node, + }; }; diff --git a/packages/openapi-ts/src/plugins/zod/resolvers/index.ts b/packages/openapi-ts/src/plugins/zod/resolvers/index.ts index 9a8db84543..777e574f29 100644 --- a/packages/openapi-ts/src/plugins/zod/resolvers/index.ts +++ b/packages/openapi-ts/src/plugins/zod/resolvers/index.ts @@ -1,4 +1,5 @@ export type { + EnumResolverContext, NumberResolverContext, ObjectResolverContext, Resolvers, diff --git a/packages/openapi-ts/src/plugins/zod/resolvers/types.d.ts b/packages/openapi-ts/src/plugins/zod/resolvers/types.d.ts index 9d64854f6a..9df1926ec9 100644 --- a/packages/openapi-ts/src/plugins/zod/resolvers/types.d.ts +++ b/packages/openapi-ts/src/plugins/zod/resolvers/types.d.ts @@ -16,6 +16,14 @@ import type { Ast, PluginState } from '../shared/types'; import type { ZodPlugin } from '../types'; export type Resolvers = Plugin.Resolvers<{ + /** + * Resolver for enum schemas. + * + * Allows customization of how enum types are rendered. + * + * Returning `undefined` will execute the default resolver logic. + */ + enum?: (ctx: EnumResolverContext) => Chain | undefined; /** * Resolver for number schemas. * @@ -99,6 +107,51 @@ interface BaseContext extends DollarTsDsl { }; } +export interface EnumResolverContext extends BaseContext { + /** + * Nodes used to build different parts of the enum schema. + */ + nodes: { + /** + * Returns the base enum expression (z.enum([...]) or z.union([...]) for mixed types). + */ + base: (ctx: EnumResolverContext) => Chain; + /** + * Returns parsed enum items with metadata about the enum members. + */ + items: (ctx: EnumResolverContext) => { + /** + * Whether all enum items are strings (determines if z.enum can be used). + */ + allStrings: boolean; + /** + * String literal values for use with z.enum([...]). + */ + enumMembers: Array>; + /** + * Whether the enum includes a null value. + */ + isNullable: boolean; + /** + * z.literal(...) expressions for each non-null enum value. + */ + literalMembers: Array; + }; + /** + * Returns a nullable wrapper if the enum includes null, undefined otherwise. + */ + nullable: (ctx: EnumResolverContext) => Chain | undefined; + }; + schema: SchemaWithType<'enum'>; + /** + * Utility functions for enum schema processing. + */ + utils: { + ast: Partial>; + state: Refs; + }; +} + export interface NumberResolverContext extends BaseContext { /** * Nodes used to build different parts of the number schema. diff --git a/packages/openapi-ts/src/plugins/zod/v3/toAst/enum.ts b/packages/openapi-ts/src/plugins/zod/v3/toAst/enum.ts index 39f003c292..038f453e4d 100644 --- a/packages/openapi-ts/src/plugins/zod/v3/toAst/enum.ts +++ b/packages/openapi-ts/src/plugins/zod/v3/toAst/enum.ts @@ -2,26 +2,24 @@ import type { SchemaWithType } from '~/plugins'; import { $ } from '~/ts-dsl'; import { identifiers } from '../../constants'; +import type { EnumResolverContext } from '../../resolvers'; +import type { Chain } from '../../shared/chain'; import type { IrSchemaToAstOptions } from '../../shared/types'; import { unknownToAst } from './unknown'; -export const enumToAst = ({ - plugin, - schema, - state, -}: IrSchemaToAstOptions & { - schema: SchemaWithType<'enum'>; -}): ReturnType => { - const z = plugin.external('zod.z'); +function itemsNode( + ctx: EnumResolverContext, +): ReturnType { + const { schema, symbols } = ctx; + const { z } = symbols; const enumMembers: Array> = []; - const literalMembers: Array> = []; + const literalMembers: Array = []; let isNullable = false; let allStrings = true; for (const item of schema.items ?? []) { - // Zod supports string, number, and boolean enums if (item.type === 'string' && typeof item.const === 'string') { const literal = $.literal(item.const); enumMembers.push(literal); @@ -42,34 +40,107 @@ export const enumToAst = ({ } } - if (!literalMembers.length) { - return unknownToAst({ - plugin, - schema: { - type: 'unknown', - }, - state, - }); - } + return { + allStrings, + enumMembers, + isNullable, + literalMembers, + }; +} + +function baseNode(ctx: EnumResolverContext): Chain { + const { symbols } = ctx; + const { z } = symbols; + const { allStrings, enumMembers, literalMembers } = ctx.nodes.items(ctx); - // Use z.enum() for pure string enums, z.union() for mixed or non-string types - let enumExpression: ReturnType; if (allStrings && enumMembers.length > 0) { - enumExpression = $(z) + return $(z) .attr(identifiers.enum) .call($.array(...enumMembers)); } else if (literalMembers.length === 1) { - // For single-member unions, use the member directly instead of wrapping in z.union() - enumExpression = literalMembers[0]!; + return literalMembers[0]!; } else { - enumExpression = $(z) + return $(z) .attr(identifiers.union) .call($.array(...literalMembers)); } +} + +function nullableNode(ctx: EnumResolverContext): Chain | undefined { + const { chain } = ctx; + const { isNullable } = ctx.nodes.items(ctx); + if (!isNullable) return; + return chain.current.attr(identifiers.nullable).call(); +} + +function enumResolver(ctx: EnumResolverContext): Chain { + const { literalMembers } = ctx.nodes.items(ctx); + + if (!literalMembers.length) { + return ctx.chain.current; + } + + const baseExpression = ctx.nodes.base(ctx); + ctx.chain.current = baseExpression; - if (isNullable) { - enumExpression = enumExpression.attr(identifiers.nullable).call(); + const nullableExpression = ctx.nodes.nullable(ctx); + if (nullableExpression) { + ctx.chain.current = nullableExpression; } - return enumExpression; + return ctx.chain.current; +} + +export const enumToAst = ({ + plugin, + schema, + state, +}: IrSchemaToAstOptions & { + schema: SchemaWithType<'enum'>; +}): Chain => { + const z = plugin.external('zod.z'); + + const { literalMembers } = itemsNode({ + $, + chain: { current: $(z) }, + nodes: { base: baseNode, items: itemsNode, nullable: nullableNode }, + plugin, + schema, + symbols: { z }, + utils: { ast: {}, state }, + }); + + if (!literalMembers.length) { + return unknownToAst({ + plugin, + schema: { + type: 'unknown', + }, + state, + }); + } + + const ctx: EnumResolverContext = { + $, + chain: { + current: $(z), + }, + nodes: { + base: baseNode, + items: itemsNode, + nullable: nullableNode, + }, + plugin, + schema, + symbols: { + z, + }, + utils: { + ast: {}, + state, + }, + }; + + const resolver = plugin.config['~resolvers']?.enum; + return resolver?.(ctx) ?? enumResolver(ctx); }; diff --git a/packages/openapi-ts/src/plugins/zod/v4/toAst/enum.ts b/packages/openapi-ts/src/plugins/zod/v4/toAst/enum.ts index f971658893..fe2e09918c 100644 --- a/packages/openapi-ts/src/plugins/zod/v4/toAst/enum.ts +++ b/packages/openapi-ts/src/plugins/zod/v4/toAst/enum.ts @@ -2,28 +2,24 @@ import type { SchemaWithType } from '~/plugins'; import { $ } from '~/ts-dsl'; import { identifiers } from '../../constants'; +import type { EnumResolverContext } from '../../resolvers'; +import type { Chain } from '../../shared/chain'; import type { Ast, IrSchemaToAstOptions } from '../../shared/types'; import { unknownToAst } from './unknown'; -export const enumToAst = ({ - plugin, - schema, - state, -}: IrSchemaToAstOptions & { - schema: SchemaWithType<'enum'>; -}): Omit => { - const result: Partial> = {}; - - const z = plugin.external('zod.z'); +function itemsNode( + ctx: EnumResolverContext, +): ReturnType { + const { schema, symbols } = ctx; + const { z } = symbols; const enumMembers: Array> = []; - const literalMembers: Array> = []; + const literalMembers: Array = []; let isNullable = false; let allStrings = true; for (const item of schema.items ?? []) { - // Zod supports string, number, and boolean enums if (item.type === 'string' && typeof item.const === 'string') { const literal = $.literal(item.const); enumMembers.push(literal); @@ -44,33 +40,112 @@ export const enumToAst = ({ } } - if (!literalMembers.length) { - return unknownToAst({ - plugin, - schema: { - type: 'unknown', - }, - state, - }); - } + return { + allStrings, + enumMembers, + isNullable, + literalMembers, + }; +} + +function baseNode(ctx: EnumResolverContext): Chain { + const { symbols } = ctx; + const { z } = symbols; + const { allStrings, enumMembers, literalMembers } = ctx.nodes.items(ctx); - // Use z.enum() for pure string enums, z.union() for mixed or non-string types if (allStrings && enumMembers.length > 0) { - result.expression = $(z) + return $(z) .attr(identifiers.enum) .call($.array(...enumMembers)); } else if (literalMembers.length === 1) { - // For single-member unions, use the member directly instead of wrapping in z.union() - result.expression = literalMembers[0]!; + return literalMembers[0]!; } else { - result.expression = $(z) + return $(z) .attr(identifiers.union) .call($.array(...literalMembers)); } +} + +function nullableNode(ctx: EnumResolverContext): Chain | undefined { + const { chain, symbols } = ctx; + const { z } = symbols; + const { isNullable } = ctx.nodes.items(ctx); + if (!isNullable) return; + return $(z).attr(identifiers.nullable).call(chain.current); +} + +function enumResolver(ctx: EnumResolverContext): Chain { + const { literalMembers } = ctx.nodes.items(ctx); + + if (!literalMembers.length) { + return ctx.chain.current; + } + + const baseExpression = ctx.nodes.base(ctx); + ctx.chain.current = baseExpression; - if (isNullable) { - result.expression = $(z).attr(identifiers.nullable).call(result.expression); + const nullableExpression = ctx.nodes.nullable(ctx); + if (nullableExpression) { + ctx.chain.current = nullableExpression; } - return result as Omit; + return ctx.chain.current; +} + +export const enumToAst = ({ + plugin, + schema, + state, +}: IrSchemaToAstOptions & { + schema: SchemaWithType<'enum'>; +}): Omit => { + const z = plugin.external('zod.z'); + + const { literalMembers } = itemsNode({ + $, + chain: { current: $(z) }, + nodes: { base: baseNode, items: itemsNode, nullable: nullableNode }, + plugin, + schema, + symbols: { z }, + utils: { ast: {}, state }, + }); + + if (!literalMembers.length) { + return unknownToAst({ + plugin, + schema: { + type: 'unknown', + }, + state, + }); + } + + const ctx: EnumResolverContext = { + $, + chain: { + current: $(z), + }, + nodes: { + base: baseNode, + items: itemsNode, + nullable: nullableNode, + }, + plugin, + schema, + symbols: { + z, + }, + utils: { + ast: {}, + state, + }, + }; + + const resolver = plugin.config['~resolvers']?.enum; + const node = resolver?.(ctx) ?? enumResolver(ctx); + + return { + expression: node, + }; };