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 #2327 from hey-api/feat/zod-types

feat(zod): add types option

authored by

Lubos and committed by
GitHub
aa8c9550 078ee9de

+1545 -607
+101 -4
docs/openapi-ts/plugins/zod.md
··· 72 72 73 73 A single request schema is generated for each endpoint. It may contain a request body, parameters, and headers. 74 74 75 - ```ts 75 + ::: code-group 76 + 77 + ```js [config] 78 + export default { 79 + input: 'https://get.heyapi.dev/hey-api/backend', 80 + output: 'src/client', 81 + plugins: [ 82 + // ...other plugins 83 + { 84 + name: 'zod', 85 + requests: true, // [!code ++] 86 + }, 87 + ], 88 + }; 89 + ``` 90 + 91 + ```ts [output] 76 92 const zData = z.object({ 77 93 body: z 78 94 .object({ ··· 87 103 }); 88 104 ``` 89 105 106 + ::: 107 + 90 108 ::: tip 91 109 If you need to access individual fields, you can do so using the [`.shape`](https://zod.dev/api?id=shape) API. For example, we can get the request body schema with `zData.shape.body`. 92 110 ::: ··· 97 115 98 116 A single Zod schema is generated for all endpoint's responses. If the endpoint describes multiple responses, the generated schema is a union of all possible response shapes. 99 117 100 - ```ts 118 + ::: code-group 119 + 120 + ```js [config] 121 + export default { 122 + input: 'https://get.heyapi.dev/hey-api/backend', 123 + output: 'src/client', 124 + plugins: [ 125 + // ...other plugins 126 + { 127 + name: 'zod', 128 + responses: true, // [!code ++] 129 + }, 130 + ], 131 + }; 132 + ``` 133 + 134 + ```ts [output] 101 135 const zResponse = z.union([ 102 136 z.object({ 103 137 foo: z.string().optional(), ··· 107 141 }), 108 142 ]); 109 143 ``` 144 + 145 + ::: 110 146 111 147 You can customize the naming and casing pattern for `responses` schemas using the `.name` and `.case` options. 112 148 ··· 114 150 115 151 A Zod schema is generated for every reusable definition from your input. 116 152 117 - ```ts 153 + ::: code-group 154 + 155 + ```js [config] 156 + export default { 157 + input: 'https://get.heyapi.dev/hey-api/backend', 158 + output: 'src/client', 159 + plugins: [ 160 + // ...other plugins 161 + { 162 + name: 'zod', 163 + definitions: true, // [!code ++] 164 + }, 165 + ], 166 + }; 167 + ``` 168 + 169 + ```ts [output] 118 170 const zFoo = z.number().int(); 119 171 120 172 const zBar = z.object({ 121 173 bar: z.array(z.number().int()).optional(), 122 174 }); 123 175 ``` 176 + 177 + ::: 124 178 125 179 You can customize the naming and casing pattern for `definitions` schemas using the `.name` and `.case` options. 126 180 ··· 128 182 129 183 It's often useful to associate a schema with some additional [metadata](https://zod.dev/metadata) for documentation, code generation, AI structured outputs, form validation, and other purposes. If this is your use case, you can set `metadata` to `true` to generate additional metadata about schemas. 130 184 131 - ```js 185 + ::: code-group 186 + 187 + ```js [config] 132 188 export default { 133 189 input: 'https://get.heyapi.dev/hey-api/backend', 134 190 output: 'src/client', 135 191 plugins: [ 136 192 // ...other plugins 137 193 { 194 + name: 'zod', 138 195 metadata: true, // [!code ++] 196 + }, 197 + ], 198 + }; 199 + ``` 200 + 201 + ```ts [output] 202 + export const zFoo = z.string().describe('Additional metadata'); 203 + ``` 204 + 205 + ::: 206 + 207 + ## Types 208 + 209 + In addition to Zod schemas, you can generate schema-specific types. These can be generated for all schemas or for specific resources. 210 + 211 + ::: code-group 212 + 213 + ```js [config] 214 + export default { 215 + input: 'https://get.heyapi.dev/hey-api/backend', 216 + output: 'src/client', 217 + plugins: [ 218 + // ...other plugins 219 + { 139 220 name: 'zod', 221 + types: { 222 + infer: false, // by default, no `z.infer` types [!code ++] 223 + }, 224 + responses: { 225 + types: { 226 + infer: true, // `z.infer` types only for response schemas [!code ++] 227 + }, 228 + }, 140 229 }, 141 230 ], 142 231 }; 143 232 ``` 233 + 234 + ```ts [output] 235 + export type ResponseZodType = z.infer<typeof zResponse>; 236 + ``` 237 + 238 + ::: 239 + 240 + You can customize the naming and casing pattern for schema-specific `types` using the `.name` and `.case` options. 144 241 145 242 ## Config API 146 243
+18
packages/openapi-ts-tests/test/3.1.x.test.ts
··· 784 784 }, 785 785 { 786 786 config: createConfig({ 787 + input: 'validators.yaml', 788 + output: 'validators-types', 789 + plugins: [ 790 + { 791 + name: 'valibot', 792 + }, 793 + { 794 + name: 'zod', 795 + types: { 796 + infer: true, 797 + }, 798 + }, 799 + ], 800 + }), 801 + description: 'generates validator schemas with types', 802 + }, 803 + { 804 + config: createConfig({ 787 805 input: 'validators-bigint-min-max.json', 788 806 output: 'validators-bigint-min-max', 789 807 plugins: ['valibot', 'zod'],
+64
packages/openapi-ts-tests/test/__snapshots__/3.1.x/validators-types/valibot.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + import * as v from 'valibot'; 4 + 5 + /** 6 + * This is Bar schema. 7 + */ 8 + export const vBar: v.GenericSchema = v.object({ 9 + foo: v.optional(v.lazy(() => { 10 + return vFoo; 11 + })) 12 + }); 13 + 14 + /** 15 + * This is Foo schema. 16 + */ 17 + export const vFoo: v.GenericSchema = v.optional(v.union([ 18 + v.object({ 19 + foo: v.optional(v.pipe(v.string(), v.regex(/^\d{3}-\d{2}-\d{4}$/))), 20 + bar: v.optional(vBar), 21 + baz: v.optional(v.array(v.lazy(() => { 22 + return vFoo; 23 + }))), 24 + qux: v.optional(v.pipe(v.number(), v.integer(), v.gtValue(0)), 0) 25 + }), 26 + v.null() 27 + ]), null); 28 + 29 + export const vBaz = v.optional(v.pipe(v.pipe(v.string(), v.regex(/foo\nbar/)), v.readonly()), 'baz'); 30 + 31 + export const vQux = v.record(v.string(), v.object({ 32 + qux: v.optional(v.string()) 33 + })); 34 + 35 + /** 36 + * This is Foo parameter. 37 + */ 38 + export const vFoo2 = v.string(); 39 + 40 + export const vFoo3 = v.object({ 41 + foo: v.optional(vBar) 42 + }); 43 + 44 + export const vPatchFooData = v.object({ 45 + body: v.object({ 46 + foo: v.optional(v.string()) 47 + }), 48 + path: v.optional(v.never()), 49 + query: v.optional(v.object({ 50 + foo: v.optional(v.string()), 51 + bar: v.optional(vBar), 52 + baz: v.optional(v.object({ 53 + baz: v.optional(v.string()) 54 + })), 55 + qux: v.optional(v.pipe(v.string(), v.isoDate())), 56 + quux: v.optional(v.pipe(v.string(), v.isoTimestamp())) 57 + })) 58 + }); 59 + 60 + export const vPostFooData = v.object({ 61 + body: vFoo3, 62 + path: v.optional(v.never()), 63 + query: v.optional(v.never()) 64 + });
+80
packages/openapi-ts-tests/test/__snapshots__/3.1.x/validators-types/zod.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + import { z } from 'zod'; 4 + 5 + /** 6 + * This is Bar schema. 7 + */ 8 + export const zBar: z.AnyZodObject = z.object({ 9 + foo: z.lazy(() => { 10 + return zFoo; 11 + }).optional() 12 + }); 13 + 14 + export type BarZodType = z.infer<typeof zBar>; 15 + 16 + /** 17 + * This is Foo schema. 18 + */ 19 + export const zFoo: z.ZodTypeAny = z.union([ 20 + z.object({ 21 + foo: z.string().regex(/^\d{3}-\d{2}-\d{4}$/).optional(), 22 + bar: zBar.optional(), 23 + baz: z.array(z.lazy(() => { 24 + return zFoo; 25 + })).optional(), 26 + qux: z.number().int().gt(0).optional().default(0) 27 + }), 28 + z.null() 29 + ]).default(null); 30 + 31 + export type FooZodType = z.infer<typeof zFoo>; 32 + 33 + export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz'); 34 + 35 + export type BazZodType = z.infer<typeof zBaz>; 36 + 37 + export const zQux = z.record(z.object({ 38 + qux: z.string().optional() 39 + })); 40 + 41 + export type QuxZodType = z.infer<typeof zQux>; 42 + 43 + /** 44 + * This is Foo parameter. 45 + */ 46 + export const zFoo2 = z.string(); 47 + 48 + export type FooZodType2 = z.infer<typeof zFoo2>; 49 + 50 + export const zFoo3 = z.object({ 51 + foo: zBar.optional() 52 + }); 53 + 54 + export type FooZodType3 = z.infer<typeof zFoo3>; 55 + 56 + export const zPatchFooData = z.object({ 57 + body: z.object({ 58 + foo: z.string().optional() 59 + }), 60 + path: z.never().optional(), 61 + query: z.object({ 62 + foo: z.string().optional(), 63 + bar: zBar.optional(), 64 + baz: z.object({ 65 + baz: z.string().optional() 66 + }).optional(), 67 + qux: z.string().date().optional(), 68 + quux: z.string().datetime().optional() 69 + }).optional() 70 + }); 71 + 72 + export type PatchFooDataZodType = z.infer<typeof zPatchFooData>; 73 + 74 + export const zPostFooData = z.object({ 75 + body: zFoo3, 76 + path: z.never().optional(), 77 + query: z.never().optional() 78 + }); 79 + 80 + export type PostFooDataZodType = z.infer<typeof zPostFooData>;
+35 -19
packages/openapi-ts-tests/test/openapi-ts.config.ts
··· 43 43 '3.1.x', 44 44 // 'case.yaml', 45 45 // 'enum-inline.yaml', 46 - 'full.yaml', 46 + // 'full.yaml', 47 47 // 'object-property-names.yaml', 48 48 // 'transformers-all-of.yaml', 49 - // 'validators-circular-ref-2.yaml', 49 + 'validators.yaml', 50 50 ), 51 51 // path: path.resolve(__dirname, 'spec', 'v3-transforms.json'), 52 52 // path: path.resolve(__dirname, 'spec', 'v3.json'), ··· 174 174 // responseStyle: 'data', 175 175 // transformer: '@hey-api/transformers', 176 176 // transformer: true, 177 - // validator: { 178 - // request: 'zod', 179 - // response: 'valibot', 180 - // }, 177 + validator: { 178 + request: 'zod', 179 + response: 'zod', 180 + }, 181 181 }, 182 182 { 183 183 // bigInt: true, ··· 227 227 { 228 228 // case: 'snake_case', 229 229 // comments: false, 230 - // dates: { 231 - // // offset: false, 232 - // }, 233 - definitions: 'z{{name}}Definition', 234 - // exportFromIndex: true, 235 - // metadata: true, 236 - // name: 'zod', 237 - requests: { 238 - // case: 'SCREAMING_SNAKE_CASE', 239 - // name: 'z{{name}}TestData', 230 + dates: { 231 + offset: true, 232 + }, 233 + definitions: { 234 + name: 'z{{name}}Definition', 235 + // types: { 236 + // infer: 'D{{name}}ZodType', 237 + // }, 240 238 }, 241 - responses: { 242 - // case: 'snake_case', 243 - // name: 'z{{name}}TestResponse', 239 + // exportFromIndex: true, 240 + metadata: true, 241 + name: 'zod', 242 + // requests: { 243 + // // case: 'SCREAMING_SNAKE_CASE', 244 + // // name: 'z{{name}}TestData', 245 + // types: { 246 + // infer: 'E{{name}}DataZodType', 247 + // }, 248 + // }, 249 + // responses: { 250 + // // case: 'snake_case', 251 + // // name: 'z{{name}}TestResponse', 252 + // types: { 253 + // infer: 'F{{name}}ResponseZodType', 254 + // }, 255 + // }, 256 + types: { 257 + infer: { 258 + case: 'snake_case', 259 + }, 244 260 }, 245 261 }, 246 262 {
+2 -6
packages/openapi-ts/src/compiler/types.ts
··· 904 904 export const createTypeOfExpression = ({ 905 905 text, 906 906 }: { 907 - text: string | ts.TypeReferenceNode; 907 + text: string | ts.Identifier; 908 908 }) => { 909 909 const expression = ts.factory.createTypeOfExpression( 910 - // TODO: this crashes when passing reference, fix 911 - // TODO: https://github.com/hey-api/openapi-ts/issues/2289 912 - typeof text === 'string' 913 - ? createIdentifier({ text }) 914 - : (text as unknown as ts.Identifier), 910 + typeof text === 'string' ? createIdentifier({ text }) : text, 915 911 ); 916 912 return expression; 917 913 };
+27 -5
packages/openapi-ts/src/config/utils.ts
··· 3 3 ? Record<string, any> 4 4 : Extract<T, Record<string, any>>; 5 5 6 + type NotArray<T> = T extends any[] ? never : T; 7 + type NotFunction<T> = T extends (...args: any[]) => any ? never : T; 8 + type PlainObject<T> = T extends object 9 + ? NotFunction<T> extends never 10 + ? never 11 + : NotArray<T> extends never 12 + ? never 13 + : T 14 + : never; 15 + 6 16 type MappersType<T> = { 7 17 boolean: T extends boolean 8 18 ? (value: boolean) => Partial<ObjectType<T>> ··· 11 21 ? (value: (...args: any[]) => any) => Partial<ObjectType<T>> 12 22 : never; 13 23 number: T extends number ? (value: number) => Partial<ObjectType<T>> : never; 14 - object?: (value: Partial<ObjectType<T>>) => Partial<ObjectType<T>>; 24 + object?: PlainObject<T> extends never 25 + ? never 26 + : ( 27 + value: Partial<PlainObject<T>>, 28 + defaultValue: PlainObject<T>, 29 + ) => Partial<ObjectType<T>>; 15 30 string: T extends string ? (value: string) => Partial<ObjectType<T>> : never; 16 31 } extends infer U 17 32 ? { [K in keyof U as U[K] extends never ? never : K]: U[K] } ··· 45 60 : { 46 61 mappers: MappersType<T>; 47 62 }), 48 - ) => ObjectType<T>; 63 + ) => PlainObject<T>; 64 + 65 + const isPlainObject = (value: unknown): value is Record<string, any> => 66 + typeof value === 'object' && 67 + value !== null && 68 + !Array.isArray(value) && 69 + typeof value !== 'function'; 49 70 50 71 const mergeResult = <T>( 51 72 result: ObjectType<T>, ··· 96 117 } 97 118 break; 98 119 case 'object': 99 - if (value !== null) { 120 + if (isPlainObject(value)) { 100 121 if ( 101 122 mappers && 102 123 'object' in mappers && ··· 104 125 ) { 105 126 const mapper = mappers.object as ( 106 127 value: Record<string, any>, 128 + defaultValue: ObjectType<any>, 107 129 ) => Partial<ObjectType<any>>; 108 - result = mergeResult(result, mapper(value)); 130 + result = mergeResult(result, mapper(value, defaultValue)); 109 131 } else { 110 132 result = mergeResult(result, value); 111 133 } ··· 113 135 break; 114 136 } 115 137 116 - return result; 138 + return result as any; 117 139 };
+52 -1
packages/openapi-ts/src/generate/file/index.ts
··· 20 20 Identifiers, 21 21 Namespace, 22 22 NodeInfo, 23 + NodeReference, 23 24 } from './types'; 24 - 25 25 export class GeneratedFile { 26 26 private _case: StringCase | undefined; 27 27 /** ··· 52 52 * ``` 53 53 */ 54 54 private names: Record<string, string> = {}; 55 + /** 56 + * Another approach for named nodes, with proper support for renaming. Keys 57 + * are node IDs and values are an array of references for given ID. 58 + */ 59 + private nodeReferences: Record<string, Array<NodeReference>> = {}; 55 60 /** 56 61 * Text value from node is kept in sync with `names`. 57 62 * 63 + * @deprecated 58 64 * @example 59 65 * ```js 60 66 * { ··· 67 73 * } 68 74 * ``` 69 75 */ 76 + // TODO: nodes can be possibly replaced with `nodeReferences`, i.e. keep 77 + // the name `nodes` and rewrite their functionality 70 78 private nodes: Record<string, NodeInfo> = {}; 71 79 72 80 /** ··· 117 125 } 118 126 119 127 /** 128 + * Adds a reference node for a name. This can be used later to rename 129 + * identifiers. 130 + */ 131 + public addNodeReference<T>( 132 + id: string, 133 + node: Pick<NodeReference<T>, 'factory'>, 134 + ): T { 135 + if (!this.nodeReferences[id]) { 136 + this.nodeReferences[id] = []; 137 + } 138 + const result = node.factory(this.names[id] ?? ''); 139 + this.nodeReferences[id].push({ 140 + factory: node.factory, 141 + node: result as void, 142 + }); 143 + return result; 144 + } 145 + 146 + /** 120 147 * Prevents a specific identifier from being created. This is useful for 121 148 * transformers where we know a certain transformer won't be needed, and 122 149 * we want to avoid attempting to create since we know it won't happen. ··· 165 192 /** 166 193 * Returns a node. If node doesn't exist, creates a blank reference. 167 194 * 195 + * @deprecated 168 196 * @param id Node ID. 169 197 * @returns Information about the node. 170 198 */ ··· 376 404 /** 377 405 * Inserts or updates a node. 378 406 * 407 + * @deprecated 379 408 * @param id Node ID. 380 409 * @param args Information about the node. 381 410 * @returns Updated node. ··· 403 432 this.nodes[id].exported = args.exported; 404 433 } 405 434 return this.nodes[id]; 435 + } 436 + 437 + /** 438 + * Updates collected reference nodes for a name with the latest value. 439 + * 440 + * @param id Node ID. 441 + * @param name Updated name for the nodes. 442 + * @returns noop 443 + */ 444 + public updateNodeReferences(id: string, name: string): void { 445 + if (!this.nodeReferences[id]) { 446 + return; 447 + } 448 + const finalName = getUniqueComponentName({ 449 + base: ensureValidIdentifier(name), 450 + components: Object.values(this.names), 451 + }); 452 + this.names[id] = finalName; 453 + for (const node of this.nodeReferences[id]) { 454 + const nextNode = node.factory(finalName); 455 + Object.assign(node.node as unknown as object, nextNode); 456 + } 406 457 } 407 458 408 459 public write(separator = '\n', tsConfig: ts.ParsedCommandLine | null = null) {
+14
packages/openapi-ts/src/generate/file/types.d.ts
··· 75 75 */ 76 76 node: ts.TypeReferenceNode; 77 77 }; 78 + 79 + export type NodeReference<T = void> = { 80 + /** 81 + * Factory function that creates the node reference. 82 + * 83 + * @param name Identifier name. 84 + * @returns Reference to the node object. 85 + */ 86 + factory: (name: string) => T; 87 + /** 88 + * Reference to the node object. 89 + */ 90 + node: T; 91 + };
+2 -1
packages/openapi-ts/src/openApi/shared/utils/name.ts
··· 14 14 if (typeof config.name === 'function') { 15 15 name = config.name(name); 16 16 } else if (config.name) { 17 - name = config.name.replace('{{name}}', name); 17 + const separator = config.case === 'preserve' ? '' : '-'; 18 + name = config.name.replace('{{name}}', `${separator}${name}${separator}`); 18 19 } 19 20 20 21 return stringCase({ case: config.case, value: name });
-1
packages/openapi-ts/src/plugins/@hey-api/transformers/index.ts
··· 1 - export { compiler } from '../../../compiler'; 2 1 export { defaultConfig, defineConfig } from './config'; 3 2 export type { HeyApiTransformersPlugin } from './types';
+273
packages/openapi-ts/src/plugins/@hey-api/typescript/operation.ts
··· 1 + import ts from 'typescript'; 2 + 3 + import { compiler } from '../../../compiler'; 4 + import { operationResponsesMap } from '../../../ir/operation'; 5 + import { deduplicateSchema } from '../../../ir/schema'; 6 + import type { IR } from '../../../ir/types'; 7 + import { buildName } from '../../../openApi/shared/utils/name'; 8 + import { schemaToType } from './plugin'; 9 + import { typesId } from './ref'; 10 + import type { HeyApiTypeScriptPlugin } from './types'; 11 + 12 + const irParametersToIrSchema = ({ 13 + parameters, 14 + }: { 15 + parameters: Record<string, IR.ParameterObject>; 16 + }): IR.SchemaObject => { 17 + const irSchema: IR.SchemaObject = { 18 + type: 'object', 19 + }; 20 + 21 + if (parameters) { 22 + const properties: Record<string, IR.SchemaObject> = {}; 23 + const required: Array<string> = []; 24 + 25 + for (const key in parameters) { 26 + const parameter = parameters[key]!; 27 + 28 + properties[parameter.name] = deduplicateSchema({ 29 + detectFormat: false, 30 + schema: parameter.schema, 31 + }); 32 + 33 + if (parameter.required) { 34 + required.push(parameter.name); 35 + } 36 + } 37 + 38 + irSchema.properties = properties; 39 + 40 + if (required.length) { 41 + irSchema.required = required; 42 + } 43 + } 44 + 45 + return irSchema; 46 + }; 47 + 48 + const operationToDataType = ({ 49 + operation, 50 + plugin, 51 + }: { 52 + operation: IR.OperationObject; 53 + plugin: HeyApiTypeScriptPlugin['Instance']; 54 + }) => { 55 + const file = plugin.context.file({ id: typesId })!; 56 + const data: IR.SchemaObject = { 57 + type: 'object', 58 + }; 59 + const dataRequired: Array<string> = []; 60 + 61 + if (!data.properties) { 62 + data.properties = {}; 63 + } 64 + 65 + if (operation.body) { 66 + data.properties.body = operation.body.schema; 67 + 68 + if (operation.body.required) { 69 + dataRequired.push('body'); 70 + } 71 + } else { 72 + data.properties.body = { 73 + type: 'never', 74 + }; 75 + } 76 + 77 + // TODO: parser - handle cookie parameters 78 + 79 + // do not set headers to never so we can always pass arbitrary values 80 + if (operation.parameters?.header) { 81 + data.properties.headers = irParametersToIrSchema({ 82 + parameters: operation.parameters.header, 83 + }); 84 + 85 + if (data.properties.headers.required) { 86 + dataRequired.push('headers'); 87 + } 88 + } 89 + 90 + if (operation.parameters?.path) { 91 + data.properties.path = irParametersToIrSchema({ 92 + parameters: operation.parameters.path, 93 + }); 94 + 95 + if (data.properties.path.required) { 96 + dataRequired.push('path'); 97 + } 98 + } else { 99 + data.properties.path = { 100 + type: 'never', 101 + }; 102 + } 103 + 104 + if (operation.parameters?.query) { 105 + data.properties.query = irParametersToIrSchema({ 106 + parameters: operation.parameters.query, 107 + }); 108 + 109 + if (data.properties.query.required) { 110 + dataRequired.push('query'); 111 + } 112 + } else { 113 + data.properties.query = { 114 + type: 'never', 115 + }; 116 + } 117 + 118 + data.properties.url = { 119 + const: operation.path, 120 + type: 'string', 121 + }; 122 + dataRequired.push('url'); 123 + 124 + data.required = dataRequired; 125 + 126 + const name = buildName({ 127 + config: plugin.config.requests, 128 + name: operation.id, 129 + }); 130 + const nodeInfo = file.updateNode( 131 + plugin.api.getId({ operation, type: 'data' }), 132 + { 133 + exported: true, 134 + name, 135 + }, 136 + ); 137 + const type = schemaToType({ 138 + onRef: undefined, 139 + plugin, 140 + schema: data, 141 + }); 142 + const node = compiler.typeAliasDeclaration({ 143 + exportType: nodeInfo.exported, 144 + name: nodeInfo.node, 145 + type, 146 + }); 147 + file.add(node); 148 + }; 149 + 150 + export const operationToType = ({ 151 + operation, 152 + plugin, 153 + }: { 154 + operation: IR.OperationObject; 155 + plugin: HeyApiTypeScriptPlugin['Instance']; 156 + }) => { 157 + operationToDataType({ operation, plugin }); 158 + 159 + const file = plugin.context.file({ id: typesId })!; 160 + 161 + const { error, errors, response, responses } = 162 + operationResponsesMap(operation); 163 + 164 + if (errors) { 165 + const name = buildName({ 166 + config: plugin.config.errors, 167 + name: operation.id, 168 + }); 169 + const nodeInfo = file.updateNode( 170 + plugin.api.getId({ operation, type: 'errors' }), 171 + { 172 + exported: true, 173 + name, 174 + }, 175 + ); 176 + const type = schemaToType({ 177 + onRef: undefined, 178 + plugin, 179 + schema: errors, 180 + }); 181 + const node = compiler.typeAliasDeclaration({ 182 + exportType: nodeInfo.exported, 183 + name: nodeInfo.node, 184 + type, 185 + }); 186 + file.add(node); 187 + 188 + if (error) { 189 + const name = buildName({ 190 + config: { 191 + case: plugin.config.errors.case, 192 + name: plugin.config.errors.error, 193 + }, 194 + name: operation.id, 195 + }); 196 + const errorNodeInfo = file.updateNode( 197 + plugin.api.getId({ operation, type: 'error' }), 198 + { 199 + exported: true, 200 + name, 201 + }, 202 + ); 203 + const type = compiler.indexedAccessTypeNode({ 204 + indexType: ts.factory.createTypeOperatorNode( 205 + ts.SyntaxKind.KeyOfKeyword, 206 + nodeInfo.node, 207 + ), 208 + objectType: nodeInfo.node, 209 + }); 210 + const node = compiler.typeAliasDeclaration({ 211 + exportType: errorNodeInfo.exported, 212 + name: errorNodeInfo.node, 213 + type, 214 + }); 215 + file.add(node); 216 + } 217 + } 218 + 219 + if (responses) { 220 + const name = buildName({ 221 + config: plugin.config.responses, 222 + name: operation.id, 223 + }); 224 + const nodeInfo = file.updateNode( 225 + plugin.api.getId({ operation, type: 'responses' }), 226 + { 227 + exported: true, 228 + name, 229 + }, 230 + ); 231 + const type = schemaToType({ 232 + onRef: undefined, 233 + plugin, 234 + schema: responses, 235 + }); 236 + const node = compiler.typeAliasDeclaration({ 237 + exportType: nodeInfo.exported, 238 + name: nodeInfo.node, 239 + type, 240 + }); 241 + file.add(node); 242 + 243 + if (response) { 244 + const name = buildName({ 245 + config: { 246 + case: plugin.config.responses.case, 247 + name: plugin.config.responses.response, 248 + }, 249 + name: operation.id, 250 + }); 251 + const responseNodeInfo = file.updateNode( 252 + plugin.api.getId({ operation, type: 'response' }), 253 + { 254 + exported: true, 255 + name, 256 + }, 257 + ); 258 + const type = compiler.indexedAccessTypeNode({ 259 + indexType: ts.factory.createTypeOperatorNode( 260 + ts.SyntaxKind.KeyOfKeyword, 261 + nodeInfo.node, 262 + ), 263 + objectType: nodeInfo.node, 264 + }); 265 + const node = compiler.typeAliasDeclaration({ 266 + exportType: responseNodeInfo.exported, 267 + name: responseNodeInfo.node, 268 + type, 269 + }); 270 + file.add(node); 271 + } 272 + } 273 + };
+1 -264
packages/openapi-ts/src/plugins/@hey-api/typescript/plugin.ts
··· 2 2 3 3 import type { Property } from '../../../compiler'; 4 4 import { compiler } from '../../../compiler'; 5 - import { operationResponsesMap } from '../../../ir/operation'; 6 5 import { deduplicateSchema } from '../../../ir/schema'; 7 6 import type { IR } from '../../../ir/types'; 8 7 import { buildName } from '../../../openApi/shared/utils/name'; ··· 12 11 import { fieldName } from '../../shared/utils/case'; 13 12 import { createSchemaComment } from '../../shared/utils/schema'; 14 13 import { createClientOptions } from './clientOptions'; 14 + import { operationToType } from './operation'; 15 15 import { typesId } from './ref'; 16 16 import type { HeyApiTypeScriptPlugin } from './types'; 17 17 ··· 424 424 return compiler.keywordTypeNode({ 425 425 keyword: 'void', 426 426 }); 427 - } 428 - }; 429 - 430 - const irParametersToIrSchema = ({ 431 - parameters, 432 - }: { 433 - parameters: Record<string, IR.ParameterObject>; 434 - }): IR.SchemaObject => { 435 - const irSchema: IR.SchemaObject = { 436 - type: 'object', 437 - }; 438 - 439 - if (parameters) { 440 - const properties: Record<string, IR.SchemaObject> = {}; 441 - const required: Array<string> = []; 442 - 443 - for (const key in parameters) { 444 - const parameter = parameters[key]!; 445 - 446 - properties[parameter.name] = deduplicateSchema({ 447 - detectFormat: false, 448 - schema: parameter.schema, 449 - }); 450 - 451 - if (parameter.required) { 452 - required.push(parameter.name); 453 - } 454 - } 455 - 456 - irSchema.properties = properties; 457 - 458 - if (required.length) { 459 - irSchema.required = required; 460 - } 461 - } 462 - 463 - return irSchema; 464 - }; 465 - 466 - const operationToDataType = ({ 467 - operation, 468 - plugin, 469 - }: { 470 - operation: IR.OperationObject; 471 - plugin: HeyApiTypeScriptPlugin['Instance']; 472 - }) => { 473 - const file = plugin.context.file({ id: typesId })!; 474 - const data: IR.SchemaObject = { 475 - type: 'object', 476 - }; 477 - const dataRequired: Array<string> = []; 478 - 479 - if (!data.properties) { 480 - data.properties = {}; 481 - } 482 - 483 - if (operation.body) { 484 - data.properties.body = operation.body.schema; 485 - 486 - if (operation.body.required) { 487 - dataRequired.push('body'); 488 - } 489 - } else { 490 - data.properties.body = { 491 - type: 'never', 492 - }; 493 - } 494 - 495 - // TODO: parser - handle cookie parameters 496 - 497 - // do not set headers to never so we can always pass arbitrary values 498 - if (operation.parameters?.header) { 499 - data.properties.headers = irParametersToIrSchema({ 500 - parameters: operation.parameters.header, 501 - }); 502 - 503 - if (data.properties.headers.required) { 504 - dataRequired.push('headers'); 505 - } 506 - } 507 - 508 - if (operation.parameters?.path) { 509 - data.properties.path = irParametersToIrSchema({ 510 - parameters: operation.parameters.path, 511 - }); 512 - 513 - if (data.properties.path.required) { 514 - dataRequired.push('path'); 515 - } 516 - } else { 517 - data.properties.path = { 518 - type: 'never', 519 - }; 520 - } 521 - 522 - if (operation.parameters?.query) { 523 - data.properties.query = irParametersToIrSchema({ 524 - parameters: operation.parameters.query, 525 - }); 526 - 527 - if (data.properties.query.required) { 528 - dataRequired.push('query'); 529 - } 530 - } else { 531 - data.properties.query = { 532 - type: 'never', 533 - }; 534 - } 535 - 536 - data.properties.url = { 537 - const: operation.path, 538 - type: 'string', 539 - }; 540 - dataRequired.push('url'); 541 - 542 - data.required = dataRequired; 543 - 544 - const name = buildName({ 545 - config: plugin.config.requests, 546 - name: operation.id, 547 - }); 548 - const nodeInfo = file.updateNode( 549 - plugin.api.getId({ operation, type: 'data' }), 550 - { 551 - exported: true, 552 - name, 553 - }, 554 - ); 555 - const type = schemaToType({ 556 - onRef: undefined, 557 - plugin, 558 - schema: data, 559 - }); 560 - const node = compiler.typeAliasDeclaration({ 561 - exportType: nodeInfo.exported, 562 - name: nodeInfo.node, 563 - type, 564 - }); 565 - file.add(node); 566 - }; 567 - 568 - const operationToType = ({ 569 - operation, 570 - plugin, 571 - }: { 572 - operation: IR.OperationObject; 573 - plugin: HeyApiTypeScriptPlugin['Instance']; 574 - }) => { 575 - operationToDataType({ operation, plugin }); 576 - 577 - const file = plugin.context.file({ id: typesId })!; 578 - 579 - const { error, errors, response, responses } = 580 - operationResponsesMap(operation); 581 - 582 - if (errors) { 583 - const name = buildName({ 584 - config: plugin.config.errors, 585 - name: operation.id, 586 - }); 587 - const nodeInfo = file.updateNode( 588 - plugin.api.getId({ operation, type: 'errors' }), 589 - { 590 - exported: true, 591 - name, 592 - }, 593 - ); 594 - const type = schemaToType({ 595 - onRef: undefined, 596 - plugin, 597 - schema: errors, 598 - }); 599 - const node = compiler.typeAliasDeclaration({ 600 - exportType: nodeInfo.exported, 601 - name: nodeInfo.node, 602 - type, 603 - }); 604 - file.add(node); 605 - 606 - if (error) { 607 - const name = buildName({ 608 - config: { 609 - case: plugin.config.errors.case, 610 - name: plugin.config.errors.error, 611 - }, 612 - name: operation.id, 613 - }); 614 - const errorNodeInfo = file.updateNode( 615 - plugin.api.getId({ operation, type: 'error' }), 616 - { 617 - exported: true, 618 - name, 619 - }, 620 - ); 621 - const type = compiler.indexedAccessTypeNode({ 622 - indexType: ts.factory.createTypeOperatorNode( 623 - ts.SyntaxKind.KeyOfKeyword, 624 - nodeInfo.node, 625 - ), 626 - objectType: nodeInfo.node, 627 - }); 628 - const node = compiler.typeAliasDeclaration({ 629 - exportType: errorNodeInfo.exported, 630 - name: errorNodeInfo.node, 631 - type, 632 - }); 633 - file.add(node); 634 - } 635 - } 636 - 637 - if (responses) { 638 - const name = buildName({ 639 - config: plugin.config.responses, 640 - name: operation.id, 641 - }); 642 - const nodeInfo = file.updateNode( 643 - plugin.api.getId({ operation, type: 'responses' }), 644 - { 645 - exported: true, 646 - name, 647 - }, 648 - ); 649 - const type = schemaToType({ 650 - onRef: undefined, 651 - plugin, 652 - schema: responses, 653 - }); 654 - const node = compiler.typeAliasDeclaration({ 655 - exportType: nodeInfo.exported, 656 - name: nodeInfo.node, 657 - type, 658 - }); 659 - file.add(node); 660 - 661 - if (response) { 662 - const name = buildName({ 663 - config: { 664 - case: plugin.config.responses.case, 665 - name: plugin.config.responses.response, 666 - }, 667 - name: operation.id, 668 - }); 669 - const responseNodeInfo = file.updateNode( 670 - plugin.api.getId({ operation, type: 'response' }), 671 - { 672 - exported: true, 673 - name, 674 - }, 675 - ); 676 - const type = compiler.indexedAccessTypeNode({ 677 - indexType: ts.factory.createTypeOperatorNode( 678 - ts.SyntaxKind.KeyOfKeyword, 679 - nodeInfo.node, 680 - ), 681 - objectType: nodeInfo.node, 682 - }); 683 - const node = compiler.typeAliasDeclaration({ 684 - exportType: responseNodeInfo.exported, 685 - name: responseNodeInfo.node, 686 - type, 687 - }); 688 - file.add(node); 689 - } 690 427 } 691 428 }; 692 429
+9
packages/openapi-ts/src/plugins/shared/utils/config.ts
··· 19 19 ...userConfig, 20 20 }, 21 21 }); 22 + 23 + /** 24 + * Reusable mappers for `enabled` and `name` fields. 25 + */ 26 + export const mappers = { 27 + boolean: (enabled: boolean) => ({ enabled }), 28 + function: (name: (...args: any[]) => any) => ({ name }), 29 + string: (name: string) => ({ name }), 30 + } as const;
+38 -28
packages/openapi-ts/src/plugins/zod/api.ts
··· 15 15 operation: IR.OperationObject; 16 16 plugin: ZodPlugin['Instance']; 17 17 }): ts.ArrowFunction | undefined => { 18 - const { requests } = plugin.config; 19 - const schemaIdentifier = plugin.context.file({ id: zodId })!.identifier({ 20 - // TODO: refactor for better cross-plugin compatibility 21 - $ref: `#/zod-data/${operation.id}`, 22 - // TODO: refactor to not have to define nameTransformer 23 - nameTransformer: typeof requests === 'object' ? requests.name : undefined, 24 - namespace: 'value', 25 - }); 26 - 27 - if (!schemaIdentifier.name) { 28 - return; 29 - } 18 + const zodFile = plugin.context.file({ id: zodId })!; 19 + const name = zodFile.getName(plugin.api.getId({ operation, type: 'data' })); 20 + if (!name) return; 30 21 31 22 file.import({ 32 23 module: file.relativePathToFile({ 33 24 context: plugin.context, 34 25 id: zodId, 35 26 }), 36 - name: schemaIdentifier.name, 27 + name, 37 28 }); 38 29 39 30 const dataParameterName = 'data'; ··· 50 41 expression: compiler.awaitExpression({ 51 42 expression: compiler.callExpression({ 52 43 functionName: compiler.propertyAccessExpression({ 53 - expression: compiler.identifier({ text: schemaIdentifier.name }), 44 + expression: compiler.identifier({ text: name }), 54 45 name: identifiers.parseAsync, 55 46 }), 56 47 parameters: [compiler.identifier({ text: dataParameterName })], ··· 70 61 operation: IR.OperationObject; 71 62 plugin: ZodPlugin['Instance']; 72 63 }): ts.ArrowFunction | undefined => { 73 - const { responses } = plugin.config; 74 - const schemaIdentifier = plugin.context.file({ id: zodId })!.identifier({ 75 - // TODO: refactor for better cross-plugin compatibility 76 - $ref: `#/zod-response/${operation.id}`, 77 - // TODO: refactor to not have to define nameTransformer 78 - nameTransformer: typeof responses === 'object' ? responses.name : undefined, 79 - namespace: 'value', 80 - }); 81 - 82 - if (!schemaIdentifier.name) { 83 - return; 84 - } 64 + const zodFile = plugin.context.file({ id: zodId })!; 65 + const name = zodFile.getName( 66 + plugin.api.getId({ operation, type: 'responses' }), 67 + ); 68 + if (!name) return; 85 69 86 70 file.import({ 87 71 module: file.relativePathToFile({ 88 72 context: plugin.context, 89 73 id: zodId, 90 74 }), 91 - name: schemaIdentifier.name, 75 + name, 92 76 }); 93 77 94 78 const dataParameterName = 'data'; ··· 105 89 expression: compiler.awaitExpression({ 106 90 expression: compiler.callExpression({ 107 91 functionName: compiler.propertyAccessExpression({ 108 - expression: compiler.identifier({ text: schemaIdentifier.name }), 92 + expression: compiler.identifier({ text: name }), 109 93 name: identifiers.parseAsync, 110 94 }), 111 95 parameters: [compiler.identifier({ text: dataParameterName })], ··· 116 100 }); 117 101 }; 118 102 103 + type GetIdArgs = 104 + | { 105 + operation: IR.OperationObject; 106 + type: 'data' | 'responses' | 'type-infer-data' | 'type-infer-responses'; 107 + } 108 + | { 109 + type: 'ref' | 'type-infer-ref'; 110 + value: string; 111 + }; 112 + 113 + const getId = (args: GetIdArgs): string => { 114 + switch (args.type) { 115 + case 'data': 116 + case 'responses': 117 + case 'type-infer-data': 118 + case 'type-infer-responses': 119 + return `${args.operation.id}-${args.type}`; 120 + case 'ref': 121 + case 'type-infer-ref': 122 + default: 123 + return `${args.type}-${args.value}`; 124 + } 125 + }; 126 + 119 127 export type Api = { 120 128 createRequestValidator: (args: { 121 129 file: GeneratedFile; ··· 127 135 operation: IR.OperationObject; 128 136 plugin: ZodPlugin['Instance']; 129 137 }) => ts.ArrowFunction | undefined; 138 + getId: (args: GetIdArgs) => string; 130 139 }; 131 140 132 141 export const api: Api = { 133 142 createRequestValidator, 134 143 createResponseValidator, 144 + getId, 135 145 };
+161 -10
packages/openapi-ts/src/plugins/zod/config.ts
··· 1 - import { definePluginConfig } from '../shared/utils/config'; 1 + import { definePluginConfig, mappers } from '../shared/utils/config'; 2 2 import { api } from './api'; 3 3 import { handler } from './plugin'; 4 4 import type { ZodPlugin } from './types'; ··· 22 22 value: plugin.config.dates, 23 23 }); 24 24 25 + plugin.config.types = context.valueToObject({ 26 + defaultValue: { 27 + infer: { 28 + case: 'PascalCase', 29 + enabled: false, 30 + }, 31 + }, 32 + mappers: { 33 + object: (fields, defaultValue) => ({ 34 + ...fields, 35 + infer: context.valueToObject({ 36 + defaultValue: { 37 + ...(defaultValue.infer as Extract< 38 + typeof defaultValue.infer, 39 + Record<string, unknown> 40 + >), 41 + enabled: 42 + fields.infer !== undefined 43 + ? Boolean(fields.infer) 44 + : ( 45 + defaultValue.infer as Extract< 46 + typeof defaultValue.infer, 47 + Record<string, unknown> 48 + > 49 + ).enabled, 50 + }, 51 + mappers, 52 + value: fields.infer, 53 + }), 54 + }), 55 + }, 56 + value: plugin.config.types, 57 + }); 58 + 25 59 plugin.config.definitions = context.valueToObject({ 26 60 defaultValue: { 27 61 case: plugin.config.case ?? 'camelCase', 28 62 enabled: true, 29 63 name: 'z{{name}}', 64 + types: { 65 + ...plugin.config.types, 66 + infer: { 67 + ...(plugin.config.types.infer as Extract< 68 + typeof plugin.config.types.infer, 69 + Record<string, unknown> 70 + >), 71 + name: '{{name}}ZodType', 72 + }, 73 + }, 30 74 }, 31 75 mappers: { 32 - boolean: (enabled) => ({ enabled }), 33 - function: (name) => ({ name }), 34 - string: (name) => ({ name }), 76 + ...mappers, 77 + object: (fields, defaultValue) => ({ 78 + ...fields, 79 + types: context.valueToObject({ 80 + defaultValue: defaultValue.types!, 81 + mappers: { 82 + object: (fields, defaultValue) => ({ 83 + ...fields, 84 + infer: context.valueToObject({ 85 + defaultValue: { 86 + ...(defaultValue.infer as Extract< 87 + typeof defaultValue.infer, 88 + Record<string, unknown> 89 + >), 90 + enabled: 91 + fields.infer !== undefined 92 + ? Boolean(fields.infer) 93 + : ( 94 + defaultValue.infer as Extract< 95 + typeof defaultValue.infer, 96 + Record<string, unknown> 97 + > 98 + ).enabled, 99 + }, 100 + mappers, 101 + value: fields.infer, 102 + }), 103 + }), 104 + }, 105 + value: fields.types, 106 + }), 107 + }), 35 108 }, 36 109 value: plugin.config.definitions, 37 110 }); ··· 41 114 case: plugin.config.case ?? 'camelCase', 42 115 enabled: true, 43 116 name: 'z{{name}}Data', 117 + types: { 118 + ...plugin.config.types, 119 + infer: { 120 + ...(plugin.config.types.infer as Extract< 121 + typeof plugin.config.types.infer, 122 + Record<string, unknown> 123 + >), 124 + name: '{{name}}DataZodType', 125 + }, 126 + }, 44 127 }, 45 128 mappers: { 46 - boolean: (enabled) => ({ enabled }), 47 - function: (name) => ({ name }), 48 - string: (name) => ({ name }), 129 + ...mappers, 130 + object: (fields, defaultValue) => ({ 131 + ...fields, 132 + types: context.valueToObject({ 133 + defaultValue: defaultValue.types!, 134 + mappers: { 135 + object: (fields, defaultValue) => ({ 136 + ...fields, 137 + infer: context.valueToObject({ 138 + defaultValue: { 139 + ...(defaultValue.infer as Extract< 140 + typeof defaultValue.infer, 141 + Record<string, unknown> 142 + >), 143 + enabled: 144 + fields.infer !== undefined 145 + ? Boolean(fields.infer) 146 + : ( 147 + defaultValue.infer as Extract< 148 + typeof defaultValue.infer, 149 + Record<string, unknown> 150 + > 151 + ).enabled, 152 + }, 153 + mappers, 154 + value: fields.infer, 155 + }), 156 + }), 157 + }, 158 + value: fields.types, 159 + }), 160 + }), 49 161 }, 50 162 value: plugin.config.requests, 51 163 }); ··· 55 167 case: plugin.config.case ?? 'camelCase', 56 168 enabled: true, 57 169 name: 'z{{name}}Response', 170 + types: { 171 + ...plugin.config.types, 172 + infer: { 173 + ...(plugin.config.types.infer as Extract< 174 + typeof plugin.config.types.infer, 175 + Record<string, unknown> 176 + >), 177 + name: '{{name}}ResponseZodType', 178 + }, 179 + }, 58 180 }, 59 181 mappers: { 60 - boolean: (enabled) => ({ enabled }), 61 - function: (name) => ({ name }), 62 - string: (name) => ({ name }), 182 + ...mappers, 183 + object: (fields, defaultValue) => ({ 184 + ...fields, 185 + types: context.valueToObject({ 186 + defaultValue: defaultValue.types!, 187 + mappers: { 188 + object: (fields, defaultValue) => ({ 189 + ...fields, 190 + infer: context.valueToObject({ 191 + defaultValue: { 192 + ...(defaultValue.infer as Extract< 193 + typeof defaultValue.infer, 194 + Record<string, unknown> 195 + >), 196 + enabled: 197 + fields.infer !== undefined 198 + ? Boolean(fields.infer) 199 + : ( 200 + defaultValue.infer as Extract< 201 + typeof defaultValue.infer, 202 + Record<string, unknown> 203 + > 204 + ).enabled, 205 + }, 206 + mappers, 207 + value: fields.infer, 208 + }), 209 + }), 210 + }, 211 + value: fields.types, 212 + }), 213 + }), 63 214 }, 64 215 value: plugin.config.responses, 65 216 });
+39
packages/openapi-ts/src/plugins/zod/constants.ts
··· 1 1 import { compiler } from '../../compiler'; 2 2 3 3 export const identifiers = { 4 + and: compiler.identifier({ text: 'and' }), 5 + array: compiler.identifier({ text: 'array' }), 6 + bigint: compiler.identifier({ text: 'bigint' }), 7 + boolean: compiler.identifier({ text: 'boolean' }), 8 + coerce: compiler.identifier({ text: 'coerce' }), 9 + datetime: compiler.identifier({ text: 'datetime' }), 10 + default: compiler.identifier({ text: 'default' }), 11 + describe: compiler.identifier({ text: 'describe' }), 12 + enum: compiler.identifier({ text: 'enum' }), 13 + gt: compiler.identifier({ text: 'gt' }), 14 + gte: compiler.identifier({ text: 'gte' }), 15 + infer: compiler.identifier({ text: 'infer' }), 16 + int: compiler.identifier({ text: 'int' }), 17 + intersection: compiler.identifier({ text: 'intersection' }), 18 + ip: compiler.identifier({ text: 'ip' }), 19 + lazy: compiler.identifier({ text: 'lazy' }), 20 + length: compiler.identifier({ text: 'length' }), 21 + literal: compiler.identifier({ text: 'literal' }), 22 + lt: compiler.identifier({ text: 'lt' }), 23 + lte: compiler.identifier({ text: 'lte' }), 24 + max: compiler.identifier({ text: 'max' }), 25 + min: compiler.identifier({ text: 'min' }), 26 + never: compiler.identifier({ text: 'never' }), 27 + null: compiler.identifier({ text: 'null' }), 28 + nullable: compiler.identifier({ text: 'nullable' }), 29 + number: compiler.identifier({ text: 'number' }), 30 + object: compiler.identifier({ text: 'object' }), 31 + optional: compiler.identifier({ text: 'optional' }), 4 32 parseAsync: compiler.identifier({ text: 'parseAsync' }), 33 + readonly: compiler.identifier({ text: 'readonly' }), 34 + record: compiler.identifier({ text: 'record' }), 35 + regex: compiler.identifier({ text: 'regex' }), 36 + string: compiler.identifier({ text: 'string' }), 37 + tuple: compiler.identifier({ text: 'tuple' }), 38 + undefined: compiler.identifier({ text: 'undefined' }), 39 + union: compiler.identifier({ text: 'union' }), 40 + unknown: compiler.identifier({ text: 'unknown' }), 41 + url: compiler.identifier({ text: 'url' }), 42 + void: compiler.identifier({ text: 'void' }), 43 + z: compiler.identifier({ text: 'z' }), 5 44 }; 6 45 7 46 export const zodId = 'zod';
+67
packages/openapi-ts/src/plugins/zod/export.ts
··· 1 + import type ts from 'typescript'; 2 + 3 + import { compiler } from '../../compiler'; 4 + import type { IR } from '../../ir/types'; 5 + import { createSchemaComment } from '../shared/utils/schema'; 6 + import { identifiers, zodId } from './constants'; 7 + import type { ZodSchema } from './plugin'; 8 + import type { ZodPlugin } from './types'; 9 + 10 + export const exportZodSchema = ({ 11 + plugin, 12 + schema, 13 + schemaId, 14 + typeInferId, 15 + zodSchema, 16 + }: { 17 + plugin: ZodPlugin['Instance']; 18 + schema: IR.SchemaObject; 19 + schemaId: string; 20 + typeInferId: string | undefined; 21 + zodSchema: ZodSchema; 22 + }) => { 23 + const file = plugin.context.file({ id: zodId })!; 24 + const node = file.addNodeReference(schemaId, { 25 + factory: (typeName) => compiler.typeReferenceNode({ typeName }), 26 + }); 27 + const statement = compiler.constVariable({ 28 + comment: plugin.config.comments 29 + ? createSchemaComment({ schema }) 30 + : undefined, 31 + exportConst: true, 32 + expression: zodSchema.expression, 33 + name: node, 34 + typeName: zodSchema.typeName 35 + ? (compiler.propertyAccessExpression({ 36 + expression: identifiers.z, 37 + name: zodSchema.typeName, 38 + }) as unknown as ts.TypeNode) 39 + : undefined, 40 + }); 41 + file.add(statement); 42 + 43 + if (typeInferId) { 44 + const inferNode = file.addNodeReference(typeInferId, { 45 + factory: (typeName) => compiler.typeReferenceNode({ typeName }), 46 + }); 47 + const nodeIdentifier = file.addNodeReference(schemaId, { 48 + factory: (text) => compiler.identifier({ text }), 49 + }); 50 + const inferType = compiler.typeAliasDeclaration({ 51 + exportType: true, 52 + name: inferNode, 53 + type: compiler.typeReferenceNode({ 54 + typeArguments: [ 55 + compiler.typeOfExpression({ 56 + text: nodeIdentifier, 57 + }) as unknown as ts.TypeNode, 58 + ], 59 + typeName: compiler.propertyAccessExpression({ 60 + expression: identifiers.z, 61 + name: identifiers.infer, 62 + }) as unknown as string, 63 + }), 64 + }); 65 + file.add(inferType); 66 + } 67 + };
+63 -26
packages/openapi-ts/src/plugins/zod/operation.ts
··· 1 1 import { operationResponsesMap } from '../../ir/operation'; 2 2 import type { IR } from '../../ir/types'; 3 + import { buildName } from '../../openApi/shared/utils/name'; 3 4 import { zodId } from './constants'; 5 + import { exportZodSchema } from './export'; 4 6 import type { State } from './plugin'; 5 7 import { schemaToZodSchema } from './plugin'; 6 8 import type { ZodPlugin } from './types'; ··· 8 10 export const operationToZodSchema = ({ 9 11 operation, 10 12 plugin, 11 - state, 12 13 }: { 13 14 operation: IR.OperationObject; 14 15 plugin: ZodPlugin['Instance']; 15 - state: State; 16 16 }) => { 17 + const state: State = { 18 + circularReferenceTracker: [], 19 + hasCircularReference: false, 20 + }; 21 + 17 22 const file = plugin.context.file({ id: zodId })!; 18 23 19 24 if (plugin.config.requests.enabled) { ··· 114 119 115 120 schemaData.required = [...requiredProperties]; 116 121 117 - const identifierData = file.identifier({ 118 - // TODO: refactor for better cross-plugin compatibility 119 - $ref: `#/zod-data/${operation.id}`, 120 - case: plugin.config.requests.case, 121 - create: true, 122 - nameTransformer: plugin.config.requests.name, 123 - namespace: 'value', 124 - }); 125 - schemaToZodSchema({ 126 - // TODO: refactor for better cross-plugin compatibility 127 - $ref: `#/zod-data/${operation.id}`, 128 - identifier: identifierData, 122 + const zodSchema = schemaToZodSchema({ 129 123 plugin, 130 124 schema: schemaData, 131 125 state, 132 126 }); 127 + const schemaId = plugin.api.getId({ operation, type: 'data' }); 128 + const typeInferId = plugin.config.requests.types.infer.enabled 129 + ? plugin.api.getId({ operation, type: 'type-infer-data' }) 130 + : undefined; 131 + exportZodSchema({ 132 + plugin, 133 + schema: schemaData, 134 + schemaId, 135 + typeInferId, 136 + zodSchema, 137 + }); 138 + file.updateNodeReferences( 139 + schemaId, 140 + buildName({ 141 + config: plugin.config.requests, 142 + name: operation.id, 143 + }), 144 + ); 145 + if (typeInferId) { 146 + file.updateNodeReferences( 147 + typeInferId, 148 + buildName({ 149 + config: plugin.config.requests.types.infer, 150 + name: operation.id, 151 + }), 152 + ); 153 + } 133 154 } 134 155 135 156 if (plugin.config.responses.enabled) { ··· 137 158 const { response } = operationResponsesMap(operation); 138 159 139 160 if (response) { 140 - const identifierResponse = file.identifier({ 141 - // TODO: refactor for better cross-plugin compatibility 142 - $ref: `#/zod-response/${operation.id}`, 143 - case: plugin.config.responses.case, 144 - create: true, 145 - nameTransformer: plugin.config.responses.name, 146 - namespace: 'value', 147 - }); 148 - schemaToZodSchema({ 149 - // TODO: refactor for better cross-plugin compatibility 150 - $ref: `#/zod-response/${operation.id}`, 151 - identifier: identifierResponse, 161 + const zodSchema = schemaToZodSchema({ 152 162 plugin, 153 163 schema: response, 154 164 state, 155 165 }); 166 + const schemaId = plugin.api.getId({ operation, type: 'responses' }); 167 + const typeInferId = plugin.config.responses.types.infer.enabled 168 + ? plugin.api.getId({ operation, type: 'type-infer-responses' }) 169 + : undefined; 170 + exportZodSchema({ 171 + plugin, 172 + schema: response, 173 + schemaId, 174 + typeInferId, 175 + zodSchema, 176 + }); 177 + file.updateNodeReferences( 178 + schemaId, 179 + buildName({ 180 + config: plugin.config.responses, 181 + name: operation.id, 182 + }), 183 + ); 184 + if (typeInferId) { 185 + file.updateNodeReferences( 186 + typeInferId, 187 + buildName({ 188 + config: plugin.config.responses.types.infer, 189 + name: operation.id, 190 + }), 191 + ); 192 + } 156 193 } 157 194 } 158 195 }
+219 -242
packages/openapi-ts/src/plugins/zod/plugin.ts
··· 1 1 import ts from 'typescript'; 2 2 3 3 import { compiler } from '../../compiler'; 4 - import type { Identifier } from '../../generate/file/types'; 5 4 import { deduplicateSchema } from '../../ir/schema'; 6 5 import type { IR } from '../../ir/types'; 7 - import type { StringCase, StringName } from '../../types/case'; 6 + import { buildName } from '../../openApi/shared/utils/name'; 7 + import { refToName } from '../../utils/ref'; 8 8 import { numberRegExp } from '../../utils/regexp'; 9 - import { createSchemaComment } from '../shared/utils/schema'; 10 - import { zodId } from './constants'; 9 + import { identifiers, zodId } from './constants'; 10 + import { exportZodSchema } from './export'; 11 11 import { operationToZodSchema } from './operation'; 12 12 import type { ZodPlugin } from './types'; 13 13 ··· 16 16 type: Extract<Required<IR.SchemaObject>['type'], T>; 17 17 } 18 18 19 - export interface State { 20 - circularReferenceTracker: Set<string>; 19 + export type State = { 20 + circularReferenceTracker: Array<string>; 21 21 hasCircularReference: boolean; 22 - nameCase: StringCase; 23 - nameTransformer: StringName; 24 - } 22 + }; 25 23 26 - // frequently used identifiers 27 - const andIdentifier = compiler.identifier({ text: 'and' }); 28 - const arrayIdentifier = compiler.identifier({ text: 'array' }); 29 - const coerceIdentifier = compiler.identifier({ text: 'coerce' }); 30 - const defaultIdentifier = compiler.identifier({ text: 'default' }); 31 - const describeIdentifier = compiler.identifier({ text: 'describe' }); 32 - const intersectionIdentifier = compiler.identifier({ text: 'intersection' }); 33 - const lazyIdentifier = compiler.identifier({ text: 'lazy' }); 34 - const lengthIdentifier = compiler.identifier({ text: 'length' }); 35 - const literalIdentifier = compiler.identifier({ text: 'literal' }); 36 - const maxIdentifier = compiler.identifier({ text: 'max' }); 37 - const minIdentifier = compiler.identifier({ text: 'min' }); 38 - const objectIdentifier = compiler.identifier({ text: 'object' }); 39 - const optionalIdentifier = compiler.identifier({ text: 'optional' }); 40 - const readonlyIdentifier = compiler.identifier({ text: 'readonly' }); 41 - const recordIdentifier = compiler.identifier({ text: 'record' }); 42 - const regexIdentifier = compiler.identifier({ text: 'regex' }); 43 - const unionIdentifier = compiler.identifier({ text: 'union' }); 44 - const zIdentifier = compiler.identifier({ text: 'z' }); 24 + export type ZodSchema = { 25 + expression: ts.Expression; 26 + typeName?: string; 27 + }; 45 28 46 29 const arrayTypeToZodSchema = ({ 47 30 plugin, ··· 53 36 state: State; 54 37 }): ts.CallExpression => { 55 38 const functionName = compiler.propertyAccessExpression({ 56 - expression: zIdentifier, 57 - name: arrayIdentifier, 39 + expression: identifiers.z, 40 + name: identifiers.array, 58 41 }); 59 42 60 43 let arrayExpression: ts.CallExpression | undefined; ··· 74 57 schema = deduplicateSchema({ schema }); 75 58 76 59 // at least one item is guaranteed 77 - const itemExpressions = schema.items!.map((item) => 78 - schemaToZodSchema({ 79 - plugin, 80 - schema: item, 81 - state, 82 - }), 60 + const itemExpressions = schema.items!.map( 61 + (item) => 62 + schemaToZodSchema({ 63 + plugin, 64 + schema: item, 65 + state, 66 + }).expression, 83 67 ); 84 68 85 69 if (itemExpressions.length === 1) { ··· 97 81 98 82 arrayExpression = compiler.callExpression({ 99 83 functionName: compiler.propertyAccessExpression({ 100 - expression: zIdentifier, 101 - name: arrayIdentifier, 84 + expression: identifiers.z, 85 + name: identifiers.array, 102 86 }), 103 87 parameters: [ 104 88 compiler.callExpression({ 105 89 functionName: compiler.propertyAccessExpression({ 106 - expression: zIdentifier, 107 - name: unionIdentifier, 90 + expression: identifiers.z, 91 + name: identifiers.union, 108 92 }), 109 93 parameters: [ 110 94 compiler.arrayLiteralExpression({ ··· 121 105 arrayExpression = compiler.callExpression({ 122 106 functionName: compiler.propertyAccessExpression({ 123 107 expression: arrayExpression, 124 - name: lengthIdentifier, 108 + name: identifiers.length, 125 109 }), 126 110 parameters: [compiler.valueToExpression({ value: schema.minItems })], 127 111 }); ··· 130 114 arrayExpression = compiler.callExpression({ 131 115 functionName: compiler.propertyAccessExpression({ 132 116 expression: arrayExpression, 133 - name: minIdentifier, 117 + name: identifiers.min, 134 118 }), 135 119 parameters: [compiler.valueToExpression({ value: schema.minItems })], 136 120 }); ··· 140 124 arrayExpression = compiler.callExpression({ 141 125 functionName: compiler.propertyAccessExpression({ 142 126 expression: arrayExpression, 143 - name: maxIdentifier, 127 + name: identifiers.max, 144 128 }), 145 129 parameters: [compiler.valueToExpression({ value: schema.maxItems })], 146 130 }); ··· 158 142 if (typeof schema.const === 'boolean') { 159 143 const expression = compiler.callExpression({ 160 144 functionName: compiler.propertyAccessExpression({ 161 - expression: zIdentifier, 162 - name: literalIdentifier, 145 + expression: identifiers.z, 146 + name: identifiers.literal, 163 147 }), 164 148 parameters: [compiler.ots.boolean(schema.const)], 165 149 }); ··· 168 152 169 153 const expression = compiler.callExpression({ 170 154 functionName: compiler.propertyAccessExpression({ 171 - expression: zIdentifier, 172 - name: compiler.identifier({ text: 'boolean' }), 155 + expression: identifiers.z, 156 + name: identifiers.boolean, 173 157 }), 174 158 }); 175 159 return expression; ··· 207 191 208 192 let enumExpression = compiler.callExpression({ 209 193 functionName: compiler.propertyAccessExpression({ 210 - expression: zIdentifier, 211 - name: compiler.identifier({ text: 'enum' }), 194 + expression: identifiers.z, 195 + name: identifiers.enum, 212 196 }), 213 197 parameters: [ 214 198 compiler.arrayLiteralExpression({ ··· 222 206 enumExpression = compiler.callExpression({ 223 207 functionName: compiler.propertyAccessExpression({ 224 208 expression: enumExpression, 225 - name: compiler.identifier({ text: 'nullable' }), 209 + name: identifiers.nullable, 226 210 }), 227 211 }); 228 212 } ··· 234 218 const neverTypeToZodSchema = (_props: { schema: SchemaWithType<'never'> }) => { 235 219 const expression = compiler.callExpression({ 236 220 functionName: compiler.propertyAccessExpression({ 237 - expression: zIdentifier, 238 - name: compiler.identifier({ text: 'never' }), 221 + expression: identifiers.z, 222 + name: identifiers.never, 239 223 }), 240 224 }); 241 225 return expression; ··· 245 229 const nullTypeToZodSchema = (_props: { schema: SchemaWithType<'null'> }) => { 246 230 const expression = compiler.callExpression({ 247 231 functionName: compiler.propertyAccessExpression({ 248 - expression: zIdentifier, 249 - name: compiler.identifier({ text: 'null' }), 232 + expression: identifiers.z, 233 + name: identifiers.null, 250 234 }), 251 235 }); 252 236 return expression; ··· 288 272 // TODO: parser - handle bigint constants 289 273 const expression = compiler.callExpression({ 290 274 functionName: compiler.propertyAccessExpression({ 291 - expression: zIdentifier, 292 - name: literalIdentifier, 275 + expression: identifiers.z, 276 + name: identifiers.literal, 293 277 }), 294 278 parameters: [compiler.ots.number(schema.const)], 295 279 }); ··· 300 284 functionName: isBigInt 301 285 ? compiler.propertyAccessExpression({ 302 286 expression: compiler.propertyAccessExpression({ 303 - expression: zIdentifier, 304 - name: coerceIdentifier, 287 + expression: identifiers.z, 288 + name: identifiers.coerce, 305 289 }), 306 - name: compiler.identifier({ text: 'bigint' }), 290 + name: identifiers.bigint, 307 291 }) 308 292 : compiler.propertyAccessExpression({ 309 - expression: zIdentifier, 310 - name: compiler.identifier({ text: 'number' }), 293 + expression: identifiers.z, 294 + name: identifiers.number, 311 295 }), 312 296 }); 313 297 ··· 315 299 numberExpression = compiler.callExpression({ 316 300 functionName: compiler.propertyAccessExpression({ 317 301 expression: numberExpression, 318 - name: compiler.identifier({ text: 'int' }), 302 + name: identifiers.int, 319 303 }), 320 304 }); 321 305 } ··· 324 308 numberExpression = compiler.callExpression({ 325 309 functionName: compiler.propertyAccessExpression({ 326 310 expression: numberExpression, 327 - name: compiler.identifier({ text: 'gt' }), 311 + name: identifiers.gt, 328 312 }), 329 313 parameters: [ 330 314 numberParameter({ isBigInt, value: schema.exclusiveMinimum }), ··· 334 318 numberExpression = compiler.callExpression({ 335 319 functionName: compiler.propertyAccessExpression({ 336 320 expression: numberExpression, 337 - name: compiler.identifier({ text: 'gte' }), 321 + name: identifiers.gte, 338 322 }), 339 323 parameters: [numberParameter({ isBigInt, value: schema.minimum })], 340 324 }); ··· 344 328 numberExpression = compiler.callExpression({ 345 329 functionName: compiler.propertyAccessExpression({ 346 330 expression: numberExpression, 347 - name: compiler.identifier({ text: 'lt' }), 331 + name: identifiers.lt, 348 332 }), 349 333 parameters: [ 350 334 numberParameter({ isBigInt, value: schema.exclusiveMaximum }), ··· 354 338 numberExpression = compiler.callExpression({ 355 339 functionName: compiler.propertyAccessExpression({ 356 340 expression: numberExpression, 357 - name: compiler.identifier({ text: 'lte' }), 341 + name: identifiers.lte, 358 342 }), 359 343 parameters: [numberParameter({ isBigInt, value: schema.maximum })], 360 344 }); ··· 389 373 plugin, 390 374 schema: property, 391 375 state, 392 - }); 376 + }).expression; 393 377 394 378 numberRegExp.lastIndex = 0; 395 379 let propertyName; ··· 427 411 plugin, 428 412 schema: schema.additionalProperties, 429 413 state, 430 - }); 414 + }).expression; 431 415 const expression = compiler.callExpression({ 432 416 functionName: compiler.propertyAccessExpression({ 433 - expression: zIdentifier, 434 - name: recordIdentifier, 417 + expression: identifiers.z, 418 + name: identifiers.record, 435 419 }), 436 420 parameters: [zodSchema], 437 421 }); ··· 443 427 444 428 const expression = compiler.callExpression({ 445 429 functionName: compiler.propertyAccessExpression({ 446 - expression: zIdentifier, 447 - name: objectIdentifier, 430 + expression: identifiers.z, 431 + name: identifiers.object, 448 432 }), 449 433 parameters: [ts.factory.createObjectLiteralExpression(properties, true)], 450 434 }); ··· 464 448 if (typeof schema.const === 'string') { 465 449 const expression = compiler.callExpression({ 466 450 functionName: compiler.propertyAccessExpression({ 467 - expression: zIdentifier, 468 - name: literalIdentifier, 451 + expression: identifiers.z, 452 + name: identifiers.literal, 469 453 }), 470 454 parameters: [compiler.ots.string(schema.const)], 471 455 }); ··· 474 458 475 459 let stringExpression = compiler.callExpression({ 476 460 functionName: compiler.propertyAccessExpression({ 477 - expression: zIdentifier, 478 - name: compiler.identifier({ text: 'string' }), 461 + expression: identifiers.z, 462 + name: identifiers.string, 479 463 }), 480 464 }); 481 465 ··· 485 469 stringExpression = compiler.callExpression({ 486 470 functionName: compiler.propertyAccessExpression({ 487 471 expression: stringExpression, 488 - name: compiler.identifier({ text: 'datetime' }), 472 + name: identifiers.datetime, 489 473 }), 490 474 parameters: plugin.config.dates.offset 491 475 ? [ ··· 506 490 stringExpression = compiler.callExpression({ 507 491 functionName: compiler.propertyAccessExpression({ 508 492 expression: stringExpression, 509 - name: compiler.identifier({ text: 'ip' }), 493 + name: identifiers.ip, 510 494 }), 511 495 }); 512 496 break; ··· 514 498 stringExpression = compiler.callExpression({ 515 499 functionName: compiler.propertyAccessExpression({ 516 500 expression: stringExpression, 517 - name: compiler.identifier({ text: 'url' }), 501 + name: identifiers.url, 518 502 }), 519 503 }); 520 504 break; ··· 536 520 stringExpression = compiler.callExpression({ 537 521 functionName: compiler.propertyAccessExpression({ 538 522 expression: stringExpression, 539 - name: lengthIdentifier, 523 + name: identifiers.length, 540 524 }), 541 525 parameters: [compiler.valueToExpression({ value: schema.minLength })], 542 526 }); ··· 545 529 stringExpression = compiler.callExpression({ 546 530 functionName: compiler.propertyAccessExpression({ 547 531 expression: stringExpression, 548 - name: minIdentifier, 532 + name: identifiers.min, 549 533 }), 550 534 parameters: [compiler.valueToExpression({ value: schema.minLength })], 551 535 }); ··· 555 539 stringExpression = compiler.callExpression({ 556 540 functionName: compiler.propertyAccessExpression({ 557 541 expression: stringExpression, 558 - name: maxIdentifier, 542 + name: identifiers.max, 559 543 }), 560 544 parameters: [compiler.valueToExpression({ value: schema.maxLength })], 561 545 }); ··· 566 550 stringExpression = compiler.callExpression({ 567 551 functionName: compiler.propertyAccessExpression({ 568 552 expression: stringExpression, 569 - name: regexIdentifier, 553 + name: identifiers.regex, 570 554 }), 571 555 parameters: [compiler.regularExpressionLiteral({ text: schema.pattern })], 572 556 }); ··· 588 572 const tupleElements = schema.const.map((value) => 589 573 compiler.callExpression({ 590 574 functionName: compiler.propertyAccessExpression({ 591 - expression: zIdentifier, 592 - name: literalIdentifier, 575 + expression: identifiers.z, 576 + name: identifiers.literal, 593 577 }), 594 578 parameters: [compiler.valueToExpression({ value })], 595 579 }), 596 580 ); 597 581 const expression = compiler.callExpression({ 598 582 functionName: compiler.propertyAccessExpression({ 599 - expression: zIdentifier, 600 - name: compiler.identifier({ text: 'tuple' }), 583 + expression: identifiers.z, 584 + name: identifiers.tuple, 601 585 }), 602 586 parameters: [ 603 587 compiler.arrayLiteralExpression({ ··· 616 600 plugin, 617 601 schema: item, 618 602 state, 619 - }), 603 + }).expression, 620 604 ); 621 605 } 622 606 623 607 const expression = compiler.callExpression({ 624 608 functionName: compiler.propertyAccessExpression({ 625 - expression: zIdentifier, 626 - name: compiler.identifier({ text: 'tuple' }), 609 + expression: identifiers.z, 610 + name: identifiers.tuple, 627 611 }), 628 612 parameters: [ 629 613 compiler.arrayLiteralExpression({ ··· 640 624 }) => { 641 625 const expression = compiler.callExpression({ 642 626 functionName: compiler.propertyAccessExpression({ 643 - expression: zIdentifier, 644 - name: compiler.identifier({ text: 'undefined' }), 627 + expression: identifiers.z, 628 + name: identifiers.undefined, 645 629 }), 646 630 }); 647 631 return expression; ··· 653 637 }) => { 654 638 const expression = compiler.callExpression({ 655 639 functionName: compiler.propertyAccessExpression({ 656 - expression: zIdentifier, 657 - name: compiler.identifier({ text: 'unknown' }), 640 + expression: identifiers.z, 641 + name: identifiers.unknown, 658 642 }), 659 643 }); 660 644 return expression; ··· 664 648 const voidTypeToZodSchema = (_props: { schema: SchemaWithType<'void'> }) => { 665 649 const expression = compiler.callExpression({ 666 650 functionName: compiler.propertyAccessExpression({ 667 - expression: zIdentifier, 668 - name: compiler.identifier({ text: 'void' }), 651 + expression: identifiers.z, 652 + name: identifiers.void, 669 653 }), 670 654 }); 671 655 return expression; ··· 766 750 }; 767 751 768 752 export const schemaToZodSchema = ({ 769 - $ref, 770 - identifier: _identifier, 771 753 optional, 772 754 plugin, 773 755 schema, 774 756 state, 775 757 }: { 776 - /** 777 - * When $ref is supplied, a node will be emitted to the file. 778 - */ 779 - $ref?: string; 780 - identifier?: Identifier; 781 758 /** 782 759 * Accept `optional` to handle optional object properties. We can't handle 783 760 * this inside the object function because `.optional()` must come before ··· 787 764 plugin: ZodPlugin['Instance']; 788 765 schema: IR.SchemaObject; 789 766 state: State; 790 - }): ts.Expression => { 767 + }): ZodSchema => { 791 768 const file = plugin.context.file({ id: zodId })!; 792 769 793 - let anyType: string | undefined; 794 - let expression: ts.Expression | undefined; 795 - let identifier: ReturnType<typeof file.identifier> | undefined = _identifier; 796 - 797 - if ($ref) { 798 - state.circularReferenceTracker.add($ref); 799 - 800 - if (!identifier) { 801 - identifier = file.identifier({ 802 - $ref, 803 - case: state.nameCase, 804 - create: true, 805 - nameTransformer: state.nameTransformer, 806 - namespace: 'value', 807 - }); 808 - } 809 - } 770 + let zodSchema: Partial<ZodSchema> = {}; 810 771 811 772 if (schema.$ref) { 812 - const isCircularReference = state.circularReferenceTracker.has(schema.$ref); 773 + const isCircularReference = state.circularReferenceTracker.includes( 774 + schema.$ref, 775 + ); 776 + state.circularReferenceTracker.push(schema.$ref); 813 777 814 - // if $ref hasn't been processed yet, inline it to avoid the 815 - // "Block-scoped variable used before its declaration." error 816 - // this could be (maybe?) fixed by reshuffling the generation order 817 - let identifierRef = file.identifier({ 818 - $ref: schema.$ref, 819 - case: state.nameCase, 820 - nameTransformer: state.nameTransformer, 821 - namespace: 'value', 822 - }); 778 + const id = plugin.api.getId({ type: 'ref', value: schema.$ref }); 823 779 824 - if (!identifierRef.name) { 780 + if (isCircularReference) { 781 + const expression = file.addNodeReference(id, { 782 + factory: (text) => compiler.identifier({ text }), 783 + }); 784 + zodSchema.expression = compiler.callExpression({ 785 + functionName: compiler.propertyAccessExpression({ 786 + expression: identifiers.z, 787 + name: identifiers.lazy, 788 + }), 789 + parameters: [ 790 + compiler.arrowFunction({ 791 + statements: [compiler.returnStatement({ expression })], 792 + }), 793 + ], 794 + }); 795 + state.hasCircularReference = true; 796 + } else if (!file.getName(id)) { 797 + // if $ref hasn't been processed yet, inline it to avoid the 798 + // "Block-scoped variable used before its declaration." error 799 + // this could be (maybe?) fixed by reshuffling the generation order 825 800 const ref = plugin.context.resolveIrRef<IR.SchemaObject>(schema.$ref); 826 - expression = schemaToZodSchema({ 827 - $ref: schema.$ref, 801 + handleComponent({ 802 + id: schema.$ref, 828 803 plugin, 829 804 schema: ref, 830 805 state, 831 806 }); 807 + } 832 808 833 - identifierRef = file.identifier({ 834 - $ref: schema.$ref, 835 - case: state.nameCase, 836 - nameTransformer: state.nameTransformer, 837 - namespace: 'value', 809 + if (!isCircularReference) { 810 + const expression = file.addNodeReference(id, { 811 + factory: (text) => compiler.identifier({ text }), 838 812 }); 813 + zodSchema.expression = expression; 839 814 } 840 815 841 - // if `identifierRef.name` is falsy, we already set expression above 842 - if (identifierRef.name) { 843 - const refIdentifier = compiler.identifier({ text: identifierRef.name }); 844 - if (isCircularReference) { 845 - expression = compiler.callExpression({ 846 - functionName: compiler.propertyAccessExpression({ 847 - expression: zIdentifier, 848 - name: lazyIdentifier, 849 - }), 850 - parameters: [ 851 - compiler.arrowFunction({ 852 - statements: [ 853 - compiler.returnStatement({ 854 - expression: refIdentifier, 855 - }), 856 - ], 857 - }), 858 - ], 859 - }); 860 - state.hasCircularReference = true; 861 - } else { 862 - expression = refIdentifier; 863 - } 864 - } 816 + state.circularReferenceTracker.pop(); 865 817 } else if (schema.type) { 866 - const zodSchema = schemaTypeToZodSchema({ plugin, schema, state }); 867 - anyType = zodSchema.anyType; 868 - expression = zodSchema.expression; 818 + const zSchema = schemaTypeToZodSchema({ plugin, schema, state }); 819 + zodSchema.expression = zSchema.expression; 820 + zodSchema.typeName = zSchema.anyType; 869 821 870 822 if (plugin.config.metadata && schema.description) { 871 - expression = compiler.callExpression({ 823 + zodSchema.expression = compiler.callExpression({ 872 824 functionName: compiler.propertyAccessExpression({ 873 - expression, 874 - name: describeIdentifier, 825 + expression: zodSchema.expression, 826 + name: identifiers.describe, 875 827 }), 876 828 parameters: [compiler.stringLiteral({ text: schema.description })], 877 829 }); ··· 880 832 schema = deduplicateSchema({ schema }); 881 833 882 834 if (schema.items) { 883 - const itemTypes = schema.items.map((item) => 884 - schemaToZodSchema({ 885 - plugin, 886 - schema: item, 887 - state, 888 - }), 835 + const itemTypes = schema.items.map( 836 + (item) => 837 + schemaToZodSchema({ 838 + plugin, 839 + schema: item, 840 + state, 841 + }).expression, 889 842 ); 890 843 891 844 if (schema.logicalOperator === 'and') { ··· 897 850 firstSchema.logicalOperator === 'or' || 898 851 (firstSchema.type && firstSchema.type !== 'object') 899 852 ) { 900 - expression = compiler.callExpression({ 853 + zodSchema.expression = compiler.callExpression({ 901 854 functionName: compiler.propertyAccessExpression({ 902 - expression: zIdentifier, 903 - name: intersectionIdentifier, 855 + expression: identifiers.z, 856 + name: identifiers.intersection, 904 857 }), 905 858 parameters: itemTypes, 906 859 }); 907 860 } else { 908 - expression = itemTypes[0]; 861 + zodSchema.expression = itemTypes[0]; 909 862 itemTypes.slice(1).forEach((item) => { 910 - expression = compiler.callExpression({ 863 + zodSchema.expression = compiler.callExpression({ 911 864 functionName: compiler.propertyAccessExpression({ 912 - expression: expression!, 913 - name: andIdentifier, 865 + expression: zodSchema.expression!, 866 + name: identifiers.and, 914 867 }), 915 868 parameters: [item], 916 869 }); 917 870 }); 918 871 } 919 872 } else { 920 - expression = compiler.callExpression({ 873 + zodSchema.expression = compiler.callExpression({ 921 874 functionName: compiler.propertyAccessExpression({ 922 - expression: zIdentifier, 923 - name: unionIdentifier, 875 + expression: identifiers.z, 876 + name: identifiers.union, 924 877 }), 925 878 parameters: [ 926 879 compiler.arrayLiteralExpression({ ··· 930 883 }); 931 884 } 932 885 } else { 933 - expression = schemaToZodSchema({ 934 - plugin, 935 - schema, 936 - state, 937 - }); 886 + zodSchema = schemaToZodSchema({ plugin, schema, state }); 938 887 } 939 888 } else { 940 889 // catch-all fallback for failed schemas 941 - const zodSchema = schemaTypeToZodSchema({ 890 + const zSchema = schemaTypeToZodSchema({ 942 891 plugin, 943 892 schema: { 944 893 type: 'unknown', 945 894 }, 946 895 state, 947 896 }); 948 - anyType = zodSchema.anyType; 949 - expression = zodSchema.expression; 950 - } 951 - 952 - if ($ref) { 953 - state.circularReferenceTracker.delete($ref); 897 + zodSchema.expression = zSchema.expression; 898 + zodSchema.typeName = zSchema.anyType; 954 899 } 955 900 956 - if (expression) { 901 + if (zodSchema.expression) { 957 902 if (schema.accessScope === 'read') { 958 - expression = compiler.callExpression({ 903 + zodSchema.expression = compiler.callExpression({ 959 904 functionName: compiler.propertyAccessExpression({ 960 - expression, 961 - name: readonlyIdentifier, 905 + expression: zodSchema.expression, 906 + name: identifiers.readonly, 962 907 }), 963 908 }); 964 909 } 965 910 966 911 if (optional) { 967 - expression = compiler.callExpression({ 912 + zodSchema.expression = compiler.callExpression({ 968 913 functionName: compiler.propertyAccessExpression({ 969 - expression, 970 - name: optionalIdentifier, 914 + expression: zodSchema.expression, 915 + name: identifiers.optional, 971 916 }), 972 917 }); 973 918 } ··· 979 924 value: schema.default, 980 925 }); 981 926 if (callParameter) { 982 - expression = compiler.callExpression({ 927 + zodSchema.expression = compiler.callExpression({ 983 928 functionName: compiler.propertyAccessExpression({ 984 - expression, 985 - name: defaultIdentifier, 929 + expression: zodSchema.expression, 930 + name: identifiers.default, 986 931 }), 987 932 parameters: [callParameter], 988 933 }); ··· 990 935 } 991 936 } 992 937 993 - // emit nodes only if $ref points to a reusable component 994 - if (identifier && identifier.name && identifier.created) { 995 - const statement = compiler.constVariable({ 996 - comment: plugin.config.comments 997 - ? createSchemaComment({ schema }) 998 - : undefined, 999 - exportConst: true, 1000 - expression: expression!, 1001 - name: identifier.name, 1002 - typeName: state.hasCircularReference 1003 - ? (compiler.propertyAccessExpression({ 1004 - expression: zIdentifier, 1005 - name: anyType || 'ZodTypeAny', 1006 - }) as unknown as ts.TypeNode) 1007 - : undefined, 1008 - }); 1009 - file.add(statement); 938 + if (state.hasCircularReference) { 939 + if (!zodSchema.typeName) { 940 + zodSchema.typeName = 'ZodTypeAny'; 941 + } 942 + } else { 943 + zodSchema.typeName = undefined; 944 + } 945 + 946 + return zodSchema as ZodSchema; 947 + }; 948 + 949 + const handleComponent = ({ 950 + id, 951 + plugin, 952 + schema, 953 + state, 954 + }: { 955 + id: string; 956 + plugin: ZodPlugin['Instance']; 957 + schema: IR.SchemaObject; 958 + state?: State; 959 + }): void => { 960 + if (!state) { 961 + state = { 962 + circularReferenceTracker: [id], 963 + hasCircularReference: false, 964 + }; 1010 965 } 1011 966 1012 - return expression!; 967 + const file = plugin.context.file({ id: zodId })!; 968 + const schemaId = plugin.api.getId({ type: 'ref', value: id }); 969 + 970 + if (file.getName(schemaId)) return; 971 + 972 + const zodSchema = schemaToZodSchema({ plugin, schema, state }); 973 + const typeInferId = plugin.config.definitions.types.infer.enabled 974 + ? plugin.api.getId({ type: 'type-infer-ref', value: id }) 975 + : undefined; 976 + exportZodSchema({ 977 + plugin, 978 + schema, 979 + schemaId, 980 + typeInferId, 981 + zodSchema, 982 + }); 983 + const baseName = refToName(id); 984 + file.updateNodeReferences( 985 + schemaId, 986 + buildName({ 987 + config: plugin.config.definitions, 988 + name: baseName, 989 + }), 990 + ); 991 + if (typeInferId) { 992 + file.updateNodeReferences( 993 + typeInferId, 994 + buildName({ 995 + config: plugin.config.definitions.types.infer, 996 + name: baseName, 997 + }), 998 + ); 999 + } 1013 1000 }; 1014 1001 1015 1002 export const handler: ZodPlugin['Handler'] = ({ plugin }) => { ··· 1025 1012 }); 1026 1013 1027 1014 plugin.forEach('operation', 'parameter', 'requestBody', 'schema', (event) => { 1028 - const state: State = { 1029 - circularReferenceTracker: new Set(), 1030 - hasCircularReference: false, 1031 - nameCase: plugin.config.definitions.case, 1032 - nameTransformer: plugin.config.definitions.name, 1033 - }; 1034 - 1035 1015 if (event.type === 'operation') { 1036 - operationToZodSchema({ operation: event.operation, plugin, state }); 1016 + operationToZodSchema({ operation: event.operation, plugin }); 1037 1017 } else if (event.type === 'parameter') { 1038 - schemaToZodSchema({ 1039 - $ref: event.$ref, 1018 + handleComponent({ 1019 + id: event.$ref, 1040 1020 plugin, 1041 1021 schema: event.parameter.schema, 1042 - state, 1043 1022 }); 1044 1023 } else if (event.type === 'requestBody') { 1045 - schemaToZodSchema({ 1046 - $ref: event.$ref, 1024 + handleComponent({ 1025 + id: event.$ref, 1047 1026 plugin, 1048 1027 schema: event.requestBody.schema, 1049 - state, 1050 1028 }); 1051 1029 } else if (event.type === 'schema') { 1052 - schemaToZodSchema({ 1053 - $ref: event.$ref, 1030 + handleComponent({ 1031 + id: event.$ref, 1054 1032 plugin, 1055 1033 schema: event.schema, 1056 - state, 1057 1034 }); 1058 1035 } 1059 1036 });
+280
packages/openapi-ts/src/plugins/zod/types.d.ts
··· 42 42 * - `boolean`: Shorthand for `{ enabled: boolean }` 43 43 * - `string` or `function`: Shorthand for `{ name: string | function }` 44 44 * - `object`: Full configuration object 45 + * 46 + * @default true 45 47 */ 46 48 definitions?: 47 49 | boolean ··· 66 68 * @default 'z{{name}}' 67 69 */ 68 70 name?: StringName; 71 + /** 72 + * Configuration for TypeScript type generation from Zod schemas. 73 + * 74 + * Controls generation of TypeScript types based on the generated Zod schemas. 75 + */ 76 + types?: { 77 + /** 78 + * Configuration for `z.infer` types. 79 + * 80 + * Can be: 81 + * - `boolean`: Shorthand for `{ enabled: boolean }` 82 + * - `string` or `function`: Shorthand for `{ name: string | function }` 83 + * - `object`: Full configuration object 84 + * 85 + * @default false 86 + */ 87 + infer?: 88 + | boolean 89 + | StringName 90 + | { 91 + /** 92 + * The casing convention to use for generated type names. 93 + * 94 + * @default 'PascalCase' 95 + */ 96 + case?: StringCase; 97 + /** 98 + * Whether to generate TypeScript types from Zod schemas. 99 + * 100 + * @default true 101 + */ 102 + enabled?: boolean; 103 + /** 104 + * Custom naming pattern for generated type names. The name variable is 105 + * obtained from the Zod schema name. 106 + * 107 + * @default '{{name}}ZodType' 108 + */ 109 + name?: StringName; 110 + }; 111 + }; 69 112 }; 70 113 /** 71 114 * Should the exports from the generated files be re-exported in the index ··· 98 141 * - `boolean`: Shorthand for `{ enabled: boolean }` 99 142 * - `string` or `function`: Shorthand for `{ name: string | function }` 100 143 * - `object`: Full configuration object 144 + * 145 + * @default true 101 146 */ 102 147 requests?: 103 148 | boolean ··· 122 167 * @default 'z{{name}}Data' 123 168 */ 124 169 name?: StringName; 170 + /** 171 + * Configuration for TypeScript type generation from Zod schemas. 172 + * 173 + * Controls generation of TypeScript types based on the generated Zod schemas. 174 + */ 175 + types?: { 176 + /** 177 + * Configuration for `z.infer` types. 178 + * 179 + * Can be: 180 + * - `boolean`: Shorthand for `{ enabled: boolean }` 181 + * - `string` or `function`: Shorthand for `{ name: string | function }` 182 + * - `object`: Full configuration object 183 + * 184 + * @default false 185 + */ 186 + infer?: 187 + | boolean 188 + | StringName 189 + | { 190 + /** 191 + * The casing convention to use for generated type names. 192 + * 193 + * @default 'PascalCase' 194 + */ 195 + case?: StringCase; 196 + /** 197 + * Whether to generate TypeScript types from Zod schemas. 198 + * 199 + * @default true 200 + */ 201 + enabled?: boolean; 202 + /** 203 + * Custom naming pattern for generated type names. The name variable is 204 + * obtained from the Zod schema name. 205 + * 206 + * @default '{{name}}DataZodType' 207 + */ 208 + name?: StringName; 209 + }; 210 + }; 125 211 }; 126 212 /** 127 213 * Configuration for response-specific Zod schemas. ··· 133 219 * - `boolean`: Shorthand for `{ enabled: boolean }` 134 220 * - `string` or `function`: Shorthand for `{ name: string | function }` 135 221 * - `object`: Full configuration object 222 + * 223 + * @default true 136 224 */ 137 225 responses?: 138 226 | boolean ··· 157 245 * @default 'z{{name}}Response' 158 246 */ 159 247 name?: StringName; 248 + /** 249 + * Configuration for TypeScript type generation from Zod schemas. 250 + * 251 + * Controls generation of TypeScript types based on the generated Zod schemas. 252 + */ 253 + types?: { 254 + /** 255 + * Configuration for `z.infer` types. 256 + * 257 + * Can be: 258 + * - `boolean`: Shorthand for `{ enabled: boolean }` 259 + * - `string` or `function`: Shorthand for `{ name: string | function }` 260 + * - `object`: Full configuration object 261 + * 262 + * @default false 263 + */ 264 + infer?: 265 + | boolean 266 + | StringName 267 + | { 268 + /** 269 + * The casing convention to use for generated type names. 270 + * 271 + * @default 'PascalCase' 272 + */ 273 + case?: StringCase; 274 + /** 275 + * Whether to generate TypeScript types from Zod schemas. 276 + * 277 + * @default true 278 + */ 279 + enabled?: boolean; 280 + /** 281 + * Custom naming pattern for generated type names. The name variable is 282 + * obtained from the Zod schema name. 283 + * 284 + * @default '{{name}}ResponseZodType' 285 + */ 286 + name?: StringName; 287 + }; 288 + }; 160 289 }; 290 + /** 291 + * Configuration for TypeScript type generation from Zod schemas. 292 + * 293 + * Controls generation of TypeScript types based on the generated Zod schemas. 294 + */ 295 + types?: { 296 + /** 297 + * Configuration for `z.infer` types. 298 + * 299 + * Can be: 300 + * - `boolean`: Shorthand for `{ enabled: boolean }` 301 + * - `string` or `function`: Shorthand for `{ name: string | function }` 302 + * - `object`: Full configuration object 303 + * 304 + * @default false 305 + */ 306 + infer?: 307 + | boolean 308 + | StringName 309 + | { 310 + /** 311 + * The casing convention to use for generated type names. 312 + * 313 + * @default 'PascalCase' 314 + */ 315 + case?: StringCase; 316 + /** 317 + * Whether to generate TypeScript types from Zod schemas. 318 + * 319 + * @default true 320 + */ 321 + enabled?: boolean; 322 + }; 323 + }; 161 324 }; 162 325 163 326 export type Config = Plugin.Name<'zod'> & { ··· 216 379 * @default 'z{{name}}' 217 380 */ 218 381 name: StringName; 382 + /** 383 + * Configuration for TypeScript type generation from Zod schemas. 384 + * 385 + * Controls generation of TypeScript types based on the generated Zod schemas. 386 + */ 387 + types: { 388 + /** 389 + * Configuration for `z.infer` types. 390 + */ 391 + infer: { 392 + /** 393 + * The casing convention to use for generated type names. 394 + * 395 + * @default 'PascalCase' 396 + */ 397 + case: StringCase; 398 + /** 399 + * Whether to generate TypeScript types from Zod schemas. 400 + * 401 + * @default true 402 + */ 403 + enabled: boolean; 404 + /** 405 + * Custom naming pattern for generated type names. The name variable is 406 + * obtained from the Zod schema name. 407 + * 408 + * @default '{{name}}ZodType' 409 + */ 410 + name: StringName; 411 + }; 412 + }; 219 413 }; 220 414 /** 221 415 * Should the exports from the generated files be re-exported in the index ··· 264 458 * @default 'z{{name}}Data' 265 459 */ 266 460 name: StringName; 461 + /** 462 + * Configuration for TypeScript type generation from Zod schemas. 463 + * 464 + * Controls generation of TypeScript types based on the generated Zod schemas. 465 + */ 466 + types: { 467 + /** 468 + * Configuration for `z.infer` types. 469 + */ 470 + infer: { 471 + /** 472 + * The casing convention to use for generated type names. 473 + * 474 + * @default 'PascalCase' 475 + */ 476 + case: StringCase; 477 + /** 478 + * Whether to generate TypeScript types from Zod schemas. 479 + * 480 + * @default true 481 + */ 482 + enabled: boolean; 483 + /** 484 + * Custom naming pattern for generated type names. The name variable is 485 + * obtained from the Zod schema name. 486 + * 487 + * @default '{{name}}DataZodType' 488 + */ 489 + name: StringName; 490 + }; 491 + }; 267 492 }; 268 493 /** 269 494 * Configuration for response-specific Zod schemas. ··· 291 516 * @default 'z{{name}}Response' 292 517 */ 293 518 name: StringName; 519 + /** 520 + * Configuration for TypeScript type generation from Zod schemas. 521 + * 522 + * Controls generation of TypeScript types based on the generated Zod schemas. 523 + */ 524 + types: { 525 + /** 526 + * Configuration for `z.infer` types. 527 + */ 528 + infer: { 529 + /** 530 + * The casing convention to use for generated type names. 531 + * 532 + * @default 'PascalCase' 533 + */ 534 + case: StringCase; 535 + /** 536 + * Whether to generate TypeScript types from Zod schemas. 537 + * 538 + * @default true 539 + */ 540 + enabled: boolean; 541 + /** 542 + * Custom naming pattern for generated type names. The name variable is 543 + * obtained from the Zod schema name. 544 + * 545 + * @default '{{name}}ResponseZodType' 546 + */ 547 + name: StringName; 548 + }; 549 + }; 550 + }; 551 + /** 552 + * Configuration for TypeScript type generation from Zod schemas. 553 + * 554 + * Controls generation of TypeScript types based on the generated Zod schemas. 555 + */ 556 + types: { 557 + /** 558 + * Configuration for `z.infer` types. 559 + */ 560 + infer: { 561 + /** 562 + * The casing convention to use for generated type names. 563 + * 564 + * @default 'PascalCase' 565 + */ 566 + case: StringCase; 567 + /** 568 + * Whether to generate TypeScript types from Zod schemas. 569 + * 570 + * @default true 571 + */ 572 + enabled: boolean; 573 + }; 294 574 }; 295 575 }; 296 576