fork of hey-api/openapi-ts because I need some additional things
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #3209 from thyming/add-enum-resolver-support

authored by

Lubos and committed by
GitHub
4213072c 1a98f238

+676 -106
+5
.changeset/young-items-brake.md
··· 1 + --- 2 + "@hey-api/openapi-ts": patch 3 + --- 4 + 5 + **plugin(valibot)**: add `enum` resolver
+5
.changeset/young-mitems-brake.md
··· 1 + --- 2 + "@hey-api/openapi-ts": patch 3 + --- 4 + 5 + **plugin(zod)**: add `enum` resolver
+52
docs/openapi-ts/plugins/concepts/resolvers.md
··· 16 16 1. [Handle arbitrary schema formats](#example-1) 17 17 2. [Validate high precision numbers](#example-2) 18 18 3. [Replace default base](#example-3) 19 + 4. [Create permissive enums](#example-4) 19 20 20 21 ## Terminology 21 22 ··· 170 171 export const vUser = v.object({ 171 172 age: v.number(), 172 173 }); 174 + ``` 175 + 176 + ::: 177 + 178 + ## Example 4 179 + 180 + ### Create permissive enums 181 + 182 + By default, enum schemas are strict and will reject unknown values. 183 + 184 + ```js 185 + export const zStatus = z.enum(['active', 'inactive', 'pending']); 186 + ``` 187 + 188 + 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. 189 + 190 + ```js 191 + { 192 + name: 'zod', 193 + '~resolvers': { 194 + enum(ctx) { 195 + const { $, symbols } = ctx; 196 + const { z } = symbols; 197 + const { allStrings, enumMembers, literalMembers } = ctx.nodes.items(ctx); 198 + 199 + if (!allStrings || !enumMembers.length) { 200 + return; 201 + } 202 + 203 + const enumSchema = $(z).attr('enum').call($.array(...enumMembers)); 204 + return $(z).attr('union').call( 205 + $.array(enumSchema, $(z).attr('string').call()) 206 + ); 207 + } 208 + } 209 + } 210 + ``` 211 + 212 + This resolver creates a union that accepts both the known enum values and any other string. 213 + 214 + ::: code-group 215 + 216 + ```js [after] 217 + export const zStatus = z.union([ 218 + z.enum(['active', 'inactive', 'pending']), 219 + z.string(), 220 + ]); 221 + ``` 222 + 223 + ```js [before] 224 + export const zStatus = z.enum(['active', 'inactive', 'pending']); 173 225 ``` 174 226 175 227 :::
+9
packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/enum-resolver-permissive/zod.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + import * as z from 'zod/v4-mini'; 4 + 5 + export const zFoo = z.union([z.enum(['foo', 'bar']), z.string()]); 6 + 7 + export const zBar = z.union([z.enum(['foo', 'bar']), z.string()]); 8 + 9 + export const zBaz = z.union([z.enum(['foo', 'bar']), z.string()]);
+9
packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/enum-resolver-permissive/zod.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + import { z } from 'zod'; 4 + 5 + export const zFoo = z.union([z.enum(['foo', 'bar']), z.string()]); 6 + 7 + export const zBar = z.union([z.enum(['foo', 'bar']), z.string()]); 8 + 9 + export const zBaz = z.union([z.enum(['foo', 'bar']), z.string()]);
+9
packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/enum-resolver-permissive/zod.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + import { z } from 'zod/v4'; 4 + 5 + export const zFoo = z.union([z.enum(['foo', 'bar']), z.string()]); 6 + 7 + export const zBar = z.union([z.enum(['foo', 'bar']), z.string()]); 8 + 9 + export const zBaz = z.union([z.enum(['foo', 'bar']), z.string()]);
+31
packages/openapi-ts-tests/zod/v3/test/3.1.x.test.ts
··· 145 145 }), 146 146 description: 'validator schemas with string constraints union', 147 147 }, 148 + { 149 + config: createConfig({ 150 + input: 'enum-null.json', 151 + output: 'enum-resolver-permissive', 152 + plugins: [ 153 + { 154 + compatibilityVersion: zodVersion.compatibilityVersion, 155 + name: 'zod', 156 + '~resolvers': { 157 + enum(ctx) { 158 + const { $, symbols } = ctx; 159 + const { z } = symbols; 160 + const { allStrings, enumMembers } = ctx.nodes.items(ctx); 161 + 162 + if (!allStrings || !enumMembers.length) { 163 + return; 164 + } 165 + 166 + const enumSchema = $(z) 167 + .attr('enum') 168 + .call($.array(...enumMembers)); 169 + return $(z) 170 + .attr('union') 171 + .call($.array(enumSchema, $(z).attr('string').call())); 172 + }, 173 + }, 174 + }, 175 + ], 176 + }), 177 + description: 'generates permissive enums with enum resolver', 178 + }, 148 179 ]; 149 180 150 181 it.each(scenarios)('$description', async ({ config }) => {
+9
packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/enum-resolver-permissive/zod.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + import * as z from 'zod/mini'; 4 + 5 + export const zFoo = z.union([z.enum(['foo', 'bar']), z.string()]); 6 + 7 + export const zBar = z.union([z.enum(['foo', 'bar']), z.string()]); 8 + 9 + export const zBaz = z.union([z.enum(['foo', 'bar']), z.string()]);
+9
packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/enum-resolver-permissive/zod.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + import { z } from 'zod/v3'; 4 + 5 + export const zFoo = z.union([z.enum(['foo', 'bar']), z.string()]); 6 + 7 + export const zBar = z.union([z.enum(['foo', 'bar']), z.string()]); 8 + 9 + export const zBaz = z.union([z.enum(['foo', 'bar']), z.string()]);
+9
packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/enum-resolver-permissive/zod.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + import { z } from 'zod'; 4 + 5 + export const zFoo = z.union([z.enum(['foo', 'bar']), z.string()]); 6 + 7 + export const zBar = z.union([z.enum(['foo', 'bar']), z.string()]); 8 + 9 + export const zBaz = z.union([z.enum(['foo', 'bar']), z.string()]);
+31
packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts
··· 152 152 }), 153 153 description: 'validator schemas with string constraints union', 154 154 }, 155 + { 156 + config: createConfig({ 157 + input: 'enum-null.json', 158 + output: 'enum-resolver-permissive', 159 + plugins: [ 160 + { 161 + compatibilityVersion: zodVersion.compatibilityVersion, 162 + name: 'zod', 163 + '~resolvers': { 164 + enum(ctx) { 165 + const { $, symbols } = ctx; 166 + const { z } = symbols; 167 + const { allStrings, enumMembers } = ctx.nodes.items(ctx); 168 + 169 + if (!allStrings || !enumMembers.length) { 170 + return; 171 + } 172 + 173 + const enumSchema = $(z) 174 + .attr('enum') 175 + .call($.array(...enumMembers)); 176 + return $(z) 177 + .attr('union') 178 + .call($.array(enumSchema, $(z).attr('string').call())); 179 + }, 180 + }, 181 + }, 182 + ], 183 + }), 184 + description: 'generates permissive enums with enum resolver', 185 + }, 155 186 ]; 156 187 157 188 it.each(scenarios)('$description', async ({ config }) => {
+1
packages/openapi-ts/src/plugins/valibot/resolvers/index.ts
··· 1 1 export type { 2 + EnumResolverContext, 2 3 NumberResolverContext, 3 4 ObjectResolverContext, 4 5 Resolvers,
+44
packages/openapi-ts/src/plugins/valibot/resolvers/types.d.ts
··· 15 15 16 16 export type Resolvers = Plugin.Resolvers<{ 17 17 /** 18 + * Resolver for enum schemas. 19 + * 20 + * Allows customization of how enum types are rendered. 21 + * 22 + * Returning `undefined` will execute the default resolver logic. 23 + */ 24 + enum?: (ctx: EnumResolverContext) => PipeResult | undefined; 25 + /** 18 26 * Resolver for number schemas. 19 27 * 20 28 * Allows customization of how number types are rendered. ··· 94 102 */ 95 103 symbols: { 96 104 v: Symbol; 105 + }; 106 + } 107 + 108 + export interface EnumResolverContext extends BaseContext { 109 + /** 110 + * Nodes used to build different parts of the enum schema. 111 + */ 112 + nodes: { 113 + /** 114 + * Returns the base enum expression (v.picklist([...])). 115 + */ 116 + base: (ctx: EnumResolverContext) => PipeResult; 117 + /** 118 + * Returns parsed enum items with metadata about the enum members. 119 + */ 120 + items: (ctx: EnumResolverContext) => { 121 + /** 122 + * String literal values for use with v.picklist([...]). 123 + */ 124 + enumMembers: Array<ReturnType<typeof $.literal>>; 125 + /** 126 + * Whether the enum includes a null value. 127 + */ 128 + isNullable: boolean; 129 + }; 130 + /** 131 + * Returns a nullable wrapper if the enum includes null, undefined otherwise. 132 + */ 133 + nullable: (ctx: EnumResolverContext) => PipeResult | undefined; 134 + }; 135 + schema: SchemaWithType<'enum'>; 136 + /** 137 + * Utility functions for enum schema processing. 138 + */ 139 + utils: { 140 + state: Refs<PluginState>; 97 141 }; 98 142 } 99 143
+92 -20
packages/openapi-ts/src/plugins/valibot/v1/toAst/enum.ts
··· 1 1 import type { SchemaWithType } from '~/plugins'; 2 2 import { $ } from '~/ts-dsl'; 3 3 4 + import type { EnumResolverContext } from '../../resolvers'; 5 + import type { Pipe, PipeResult } from '../../shared/pipes'; 6 + import { pipes } from '../../shared/pipes'; 4 7 import type { IrSchemaToAstOptions } from '../../shared/types'; 5 8 import { identifiers } from '../constants'; 6 9 import { unknownToAst } from './unknown'; 7 10 8 - export const enumToAst = ({ 9 - plugin, 10 - schema, 11 - state, 12 - }: IrSchemaToAstOptions & { 13 - schema: SchemaWithType<'enum'>; 14 - }): ReturnType<typeof $.call> => { 11 + function itemsNode( 12 + ctx: EnumResolverContext, 13 + ): ReturnType<EnumResolverContext['nodes']['items']> { 14 + const { schema } = ctx; 15 + 15 16 const enumMembers: Array<ReturnType<typeof $.literal>> = []; 16 17 17 18 let isNullable = false; 18 19 19 20 for (const item of schema.items ?? []) { 20 - // Zod supports only string enums 21 21 if (item.type === 'string' && typeof item.const === 'string') { 22 22 enumMembers.push($.literal(item.const)); 23 23 } else if (item.type === 'null' || item.const === null) { ··· 25 25 } 26 26 } 27 27 28 + return { 29 + enumMembers, 30 + isNullable, 31 + }; 32 + } 33 + 34 + function baseNode(ctx: EnumResolverContext): PipeResult { 35 + const { symbols } = ctx; 36 + const { v } = symbols; 37 + const { enumMembers } = ctx.nodes.items(ctx); 38 + return $(v) 39 + .attr(identifiers.schemas.picklist) 40 + .call($.array(...enumMembers)); 41 + } 42 + 43 + function nullableNode(ctx: EnumResolverContext): PipeResult | undefined { 44 + const { symbols } = ctx; 45 + const { v } = symbols; 46 + const { isNullable } = ctx.nodes.items(ctx); 47 + if (!isNullable) return; 48 + const currentNode = ctx.pipes.toNode(ctx.pipes.current, ctx.plugin); 49 + return $(v).attr(identifiers.schemas.nullable).call(currentNode); 50 + } 51 + 52 + function enumResolver(ctx: EnumResolverContext): PipeResult { 53 + const { enumMembers } = ctx.nodes.items(ctx); 54 + 55 + if (!enumMembers.length) { 56 + return ctx.pipes.current; 57 + } 58 + 59 + const baseExpression = ctx.nodes.base(ctx); 60 + ctx.pipes.push(ctx.pipes.current, baseExpression); 61 + 62 + const nullableExpression = ctx.nodes.nullable(ctx); 63 + if (nullableExpression) { 64 + return nullableExpression; 65 + } 66 + 67 + return ctx.pipes.current; 68 + } 69 + 70 + export const enumToAst = ({ 71 + plugin, 72 + schema, 73 + state, 74 + }: IrSchemaToAstOptions & { 75 + schema: SchemaWithType<'enum'>; 76 + }): Pipe => { 77 + const v = plugin.external('valibot.v'); 78 + 79 + const { enumMembers } = itemsNode({ 80 + $, 81 + nodes: { base: baseNode, items: itemsNode, nullable: nullableNode }, 82 + pipes: { ...pipes, current: [] }, 83 + plugin, 84 + schema, 85 + symbols: { v }, 86 + utils: { state }, 87 + }); 88 + 28 89 if (!enumMembers.length) { 29 90 return unknownToAst({ 30 91 plugin, ··· 35 96 }); 36 97 } 37 98 38 - const v = plugin.external('valibot.v'); 39 - 40 - let resultExpression = $(v) 41 - .attr(identifiers.schemas.picklist) 42 - .call($.array(...enumMembers)); 43 - 44 - if (isNullable) { 45 - resultExpression = $(v) 46 - .attr(identifiers.schemas.nullable) 47 - .call(resultExpression); 48 - } 99 + const ctx: EnumResolverContext = { 100 + $, 101 + nodes: { 102 + base: baseNode, 103 + items: itemsNode, 104 + nullable: nullableNode, 105 + }, 106 + pipes: { 107 + ...pipes, 108 + current: [], 109 + }, 110 + plugin, 111 + schema, 112 + symbols: { 113 + v, 114 + }, 115 + utils: { 116 + state, 117 + }, 118 + }; 49 119 50 - return resultExpression; 120 + const resolver = plugin.config['~resolvers']?.enum; 121 + const node = resolver?.(ctx) ?? enumResolver(ctx); 122 + return ctx.pipes.toNode(node, plugin); 51 123 };
+104 -29
packages/openapi-ts/src/plugins/zod/mini/toAst/enum.ts
··· 2 2 import { $ } from '~/ts-dsl'; 3 3 4 4 import { identifiers } from '../../constants'; 5 + import type { EnumResolverContext } from '../../resolvers'; 6 + import type { Chain } from '../../shared/chain'; 5 7 import type { Ast, IrSchemaToAstOptions } from '../../shared/types'; 6 8 import { unknownToAst } from './unknown'; 7 9 8 - export const enumToAst = ({ 9 - plugin, 10 - schema, 11 - state, 12 - }: IrSchemaToAstOptions & { 13 - schema: SchemaWithType<'enum'>; 14 - }): Omit<Ast, 'typeName'> => { 15 - const z = plugin.external('zod.z'); 16 - 17 - const result: Partial<Omit<Ast, 'typeName'>> = {}; 10 + function itemsNode( 11 + ctx: EnumResolverContext, 12 + ): ReturnType<EnumResolverContext['nodes']['items']> { 13 + const { schema, symbols } = ctx; 14 + const { z } = symbols; 18 15 19 16 const enumMembers: Array<ReturnType<typeof $.literal>> = []; 20 - const literalMembers: Array<ReturnType<typeof $.call>> = []; 17 + const literalMembers: Array<Chain> = []; 21 18 22 19 let isNullable = false; 23 20 let allStrings = true; 24 21 25 22 for (const item of schema.items ?? []) { 26 - // Zod supports string, number, and boolean enums 27 23 if (item.type === 'string' && typeof item.const === 'string') { 28 24 const literal = $.literal(item.const); 29 25 enumMembers.push(literal); ··· 44 40 } 45 41 } 46 42 43 + return { 44 + allStrings, 45 + enumMembers, 46 + isNullable, 47 + literalMembers, 48 + }; 49 + } 50 + 51 + function baseNode(ctx: EnumResolverContext): Chain { 52 + const { symbols } = ctx; 53 + const { z } = symbols; 54 + const { allStrings, enumMembers, literalMembers } = ctx.nodes.items(ctx); 55 + 56 + if (allStrings && enumMembers.length > 0) { 57 + return $(z) 58 + .attr(identifiers.enum) 59 + .call($.array(...enumMembers)); 60 + } else if (literalMembers.length === 1) { 61 + return literalMembers[0]!; 62 + } else { 63 + return $(z) 64 + .attr(identifiers.union) 65 + .call($.array(...literalMembers)); 66 + } 67 + } 68 + 69 + function nullableNode(ctx: EnumResolverContext): Chain | undefined { 70 + const { chain, symbols } = ctx; 71 + const { z } = symbols; 72 + const { isNullable } = ctx.nodes.items(ctx); 73 + if (!isNullable) return; 74 + return $(z).attr(identifiers.nullable).call(chain.current); 75 + } 76 + 77 + function enumResolver(ctx: EnumResolverContext): Chain { 78 + const { literalMembers } = ctx.nodes.items(ctx); 79 + 80 + if (!literalMembers.length) { 81 + return ctx.chain.current; 82 + } 83 + 84 + const baseExpression = ctx.nodes.base(ctx); 85 + ctx.chain.current = baseExpression; 86 + 87 + const nullableExpression = ctx.nodes.nullable(ctx); 88 + if (nullableExpression) { 89 + ctx.chain.current = nullableExpression; 90 + } 91 + 92 + return ctx.chain.current; 93 + } 94 + 95 + export const enumToAst = ({ 96 + plugin, 97 + schema, 98 + state, 99 + }: IrSchemaToAstOptions & { 100 + schema: SchemaWithType<'enum'>; 101 + }): Omit<Ast, 'typeName'> => { 102 + const z = plugin.external('zod.z'); 103 + 104 + const { literalMembers } = itemsNode({ 105 + $, 106 + chain: { current: $(z) }, 107 + nodes: { base: baseNode, items: itemsNode, nullable: nullableNode }, 108 + plugin, 109 + schema, 110 + symbols: { z }, 111 + utils: { ast: {}, state }, 112 + }); 113 + 47 114 if (!literalMembers.length) { 48 115 return unknownToAst({ 49 116 plugin, ··· 54 121 }); 55 122 } 56 123 57 - // Use z.enum() for pure string enums, z.union() for mixed or non-string types 58 - if (allStrings && enumMembers.length > 0) { 59 - result.expression = $(z) 60 - .attr(identifiers.enum) 61 - .call($.array(...enumMembers)); 62 - } else if (literalMembers.length === 1) { 63 - // For single-member unions, use the member directly instead of wrapping in z.union() 64 - result.expression = literalMembers[0]!; 65 - } else { 66 - result.expression = $(z) 67 - .attr(identifiers.union) 68 - .call($.array(...literalMembers)); 69 - } 124 + const ctx: EnumResolverContext = { 125 + $, 126 + chain: { 127 + current: $(z), 128 + }, 129 + nodes: { 130 + base: baseNode, 131 + items: itemsNode, 132 + nullable: nullableNode, 133 + }, 134 + plugin, 135 + schema, 136 + symbols: { 137 + z, 138 + }, 139 + utils: { 140 + ast: {}, 141 + state, 142 + }, 143 + }; 70 144 71 - if (isNullable) { 72 - result.expression = $(z).attr(identifiers.nullable).call(result.expression); 73 - } 145 + const resolver = plugin.config['~resolvers']?.enum; 146 + const node = resolver?.(ctx) ?? enumResolver(ctx); 74 147 75 - return result as Omit<Ast, 'typeName'>; 148 + return { 149 + expression: node, 150 + }; 76 151 };
+1
packages/openapi-ts/src/plugins/zod/resolvers/index.ts
··· 1 1 export type { 2 + EnumResolverContext, 2 3 NumberResolverContext, 3 4 ObjectResolverContext, 4 5 Resolvers,
+53
packages/openapi-ts/src/plugins/zod/resolvers/types.d.ts
··· 17 17 18 18 export type Resolvers = Plugin.Resolvers<{ 19 19 /** 20 + * Resolver for enum schemas. 21 + * 22 + * Allows customization of how enum types are rendered. 23 + * 24 + * Returning `undefined` will execute the default resolver logic. 25 + */ 26 + enum?: (ctx: EnumResolverContext) => Chain | undefined; 27 + /** 20 28 * Resolver for number schemas. 21 29 * 22 30 * Allows customization of how number types are rendered. ··· 96 104 */ 97 105 symbols: { 98 106 z: Symbol; 107 + }; 108 + } 109 + 110 + export interface EnumResolverContext extends BaseContext { 111 + /** 112 + * Nodes used to build different parts of the enum schema. 113 + */ 114 + nodes: { 115 + /** 116 + * Returns the base enum expression (z.enum([...]) or z.union([...]) for mixed types). 117 + */ 118 + base: (ctx: EnumResolverContext) => Chain; 119 + /** 120 + * Returns parsed enum items with metadata about the enum members. 121 + */ 122 + items: (ctx: EnumResolverContext) => { 123 + /** 124 + * Whether all enum items are strings (determines if z.enum can be used). 125 + */ 126 + allStrings: boolean; 127 + /** 128 + * String literal values for use with z.enum([...]). 129 + */ 130 + enumMembers: Array<ReturnType<typeof $.literal>>; 131 + /** 132 + * Whether the enum includes a null value. 133 + */ 134 + isNullable: boolean; 135 + /** 136 + * z.literal(...) expressions for each non-null enum value. 137 + */ 138 + literalMembers: Array<Chain>; 139 + }; 140 + /** 141 + * Returns a nullable wrapper if the enum includes null, undefined otherwise. 142 + */ 143 + nullable: (ctx: EnumResolverContext) => Chain | undefined; 144 + }; 145 + schema: SchemaWithType<'enum'>; 146 + /** 147 + * Utility functions for enum schema processing. 148 + */ 149 + utils: { 150 + ast: Partial<Omit<Ast, 'typeName'>>; 151 + state: Refs<PluginState>; 99 152 }; 100 153 } 101 154
+99 -28
packages/openapi-ts/src/plugins/zod/v3/toAst/enum.ts
··· 2 2 import { $ } from '~/ts-dsl'; 3 3 4 4 import { identifiers } from '../../constants'; 5 + import type { EnumResolverContext } from '../../resolvers'; 6 + import type { Chain } from '../../shared/chain'; 5 7 import type { IrSchemaToAstOptions } from '../../shared/types'; 6 8 import { unknownToAst } from './unknown'; 7 9 8 - export const enumToAst = ({ 9 - plugin, 10 - schema, 11 - state, 12 - }: IrSchemaToAstOptions & { 13 - schema: SchemaWithType<'enum'>; 14 - }): ReturnType<typeof $.call> => { 15 - const z = plugin.external('zod.z'); 10 + function itemsNode( 11 + ctx: EnumResolverContext, 12 + ): ReturnType<EnumResolverContext['nodes']['items']> { 13 + const { schema, symbols } = ctx; 14 + const { z } = symbols; 16 15 17 16 const enumMembers: Array<ReturnType<typeof $.literal>> = []; 18 - const literalMembers: Array<ReturnType<typeof $.call>> = []; 17 + const literalMembers: Array<Chain> = []; 19 18 20 19 let isNullable = false; 21 20 let allStrings = true; 22 21 23 22 for (const item of schema.items ?? []) { 24 - // Zod supports string, number, and boolean enums 25 23 if (item.type === 'string' && typeof item.const === 'string') { 26 24 const literal = $.literal(item.const); 27 25 enumMembers.push(literal); ··· 42 40 } 43 41 } 44 42 45 - if (!literalMembers.length) { 46 - return unknownToAst({ 47 - plugin, 48 - schema: { 49 - type: 'unknown', 50 - }, 51 - state, 52 - }); 53 - } 43 + return { 44 + allStrings, 45 + enumMembers, 46 + isNullable, 47 + literalMembers, 48 + }; 49 + } 54 50 55 - // Use z.enum() for pure string enums, z.union() for mixed or non-string types 56 - let enumExpression: ReturnType<typeof $.call>; 51 + function baseNode(ctx: EnumResolverContext): Chain { 52 + const { symbols } = ctx; 53 + const { z } = symbols; 54 + const { allStrings, enumMembers, literalMembers } = ctx.nodes.items(ctx); 55 + 57 56 if (allStrings && enumMembers.length > 0) { 58 - enumExpression = $(z) 57 + return $(z) 59 58 .attr(identifiers.enum) 60 59 .call($.array(...enumMembers)); 61 60 } else if (literalMembers.length === 1) { 62 - // For single-member unions, use the member directly instead of wrapping in z.union() 63 - enumExpression = literalMembers[0]!; 61 + return literalMembers[0]!; 64 62 } else { 65 - enumExpression = $(z) 63 + return $(z) 66 64 .attr(identifiers.union) 67 65 .call($.array(...literalMembers)); 68 66 } 67 + } 69 68 70 - if (isNullable) { 71 - enumExpression = enumExpression.attr(identifiers.nullable).call(); 69 + function nullableNode(ctx: EnumResolverContext): Chain | undefined { 70 + const { chain } = ctx; 71 + const { isNullable } = ctx.nodes.items(ctx); 72 + if (!isNullable) return; 73 + return chain.current.attr(identifiers.nullable).call(); 74 + } 75 + 76 + function enumResolver(ctx: EnumResolverContext): Chain { 77 + const { literalMembers } = ctx.nodes.items(ctx); 78 + 79 + if (!literalMembers.length) { 80 + return ctx.chain.current; 81 + } 82 + 83 + const baseExpression = ctx.nodes.base(ctx); 84 + ctx.chain.current = baseExpression; 85 + 86 + const nullableExpression = ctx.nodes.nullable(ctx); 87 + if (nullableExpression) { 88 + ctx.chain.current = nullableExpression; 89 + } 90 + 91 + return ctx.chain.current; 92 + } 93 + 94 + export const enumToAst = ({ 95 + plugin, 96 + schema, 97 + state, 98 + }: IrSchemaToAstOptions & { 99 + schema: SchemaWithType<'enum'>; 100 + }): Chain => { 101 + const z = plugin.external('zod.z'); 102 + 103 + const { literalMembers } = itemsNode({ 104 + $, 105 + chain: { current: $(z) }, 106 + nodes: { base: baseNode, items: itemsNode, nullable: nullableNode }, 107 + plugin, 108 + schema, 109 + symbols: { z }, 110 + utils: { ast: {}, state }, 111 + }); 112 + 113 + if (!literalMembers.length) { 114 + return unknownToAst({ 115 + plugin, 116 + schema: { 117 + type: 'unknown', 118 + }, 119 + state, 120 + }); 72 121 } 73 122 74 - return enumExpression; 123 + const ctx: EnumResolverContext = { 124 + $, 125 + chain: { 126 + current: $(z), 127 + }, 128 + nodes: { 129 + base: baseNode, 130 + items: itemsNode, 131 + nullable: nullableNode, 132 + }, 133 + plugin, 134 + schema, 135 + symbols: { 136 + z, 137 + }, 138 + utils: { 139 + ast: {}, 140 + state, 141 + }, 142 + }; 143 + 144 + const resolver = plugin.config['~resolvers']?.enum; 145 + return resolver?.(ctx) ?? enumResolver(ctx); 75 146 };
+104 -29
packages/openapi-ts/src/plugins/zod/v4/toAst/enum.ts
··· 2 2 import { $ } from '~/ts-dsl'; 3 3 4 4 import { identifiers } from '../../constants'; 5 + import type { EnumResolverContext } from '../../resolvers'; 6 + import type { Chain } from '../../shared/chain'; 5 7 import type { Ast, IrSchemaToAstOptions } from '../../shared/types'; 6 8 import { unknownToAst } from './unknown'; 7 9 8 - export const enumToAst = ({ 9 - plugin, 10 - schema, 11 - state, 12 - }: IrSchemaToAstOptions & { 13 - schema: SchemaWithType<'enum'>; 14 - }): Omit<Ast, 'typeName'> => { 15 - const result: Partial<Omit<Ast, 'typeName'>> = {}; 16 - 17 - const z = plugin.external('zod.z'); 10 + function itemsNode( 11 + ctx: EnumResolverContext, 12 + ): ReturnType<EnumResolverContext['nodes']['items']> { 13 + const { schema, symbols } = ctx; 14 + const { z } = symbols; 18 15 19 16 const enumMembers: Array<ReturnType<typeof $.literal>> = []; 20 - const literalMembers: Array<ReturnType<typeof $.call>> = []; 17 + const literalMembers: Array<Chain> = []; 21 18 22 19 let isNullable = false; 23 20 let allStrings = true; 24 21 25 22 for (const item of schema.items ?? []) { 26 - // Zod supports string, number, and boolean enums 27 23 if (item.type === 'string' && typeof item.const === 'string') { 28 24 const literal = $.literal(item.const); 29 25 enumMembers.push(literal); ··· 44 40 } 45 41 } 46 42 43 + return { 44 + allStrings, 45 + enumMembers, 46 + isNullable, 47 + literalMembers, 48 + }; 49 + } 50 + 51 + function baseNode(ctx: EnumResolverContext): Chain { 52 + const { symbols } = ctx; 53 + const { z } = symbols; 54 + const { allStrings, enumMembers, literalMembers } = ctx.nodes.items(ctx); 55 + 56 + if (allStrings && enumMembers.length > 0) { 57 + return $(z) 58 + .attr(identifiers.enum) 59 + .call($.array(...enumMembers)); 60 + } else if (literalMembers.length === 1) { 61 + return literalMembers[0]!; 62 + } else { 63 + return $(z) 64 + .attr(identifiers.union) 65 + .call($.array(...literalMembers)); 66 + } 67 + } 68 + 69 + function nullableNode(ctx: EnumResolverContext): Chain | undefined { 70 + const { chain, symbols } = ctx; 71 + const { z } = symbols; 72 + const { isNullable } = ctx.nodes.items(ctx); 73 + if (!isNullable) return; 74 + return $(z).attr(identifiers.nullable).call(chain.current); 75 + } 76 + 77 + function enumResolver(ctx: EnumResolverContext): Chain { 78 + const { literalMembers } = ctx.nodes.items(ctx); 79 + 80 + if (!literalMembers.length) { 81 + return ctx.chain.current; 82 + } 83 + 84 + const baseExpression = ctx.nodes.base(ctx); 85 + ctx.chain.current = baseExpression; 86 + 87 + const nullableExpression = ctx.nodes.nullable(ctx); 88 + if (nullableExpression) { 89 + ctx.chain.current = nullableExpression; 90 + } 91 + 92 + return ctx.chain.current; 93 + } 94 + 95 + export const enumToAst = ({ 96 + plugin, 97 + schema, 98 + state, 99 + }: IrSchemaToAstOptions & { 100 + schema: SchemaWithType<'enum'>; 101 + }): Omit<Ast, 'typeName'> => { 102 + const z = plugin.external('zod.z'); 103 + 104 + const { literalMembers } = itemsNode({ 105 + $, 106 + chain: { current: $(z) }, 107 + nodes: { base: baseNode, items: itemsNode, nullable: nullableNode }, 108 + plugin, 109 + schema, 110 + symbols: { z }, 111 + utils: { ast: {}, state }, 112 + }); 113 + 47 114 if (!literalMembers.length) { 48 115 return unknownToAst({ 49 116 plugin, ··· 54 121 }); 55 122 } 56 123 57 - // Use z.enum() for pure string enums, z.union() for mixed or non-string types 58 - if (allStrings && enumMembers.length > 0) { 59 - result.expression = $(z) 60 - .attr(identifiers.enum) 61 - .call($.array(...enumMembers)); 62 - } else if (literalMembers.length === 1) { 63 - // For single-member unions, use the member directly instead of wrapping in z.union() 64 - result.expression = literalMembers[0]!; 65 - } else { 66 - result.expression = $(z) 67 - .attr(identifiers.union) 68 - .call($.array(...literalMembers)); 69 - } 124 + const ctx: EnumResolverContext = { 125 + $, 126 + chain: { 127 + current: $(z), 128 + }, 129 + nodes: { 130 + base: baseNode, 131 + items: itemsNode, 132 + nullable: nullableNode, 133 + }, 134 + plugin, 135 + schema, 136 + symbols: { 137 + z, 138 + }, 139 + utils: { 140 + ast: {}, 141 + state, 142 + }, 143 + }; 70 144 71 - if (isNullable) { 72 - result.expression = $(z).attr(identifiers.nullable).call(result.expression); 73 - } 145 + const resolver = plugin.config['~resolvers']?.enum; 146 + const node = resolver?.(ctx) ?? enumResolver(ctx); 74 147 75 - return result as Omit<Ast, 'typeName'>; 148 + return { 149 + expression: node, 150 + }; 76 151 };