@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.
@@ -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 ZodPrinterEntry = { printer: ReturnType<typeof printerZod>; coercion: unknown; guidType: unknown; dateType: unknown }
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 imports = adapter.getImports(node, (schemaName) => ({
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 cyclicSchemas = new Set<string>(ctx.meta.circularNames)
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' && imports.map((imp) => <File.Import key={[node.name, imp.path].join('-')} root={meta.file.path} path={imp.path} name={imp.name} />)}
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({ schema, name, keysToOmit }: { schema: ast.SchemaNode | null; name: string; keysToOmit?: Array<string> | null }) {
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
- : cachedMini?.guidType === guidType
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
- : cachedStd?.coercion === coercion && cachedStd?.guidType === guidType && cachedStd?.dateType === dateType
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
- const paramSchemas = params.map((param) => renderSchemaEntry({ schema: param.schema, name: resolver.resolveParamName(node, param) }))
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 responseSchemas = node.responses.map((res) =>
145
- renderSchemaEntry({
146
- schema: res.content?.[0]?.schema ?? null,
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: res.content?.[0]?.keysToOmit,
149
- }),
150
- )
238
+ keysToOmit: primary?.keysToOmit,
239
+ })
240
+ })
151
241
 
152
- const responsesWithSchema = node.responses.filter((res) => res.content?.[0]?.schema)
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?.[0]?.schema
164
- ? adapter
165
- .getImports(res.content[0].schema, (schemaName) => ({
166
- name: resolver.resolveSchemaName(schemaName),
167
- path: '',
168
- }))
169
- .flatMap((imp) => (Array.isArray(imp.name) ? imp.name : [imp.name]))
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 requestSchema = node.requestBody?.content?.[0]?.schema
189
- ? renderSchemaEntry({
190
- schema: {
191
- ...node.requestBody.content![0]!.schema!,
192
- description: node.requestBody.description ?? node.requestBody.content![0]!.schema!.description,
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: node.requestBody.content![0]!.keysToOmit,
289
+ keysToOmit: entry.keysToOmit,
290
+ direction: 'input',
196
291
  })
197
- : null
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 { camelCase } from '@internals/utils'
2
- import { definePlugin, type Group } from '@kubb/core'
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
- if (node.representation === 'string') {
143
- return 'z.iso.date()'
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 shouldCoerce(this.options.coercion, 'dates') ? 'z.coerce.date()' : 'z.date()'
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
- const resolvedName = node.ref ? (this.options.resolver?.default(refName, 'function') ?? refName) : node.name
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.