@kubb/plugin-zod 5.0.0-beta.30 → 5.0.0-beta.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +312 -78
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +24 -0
- package/dist/index.js +312 -78
- package/dist/index.js.map +1 -1
- package/extension.yaml +2 -0
- package/package.json +5 -4
- package/src/components/Operations.tsx +2 -1
- package/src/generators/zodGenerator.tsx +166 -61
- package/src/plugin.ts +3 -13
- package/src/printers/printerZod.ts +24 -5
- package/src/resolvers/resolverZod.ts +6 -0
- package/src/types.ts +15 -0
- package/src/utils.ts +93 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveContentTypeVariants } from '@internals/shared'
|
|
1
2
|
import type { Adapter } from '@kubb/core'
|
|
2
3
|
import { ast, defineGenerator } from '@kubb/core'
|
|
3
4
|
import type { AdapterOas } from '@kubb/adapter-oas'
|
|
@@ -8,15 +9,43 @@ import { ZOD_NAMESPACE_IMPORTS } from '../constants.ts'
|
|
|
8
9
|
import { printerZod } from '../printers/printerZod.ts'
|
|
9
10
|
import { printerZodMini } from '../printers/printerZodMini.ts'
|
|
10
11
|
import type { PluginZod, ResolverZod } from '../types'
|
|
11
|
-
import { buildSchemaNames } from '../utils.ts'
|
|
12
|
+
import { buildSchemaNames, containsCodec } from '../utils.ts'
|
|
12
13
|
|
|
13
|
-
type
|
|
14
|
+
type StdPrinters = { output: ReturnType<typeof printerZod>; input: ReturnType<typeof printerZod> }
|
|
15
|
+
type ZodPrinterEntry = StdPrinters & { coercion: unknown; guidType: unknown; dateType: unknown }
|
|
14
16
|
type ZodMiniPrinterEntry = { printer: ReturnType<typeof printerZodMini>; guidType: unknown }
|
|
15
17
|
|
|
16
18
|
// Per-build caches: keyed on resolver (unique per plugin instance per build, GC'd when released)
|
|
17
19
|
const zodPrinterCache = new WeakMap<ResolverZod, ZodPrinterEntry>()
|
|
18
20
|
const zodMiniPrinterCache = new WeakMap<ResolverZod, ZodMiniPrinterEntry>()
|
|
19
21
|
|
|
22
|
+
type StdPrinterParams = { coercion: unknown; guidType: unknown; dateType: unknown; wrapOutput: unknown; cyclicSchemas: ReadonlySet<string>; nodes: unknown }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns the cached `output`/`input` direction printers for a resolver, building them on
|
|
26
|
+
* first use. The `input` printer encodes `Date → string` for request bodies; `output` decodes
|
|
27
|
+
* `string → Date` for responses. Schemas without `dateType: 'date'` fields print identically.
|
|
28
|
+
*/
|
|
29
|
+
function getStdPrinters(resolver: ResolverZod, params: StdPrinterParams): StdPrinters {
|
|
30
|
+
const cached = zodPrinterCache.get(resolver)
|
|
31
|
+
if (cached && cached.coercion === params.coercion && cached.guidType === params.guidType && cached.dateType === params.dateType) {
|
|
32
|
+
return { output: cached.output, input: cached.input }
|
|
33
|
+
}
|
|
34
|
+
const base = { ...params, resolver } as Parameters<typeof printerZod>[0]
|
|
35
|
+
const output = printerZod({ ...base, direction: 'output' })
|
|
36
|
+
const input = printerZod({ ...base, direction: 'input' })
|
|
37
|
+
zodPrinterCache.set(resolver, { output, input, coercion: params.coercion, guidType: params.guidType, dateType: params.dateType })
|
|
38
|
+
return { output, input }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getMiniPrinter(resolver: ResolverZod, params: { guidType: unknown; wrapOutput: unknown; cyclicSchemas: ReadonlySet<string>; nodes: unknown }) {
|
|
42
|
+
const cached = zodMiniPrinterCache.get(resolver)
|
|
43
|
+
if (cached && cached.guidType === params.guidType) return cached.printer
|
|
44
|
+
const p = printerZodMini({ ...params, resolver } as Parameters<typeof printerZodMini>[0])
|
|
45
|
+
zodMiniPrinterCache.set(resolver, { printer: p, guidType: params.guidType })
|
|
46
|
+
return p
|
|
47
|
+
}
|
|
48
|
+
|
|
20
49
|
/**
|
|
21
50
|
* Built-in generator for `@kubb/plugin-zod`. Emits one Zod schema per
|
|
22
51
|
* schema in the spec plus per-operation request/response/parameter schemas.
|
|
@@ -37,11 +66,36 @@ export const zodGenerator = defineGenerator<PluginZod>({
|
|
|
37
66
|
|
|
38
67
|
const mode = ctx.getMode(output)
|
|
39
68
|
const isZodImport = ZOD_NAMESPACE_IMPORTS.has(importPath as 'zod' | 'zod/mini')
|
|
69
|
+
const cyclicSchemas = new Set<string>(ctx.meta.circularNames)
|
|
70
|
+
|
|
71
|
+
// A codec component is rendered twice: the canonical (output) schema decodes
|
|
72
|
+
// `string → Date`, and an `${name}InputSchema` variant encodes `Date → string` for requests.
|
|
73
|
+
const hasCodec = !mini && containsCodec(node)
|
|
40
74
|
|
|
41
|
-
const
|
|
75
|
+
const codecRefNames = new Set(
|
|
76
|
+
hasCodec
|
|
77
|
+
? ast.collect<string>(node, {
|
|
78
|
+
schema: (n) => (n.type === 'ref' && n.ref && containsCodec(n) ? (ast.extractRefName(n.ref) ?? undefined) : undefined),
|
|
79
|
+
})
|
|
80
|
+
: [],
|
|
81
|
+
)
|
|
82
|
+
const importEntries = adapter.getImports(node, (schemaName) => ({
|
|
42
83
|
name: resolver.resolveSchemaName(schemaName),
|
|
43
84
|
path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group: group ?? undefined }).path,
|
|
44
85
|
}))
|
|
86
|
+
const inputImportEntries = hasCodec
|
|
87
|
+
? [...codecRefNames].map((schemaName) => ({
|
|
88
|
+
name: resolver.resolveInputSchemaName(schemaName),
|
|
89
|
+
path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group: group ?? undefined }).path,
|
|
90
|
+
}))
|
|
91
|
+
: []
|
|
92
|
+
const seenImports = new Set<string>()
|
|
93
|
+
const imports = [...importEntries, ...inputImportEntries].filter((imp) => {
|
|
94
|
+
const key = `${Array.isArray(imp.name) ? imp.name.join(',') : imp.name}|${imp.path}`
|
|
95
|
+
if (seenImports.has(key)) return false
|
|
96
|
+
seenImports.add(key)
|
|
97
|
+
return true
|
|
98
|
+
})
|
|
45
99
|
|
|
46
100
|
const meta = {
|
|
47
101
|
name: resolver.resolveSchemaName(node.name),
|
|
@@ -50,27 +104,8 @@ export const zodGenerator = defineGenerator<PluginZod>({
|
|
|
50
104
|
|
|
51
105
|
const inferTypeName = inferred ? resolver.resolveSchemaTypeName(node.name) : null
|
|
52
106
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
const schemaPrinter = mini ? getCachedMiniPrinter() : getCachedStdPrinter()
|
|
56
|
-
function getCachedStdPrinter() {
|
|
57
|
-
const cached = zodPrinterCache.get(resolver)
|
|
58
|
-
if (cached && cached.coercion === coercion && cached.guidType === guidType && cached.dateType === dateType) {
|
|
59
|
-
return cached.printer
|
|
60
|
-
}
|
|
61
|
-
const p = printerZod({ coercion, guidType, dateType, wrapOutput, resolver, cyclicSchemas, nodes: printer?.nodes })
|
|
62
|
-
zodPrinterCache.set(resolver, { printer: p, coercion, guidType, dateType })
|
|
63
|
-
return p
|
|
64
|
-
}
|
|
65
|
-
function getCachedMiniPrinter() {
|
|
66
|
-
const cached = zodMiniPrinterCache.get(resolver)
|
|
67
|
-
if (cached && cached.guidType === guidType) {
|
|
68
|
-
return cached.printer
|
|
69
|
-
}
|
|
70
|
-
const p = printerZodMini({ guidType, wrapOutput, resolver, cyclicSchemas, nodes: printer?.nodes })
|
|
71
|
-
zodMiniPrinterCache.set(resolver, { printer: p, guidType })
|
|
72
|
-
return p
|
|
73
|
-
}
|
|
107
|
+
const stdPrinters = mini ? null : getStdPrinters(resolver, { coercion, guidType, dateType, wrapOutput, cyclicSchemas, nodes: printer?.nodes })
|
|
108
|
+
const schemaPrinter = mini ? getMiniPrinter(resolver, { guidType, wrapOutput, cyclicSchemas, nodes: printer?.nodes }) : stdPrinters!.output
|
|
74
109
|
|
|
75
110
|
return (
|
|
76
111
|
<File
|
|
@@ -81,13 +116,23 @@ export const zodGenerator = defineGenerator<PluginZod>({
|
|
|
81
116
|
footer={resolver.resolveFooter(ctx.meta, { output, config, file: { path: meta.file.path, baseName: meta.file.baseName } })}
|
|
82
117
|
>
|
|
83
118
|
<File.Import name={isZodImport ? 'z' : ['z']} path={importPath} isNameSpace={isZodImport} />
|
|
84
|
-
{mode === 'split' &&
|
|
119
|
+
{mode === 'split' &&
|
|
120
|
+
imports.map((imp) => <File.Import key={[node.name, imp.path, imp.name].join('-')} root={meta.file.path} path={imp.path} name={imp.name} />)}
|
|
85
121
|
|
|
86
122
|
<Zod name={meta.name} node={node} printer={schemaPrinter} inferTypeName={inferTypeName} />
|
|
123
|
+
{hasCodec && stdPrinters && (
|
|
124
|
+
<Zod
|
|
125
|
+
name={resolver.resolveInputSchemaName(node.name)}
|
|
126
|
+
node={node}
|
|
127
|
+
printer={stdPrinters.input}
|
|
128
|
+
inferTypeName={inferred ? resolver.resolveInputSchemaTypeName(node.name) : null}
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
87
131
|
</File>
|
|
88
132
|
)
|
|
89
133
|
},
|
|
90
134
|
operation(node, ctx) {
|
|
135
|
+
if (!ast.isHttpOperationNode(node)) return null
|
|
91
136
|
const { adapter, config, resolver, root } = ctx
|
|
92
137
|
const { output, coercion, guidType, mini, wrapOutput, inferred, importPath, group, paramsCasing, printer } = ctx.options
|
|
93
138
|
const dateType = (adapter as Adapter<AdapterOas>).options.dateType
|
|
@@ -106,29 +151,42 @@ export const zodGenerator = defineGenerator<PluginZod>({
|
|
|
106
151
|
|
|
107
152
|
const cyclicSchemas = new Set<string>(ctx.meta.circularNames)
|
|
108
153
|
|
|
109
|
-
function renderSchemaEntry({
|
|
154
|
+
function renderSchemaEntry({
|
|
155
|
+
schema,
|
|
156
|
+
name,
|
|
157
|
+
keysToOmit,
|
|
158
|
+
direction = 'output',
|
|
159
|
+
}: {
|
|
160
|
+
schema: ast.SchemaNode | null
|
|
161
|
+
name: string
|
|
162
|
+
keysToOmit?: Array<string> | null
|
|
163
|
+
direction?: 'input' | 'output'
|
|
164
|
+
}) {
|
|
110
165
|
if (!schema) return null
|
|
111
166
|
|
|
112
167
|
const inferTypeName = inferred ? resolver.resolveTypeName(name) : null
|
|
113
168
|
|
|
169
|
+
// In the input direction, refs to codec components resolve to their input variant.
|
|
170
|
+
const codecRefNames =
|
|
171
|
+
direction === 'input' && !mini
|
|
172
|
+
? new Set(
|
|
173
|
+
ast.collect<string>(schema, {
|
|
174
|
+
schema: (n) => (n.type === 'ref' && n.ref && containsCodec(n) ? (ast.extractRefName(n.ref) ?? undefined) : undefined),
|
|
175
|
+
}),
|
|
176
|
+
)
|
|
177
|
+
: null
|
|
114
178
|
const imports = adapter.getImports(schema, (schemaName) => ({
|
|
115
|
-
name: resolver.resolveSchemaName(schemaName),
|
|
179
|
+
name: codecRefNames?.has(schemaName) ? resolver.resolveInputSchemaName(schemaName) : resolver.resolveSchemaName(schemaName),
|
|
116
180
|
path: resolver.resolveFile({ name: schemaName, extname: '.ts' }, { root, output, group: group ?? undefined }).path,
|
|
117
181
|
}))
|
|
118
182
|
|
|
119
|
-
const cachedStd = zodPrinterCache.get(resolver)
|
|
120
|
-
const cachedMini = zodMiniPrinterCache.get(resolver)
|
|
121
183
|
const schemaPrinter = mini
|
|
122
184
|
? keysToOmit?.length
|
|
123
185
|
? printerZodMini({ guidType, wrapOutput, resolver, keysToOmit, cyclicSchemas, nodes: printer?.nodes })
|
|
124
|
-
:
|
|
125
|
-
? cachedMini.printer
|
|
126
|
-
: printerZodMini({ guidType, wrapOutput, resolver, cyclicSchemas, nodes: printer?.nodes })
|
|
186
|
+
: getMiniPrinter(resolver, { guidType, wrapOutput, cyclicSchemas, nodes: printer?.nodes })
|
|
127
187
|
: keysToOmit?.length
|
|
128
|
-
? printerZod({ coercion, guidType, dateType, wrapOutput, resolver, keysToOmit, cyclicSchemas, nodes: printer?.nodes })
|
|
129
|
-
:
|
|
130
|
-
? cachedStd.printer
|
|
131
|
-
: printerZod({ coercion, guidType, dateType, wrapOutput, resolver, cyclicSchemas, nodes: printer?.nodes })
|
|
188
|
+
? printerZod({ coercion, guidType, dateType, wrapOutput, resolver, keysToOmit, cyclicSchemas, nodes: printer?.nodes, direction })
|
|
189
|
+
: getStdPrinters(resolver, { coercion, guidType, dateType, wrapOutput, cyclicSchemas, nodes: printer?.nodes })[direction]
|
|
132
190
|
|
|
133
191
|
return (
|
|
134
192
|
<>
|
|
@@ -139,17 +197,49 @@ export const zodGenerator = defineGenerator<PluginZod>({
|
|
|
139
197
|
)
|
|
140
198
|
}
|
|
141
199
|
|
|
142
|
-
|
|
200
|
+
// Multiple content types for a single name — emit one schema per content type plus a union alias.
|
|
201
|
+
function buildContentTypeVariants(
|
|
202
|
+
entries: Array<{ contentType: string; schema?: ast.SchemaNode | null; keysToOmit?: Array<string> | null }>,
|
|
203
|
+
baseName: string,
|
|
204
|
+
decorate?: (schema: ast.SchemaNode) => ast.SchemaNode,
|
|
205
|
+
direction?: 'input' | 'output',
|
|
206
|
+
) {
|
|
207
|
+
const variants = resolveContentTypeVariants(entries, baseName)
|
|
208
|
+
const unionSchema = ast.createSchema({
|
|
209
|
+
type: 'union',
|
|
210
|
+
members: variants.map((variant) => ast.createSchema({ type: 'ref', name: variant.name })),
|
|
211
|
+
})
|
|
212
|
+
return (
|
|
213
|
+
<>
|
|
214
|
+
{variants.map((variant) =>
|
|
215
|
+
renderSchemaEntry({
|
|
216
|
+
schema: decorate ? decorate(variant.schema) : variant.schema,
|
|
217
|
+
name: variant.name,
|
|
218
|
+
keysToOmit: variant.keysToOmit,
|
|
219
|
+
direction,
|
|
220
|
+
}),
|
|
221
|
+
)}
|
|
222
|
+
{renderSchemaEntry({ schema: unionSchema, name: baseName, direction })}
|
|
223
|
+
</>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
143
226
|
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
227
|
+
const paramSchemas = params.map((param) => renderSchemaEntry({ schema: param.schema, name: resolver.resolveParamName(node, param), direction: 'input' }))
|
|
228
|
+
|
|
229
|
+
const responseSchemas = node.responses.map((res) => {
|
|
230
|
+
const variants = (res.content ?? []).filter((entry) => entry.schema)
|
|
231
|
+
if (variants.length > 1) {
|
|
232
|
+
return buildContentTypeVariants(res.content!, resolver.resolveResponseStatusName(node, res.statusCode))
|
|
233
|
+
}
|
|
234
|
+
const primary = variants[0] ?? res.content?.[0]
|
|
235
|
+
return renderSchemaEntry({
|
|
236
|
+
schema: primary?.schema ?? null,
|
|
147
237
|
name: resolver.resolveResponseStatusName(node, res.statusCode),
|
|
148
|
-
keysToOmit:
|
|
149
|
-
})
|
|
150
|
-
)
|
|
238
|
+
keysToOmit: primary?.keysToOmit,
|
|
239
|
+
})
|
|
240
|
+
})
|
|
151
241
|
|
|
152
|
-
const responsesWithSchema = node.responses.filter((res) => res.content?.
|
|
242
|
+
const responsesWithSchema = node.responses.filter((res) => res.content?.some((entry) => entry.schema))
|
|
153
243
|
const responseUnionSchema =
|
|
154
244
|
responsesWithSchema.length > 0
|
|
155
245
|
? (() => {
|
|
@@ -160,14 +250,16 @@ export const zodGenerator = defineGenerator<PluginZod>({
|
|
|
160
250
|
// the response union name, skip generation to avoid redeclaration errors.
|
|
161
251
|
const importedNames = new Set(
|
|
162
252
|
responsesWithSchema.flatMap((res) =>
|
|
163
|
-
res.content
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
253
|
+
(res.content ?? []).flatMap((entry) =>
|
|
254
|
+
entry.schema
|
|
255
|
+
? adapter
|
|
256
|
+
.getImports(entry.schema, (schemaName) => ({
|
|
257
|
+
name: resolver.resolveSchemaName(schemaName),
|
|
258
|
+
path: '',
|
|
259
|
+
}))
|
|
260
|
+
.flatMap((imp) => (Array.isArray(imp.name) ? imp.name : [imp.name]))
|
|
261
|
+
: [],
|
|
262
|
+
),
|
|
171
263
|
),
|
|
172
264
|
)
|
|
173
265
|
|
|
@@ -185,16 +277,29 @@ export const zodGenerator = defineGenerator<PluginZod>({
|
|
|
185
277
|
})()
|
|
186
278
|
: null
|
|
187
279
|
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
280
|
+
const requestBodyContent = node.requestBody?.content ?? []
|
|
281
|
+
const requestSchema = (() => {
|
|
282
|
+
if (requestBodyContent.length === 0) return null
|
|
283
|
+
if (requestBodyContent.length === 1) {
|
|
284
|
+
const entry = requestBodyContent[0]!
|
|
285
|
+
if (!entry.schema) return null
|
|
286
|
+
return renderSchemaEntry({
|
|
287
|
+
schema: { ...entry.schema, description: node.requestBody!.description ?? entry.schema.description },
|
|
194
288
|
name: resolver.resolveDataName(node),
|
|
195
|
-
keysToOmit:
|
|
289
|
+
keysToOmit: entry.keysToOmit,
|
|
290
|
+
direction: 'input',
|
|
196
291
|
})
|
|
197
|
-
|
|
292
|
+
}
|
|
293
|
+
return buildContentTypeVariants(
|
|
294
|
+
requestBodyContent,
|
|
295
|
+
resolver.resolveDataName(node),
|
|
296
|
+
(schema) => ({
|
|
297
|
+
...schema,
|
|
298
|
+
description: node.requestBody!.description ?? schema.description,
|
|
299
|
+
}),
|
|
300
|
+
'input',
|
|
301
|
+
)
|
|
302
|
+
})()
|
|
198
303
|
|
|
199
304
|
return (
|
|
200
305
|
<File
|
|
@@ -225,7 +330,7 @@ export const zodGenerator = defineGenerator<PluginZod>({
|
|
|
225
330
|
file: resolver.resolveFile({ name: 'operations', extname: '.ts' }, { root, output, group: group ?? undefined }),
|
|
226
331
|
} as const
|
|
227
332
|
|
|
228
|
-
const transformedOperations = nodes.map((node) => {
|
|
333
|
+
const transformedOperations = nodes.filter(ast.isHttpOperationNode).map((node) => {
|
|
229
334
|
const params = ast.caseParams(node.parameters, paramsCasing)
|
|
230
335
|
|
|
231
336
|
return {
|
package/src/plugin.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { definePlugin
|
|
1
|
+
import { createGroupConfig } from '@internals/shared'
|
|
2
|
+
import { definePlugin } from '@kubb/core'
|
|
3
3
|
import { zodGenerator } from './generators/zodGenerator.tsx'
|
|
4
4
|
import { resolverZod } from './resolvers/resolverZod.ts'
|
|
5
5
|
import type { PluginZod } from './types.ts'
|
|
@@ -57,17 +57,7 @@ export const pluginZod = definePlugin<PluginZod>((options) => {
|
|
|
57
57
|
generators: userGenerators = [],
|
|
58
58
|
} = options
|
|
59
59
|
|
|
60
|
-
const groupConfig = group
|
|
61
|
-
? ({
|
|
62
|
-
...group,
|
|
63
|
-
name: (ctx) => {
|
|
64
|
-
if (group.type === 'path') {
|
|
65
|
-
return `${ctx.group.split('/')[1]}`
|
|
66
|
-
}
|
|
67
|
-
return `${camelCase(ctx.group)}Controller`
|
|
68
|
-
},
|
|
69
|
-
} satisfies Group)
|
|
70
|
-
: null
|
|
60
|
+
const groupConfig = createGroupConfig(group, { suffix: 'Controller' })
|
|
71
61
|
|
|
72
62
|
return {
|
|
73
63
|
name: pluginZodName,
|
|
@@ -2,7 +2,7 @@ import { stringify } from '@internals/utils'
|
|
|
2
2
|
|
|
3
3
|
import { ast } from '@kubb/core'
|
|
4
4
|
import type { PluginZod, ResolverZod } from '../types.ts'
|
|
5
|
-
import { applyModifiers, formatLiteral, lengthConstraints, numberConstraints, shouldCoerce } from '../utils.ts'
|
|
5
|
+
import { applyModifiers, containsCodec, formatLiteral, getCodec, lengthConstraints, numberConstraints, shouldCoerce } from '../utils.ts'
|
|
6
6
|
import type { AdapterOas } from '@kubb/adapter-oas'
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -59,6 +59,15 @@ export type PrinterZodOptions = {
|
|
|
59
59
|
* Properties referencing these emit lazy getters wrapping refs in `z.lazy(() => …)`.
|
|
60
60
|
*/
|
|
61
61
|
cyclicSchemas?: ReadonlySet<string>
|
|
62
|
+
/**
|
|
63
|
+
* Print direction for `dateType: 'date'` fields (`Date` in TypeScript):
|
|
64
|
+
* - `'output'` (default) — decode the wire `string` into a `Date` (response bodies).
|
|
65
|
+
* - `'input'` — encode a `Date` back into the wire `string` (request bodies/params).
|
|
66
|
+
*
|
|
67
|
+
* Diverging the directions requires the generator to emit an `${name}InputSchema`
|
|
68
|
+
* variant for each date-bearing component.
|
|
69
|
+
*/
|
|
70
|
+
direction?: 'input' | 'output'
|
|
62
71
|
/**
|
|
63
72
|
* Custom handler map for node type overrides.
|
|
64
73
|
*/
|
|
@@ -139,11 +148,13 @@ export const printerZod = ast.definePrinter<PrinterZodFactory>((options) => {
|
|
|
139
148
|
return shouldCoerce(this.options.coercion, 'numbers') ? 'z.coerce.bigint()' : 'z.bigint()'
|
|
140
149
|
},
|
|
141
150
|
date(node) {
|
|
142
|
-
|
|
143
|
-
|
|
151
|
+
// representation: 'date' → typed as `Date`; decode/encode at the boundary.
|
|
152
|
+
const codec = getCodec(node)
|
|
153
|
+
if (codec) {
|
|
154
|
+
return this.options.direction === 'input' ? codec.encode(node) : codec.decode(node)
|
|
144
155
|
}
|
|
145
156
|
|
|
146
|
-
return
|
|
157
|
+
return 'z.iso.date()'
|
|
147
158
|
},
|
|
148
159
|
datetime(node) {
|
|
149
160
|
const offset = node.offset || this.options.dateType === 'stringOffset'
|
|
@@ -193,7 +204,15 @@ export const printerZod = ast.definePrinter<PrinterZodFactory>((options) => {
|
|
|
193
204
|
ref(node) {
|
|
194
205
|
if (!node.name) return null
|
|
195
206
|
const refName = node.ref ? (ast.extractRefName(node.ref) ?? node.name) : node.name
|
|
196
|
-
|
|
207
|
+
|
|
208
|
+
// In the input direction, a date-bearing component resolves to its `${name}InputSchema`
|
|
209
|
+
// variant so request bodies encode `Date → string` instead of decoding.
|
|
210
|
+
const useInputVariant = node.ref != null && this.options.direction === 'input' && containsCodec(node)
|
|
211
|
+
const resolvedName = node.ref
|
|
212
|
+
? useInputVariant
|
|
213
|
+
? (this.options.resolver?.resolveInputSchemaName(refName) ?? refName)
|
|
214
|
+
: (this.options.resolver?.default(refName, 'function') ?? refName)
|
|
215
|
+
: node.name
|
|
197
216
|
|
|
198
217
|
if (node.ref && this.options.cyclicSchemas?.has(refName)) {
|
|
199
218
|
return `z.lazy(() => ${resolvedName})`
|
|
@@ -29,6 +29,12 @@ export const resolverZod = defineResolver<PluginZod>(() => {
|
|
|
29
29
|
resolveSchemaTypeName(name) {
|
|
30
30
|
return ensureValidVarName(pascalCase(name, { suffix: 'schema' }))
|
|
31
31
|
},
|
|
32
|
+
resolveInputSchemaName(name) {
|
|
33
|
+
return this.resolveSchemaName(`${name} input`)
|
|
34
|
+
},
|
|
35
|
+
resolveInputSchemaTypeName(name) {
|
|
36
|
+
return this.resolveSchemaTypeName(`${name} input`)
|
|
37
|
+
},
|
|
32
38
|
resolveTypeName(name) {
|
|
33
39
|
return ensureValidVarName(pascalCase(name))
|
|
34
40
|
},
|
package/src/types.ts
CHANGED
|
@@ -18,6 +18,21 @@ export type ResolverZod = Resolver &
|
|
|
18
18
|
* `resolver.resolveSchemaTypeName('pet') // → 'Pet'`
|
|
19
19
|
*/
|
|
20
20
|
resolveSchemaTypeName(this: ResolverZod, name: string): string
|
|
21
|
+
/**
|
|
22
|
+
* Resolves the schema function name for the request (input) direction of a
|
|
23
|
+
* date-bearing component, where `Date` is encoded back to a wire `string`.
|
|
24
|
+
*
|
|
25
|
+
* @example Input schema names
|
|
26
|
+
* `resolver.resolveInputSchemaName('order') // → 'orderInputSchema'`
|
|
27
|
+
*/
|
|
28
|
+
resolveInputSchemaName(this: ResolverZod, name: string): string
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the inferred type name for the request (input) direction variant.
|
|
31
|
+
*
|
|
32
|
+
* @example Input schema type names
|
|
33
|
+
* `resolver.resolveInputSchemaTypeName('order') // → 'OrderInputSchema'`
|
|
34
|
+
*/
|
|
35
|
+
resolveInputSchemaTypeName(this: ResolverZod, name: string): string
|
|
21
36
|
/**
|
|
22
37
|
* Resolves the generated type name from the schema.
|
|
23
38
|
*
|
package/src/utils.ts
CHANGED
|
@@ -12,6 +12,99 @@ export function shouldCoerce(coercion: PluginZod['resolvedOptions']['coercion']
|
|
|
12
12
|
return !!coercion[type]
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* A codec for a schema node whose runtime type differs from its JSON wire type:
|
|
17
|
+
* the output (response) schema decodes wire → runtime, and the input (request)
|
|
18
|
+
* variant encodes runtime → wire.
|
|
19
|
+
*
|
|
20
|
+
* To support another codec type, append a `Codec` to `codecs` and route that
|
|
21
|
+
* type's printer node handler through `getCodec`.
|
|
22
|
+
*/
|
|
23
|
+
export type Codec = {
|
|
24
|
+
/**
|
|
25
|
+
* Whether this node is encoded/decoded by this codec.
|
|
26
|
+
*/
|
|
27
|
+
matches(node: ast.SchemaNode): boolean
|
|
28
|
+
/**
|
|
29
|
+
* Output direction (response): decode the wire value into the runtime type.
|
|
30
|
+
*/
|
|
31
|
+
decode(node: ast.SchemaNode): string
|
|
32
|
+
/**
|
|
33
|
+
* Input direction (request): encode the runtime value back to the wire value.
|
|
34
|
+
*/
|
|
35
|
+
encode(node: ast.SchemaNode): string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `dateType: 'date'` fields are typed as `Date` but travel as ISO `string`s.
|
|
40
|
+
* Output decodes `string → Date`; input encodes `Date → string`, preserving the
|
|
41
|
+
* `date` (`YYYY-MM-DD`) vs `date-time` precision carried on `node.format`.
|
|
42
|
+
*/
|
|
43
|
+
const dateCodec: Codec = {
|
|
44
|
+
matches(node) {
|
|
45
|
+
return node.type === 'date' && node.representation === 'date'
|
|
46
|
+
},
|
|
47
|
+
decode(node) {
|
|
48
|
+
return node.format === 'date' ? 'z.iso.date().transform((value) => new Date(value))' : 'z.iso.datetime().transform((value) => new Date(value))'
|
|
49
|
+
},
|
|
50
|
+
encode(node) {
|
|
51
|
+
return node.format === 'date' ? 'z.date().transform((value) => value.toISOString().slice(0, 10))' : 'z.date().transform((value) => value.toISOString())'
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Registered codecs, checked in order.
|
|
57
|
+
*/
|
|
58
|
+
const codecs: Array<Codec> = [dateCodec]
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns the codec for this node, or `undefined` when the node needs no
|
|
62
|
+
* encode/decode (its wire and runtime types match).
|
|
63
|
+
*/
|
|
64
|
+
export function getCodec(node: ast.SchemaNode | undefined): Codec | undefined {
|
|
65
|
+
if (!node) return undefined
|
|
66
|
+
return codecs.find((codec) => codec.matches(node))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns `true` when the node itself is encoded/decoded by a codec.
|
|
71
|
+
*/
|
|
72
|
+
export function hasCodec(node: ast.SchemaNode | undefined): boolean {
|
|
73
|
+
return getCodec(node) !== undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns `true` when the schema transitively contains a codec node —
|
|
78
|
+
* a value whose runtime type differs from its wire type (see {@link hasCodec}),
|
|
79
|
+
* so it must be decoded (response) or encoded (request) at the validation boundary.
|
|
80
|
+
* `$ref`s are followed via their resolved schema; a `seen` set guards cycles.
|
|
81
|
+
*/
|
|
82
|
+
export function containsCodec(node: ast.SchemaNode | undefined, seen: Set<string> = new Set()): boolean {
|
|
83
|
+
if (!node) return false
|
|
84
|
+
|
|
85
|
+
if (hasCodec(node)) return true
|
|
86
|
+
|
|
87
|
+
if (node.type === 'ref') {
|
|
88
|
+
if (!node.ref) return false
|
|
89
|
+
const refName = ast.extractRefName(node.ref)
|
|
90
|
+
if (refName) {
|
|
91
|
+
if (seen.has(refName)) return false
|
|
92
|
+
seen.add(refName)
|
|
93
|
+
}
|
|
94
|
+
const resolved = ast.syncSchemaRef(node)
|
|
95
|
+
if (resolved.type === 'ref') return false
|
|
96
|
+
return containsCodec(resolved, seen)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const children: Array<ast.SchemaNode | undefined> = []
|
|
100
|
+
if ('properties' in node && node.properties) children.push(...node.properties.map((prop) => prop.schema))
|
|
101
|
+
if ('items' in node && node.items) children.push(...node.items)
|
|
102
|
+
if ('members' in node && node.members) children.push(...node.members)
|
|
103
|
+
if ('additionalProperties' in node && node.additionalProperties && node.additionalProperties !== true) children.push(node.additionalProperties)
|
|
104
|
+
|
|
105
|
+
return children.some((child) => containsCodec(child, seen))
|
|
106
|
+
}
|
|
107
|
+
|
|
15
108
|
/**
|
|
16
109
|
* Collects all resolved schema names for an operation's parameters and responses
|
|
17
110
|
* into a single lookup object, useful for building imports and type references.
|