typespec-http-zod 0.1.0
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/LICENSE +21 -0
- package/README.md +269 -0
- package/dist/src/api.d.ts +304 -0
- package/dist/src/api.js +1425 -0
- package/dist/src/constraints.d.ts +38 -0
- package/dist/src/constraints.js +68 -0
- package/dist/src/emitter.d.ts +13 -0
- package/dist/src/emitter.js +15 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/lib.d.ts +181 -0
- package/dist/src/lib.js +172 -0
- package/dist/src/registry.d.ts +69 -0
- package/dist/src/registry.js +421 -0
- package/dist/src/runtime.d.ts +84 -0
- package/dist/src/runtime.js +25 -0
- package/dist/src/streams.d.ts +40 -0
- package/dist/src/streams.js +40 -0
- package/dist/src/tsp-index.d.ts +12 -0
- package/dist/src/tsp-index.js +12 -0
- package/dist/src/types.d.ts +10 -0
- package/dist/src/types.js +254 -0
- package/dist/src/versioning.d.ts +58 -0
- package/dist/src/versioning.js +57 -0
- package/dist/src/zod.d.ts +124 -0
- package/dist/src/zod.js +1015 -0
- package/lib/main.tsp +4 -0
- package/package.json +94 -0
package/dist/src/zod.js
ADDED
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
import { getDiscriminatedUnion, getDiscriminator, getEncode, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
|
|
2
|
+
import { createMetadataInfo, isQueryParam, Visibility } from "@typespec/http";
|
|
3
|
+
import { applyConstraints } from "./constraints.js";
|
|
4
|
+
import { reportDiagnostic } from "./lib.js";
|
|
5
|
+
/**
|
|
6
|
+
* What a walk returns after reporting a refusal.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **Reporting does not unwind, which is the point.** A throw abandoned the compile at the first
|
|
9
|
+
* unrepresentable construct; reporting lets the walk carry on and name every one of them in a single
|
|
10
|
+
* run. The walk still has to return a `string`, and `z.never()` is the honest placeholder: valid
|
|
11
|
+
* Zod, rejects everything, and unreachable in practice because the error diagnostic has already
|
|
12
|
+
* failed the compile that would have written it.
|
|
13
|
+
*/
|
|
14
|
+
const UNREPRESENTABLE = "z.never()";
|
|
15
|
+
/**
|
|
16
|
+
* TypeSpec type → Zod source text.
|
|
17
|
+
*
|
|
18
|
+
* **Deliberately partial, and it fails loudly rather than guessing.** A general TypeSpec→Zod
|
|
19
|
+
* converter has to answer for every construct the language can express; this one only has to answer
|
|
20
|
+
* for the constructs our own specs are allowed to use, which is what makes it a bounded problem
|
|
21
|
+
* rather than an open-ended dependency (see the ADR on why `typespec-zod@0.0.0-68` is not in the
|
|
22
|
+
* runtime path). Anything outside that set throws with the offending type named — an emitter that
|
|
23
|
+
* silently produced `z.any()` for a construct it did not understand would push a hole all the way
|
|
24
|
+
* into `respond()`, where the whole point is that drift is caught.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* External module → the constants the emitted schemas reference from it.
|
|
28
|
+
*
|
|
29
|
+
* Collected during emission rather than pre-scanned: only the enums actually reachable from an
|
|
30
|
+
* operation should produce an import, and reachability is exactly what the walk already computes.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ **Scoped to one emission, not module-global-and-cleared.** The previous shape was a module
|
|
33
|
+
* `Map` emptied by whoever read it first, which is correct only while there is exactly one service
|
|
34
|
+
* and one read. A second `@service` namespace — a public surface beside an internal one — would
|
|
35
|
+
* have merged one surface's imports into the other's file: an unused import if lucky, a missing one
|
|
36
|
+
* if not. Neither is discoverable while there is only one service, so it is fixed before there are
|
|
37
|
+
* two rather than found by them. Same save/restore idiom as {@link withRefResolver}.
|
|
38
|
+
*/
|
|
39
|
+
let externalImports;
|
|
40
|
+
/**
|
|
41
|
+
* Enum name → its declared members, for every vocabulary the surface actually reaches.
|
|
42
|
+
*
|
|
43
|
+
* The spec is the single source for these. A consumer's shared package used to hold them as tuples
|
|
44
|
+
* and the spec merely *described* them for the document's benefit — two lists that agreed only by
|
|
45
|
+
* inspection, and by the time this flipped, two of them did not: `PersonalAttribute` published four
|
|
46
|
+
* of six values wrong (the charge-authentication answers an MR01 cannot be filed without) and
|
|
47
|
+
* `SubmissionStatusCode` published all five wrong. Generating them removes the second list.
|
|
48
|
+
*/
|
|
49
|
+
let vocabularies;
|
|
50
|
+
/**
|
|
51
|
+
* Whether a model is SEALED — closed to properties nobody declared.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ **A property of the model's own shape, not of where it is used.** This was previously "the
|
|
54
|
+
* model is reachable from a request body", computed by a whole-graph pre-pass. That rule is wrong in
|
|
55
|
+
* a way that is invisible while it happens to agree: there is one declaration per model, so adding a
|
|
56
|
+
* single operation that accepts `ProblemItem` in a body would flip `problemItemSchema` from
|
|
57
|
+
* stripping to rejecting **in every response that uses it too**. Strictness would be decided by the
|
|
58
|
+
* rest of the spec rather than by the model.
|
|
59
|
+
*
|
|
60
|
+
* ⚠️ **This mirrors `@typespec/openapi3`'s `shouldSealSchema` deliberately, line for line.** The
|
|
61
|
+
* emitted validator and the published document have to answer the same question the same way, and
|
|
62
|
+
* the only way to guarantee that is to ask it the same way — not to arrive at the same answer by a
|
|
63
|
+
* different route and measure that they still agree. The exception for models with derived models is
|
|
64
|
+
* theirs: sealing a base would make a subtype's own properties "unevaluated" against it.
|
|
65
|
+
*
|
|
66
|
+
* The one thing not mirrored is the `seal-object-schemas` option itself. openapi3 seals only when it
|
|
67
|
+
* is on; a validator that refuses a property the document permits is wrong in the other direction,
|
|
68
|
+
* so a consumer running with sealing off wants schemas that strip rather than reject. That is
|
|
69
|
+
* `seal-object-schemas` in `EmitterOptions`, defaulting to openapi3's own default.
|
|
70
|
+
*/
|
|
71
|
+
function isSealed(model) {
|
|
72
|
+
// `Record<never>` is closed whatever the option says — the spec has stated it outright, and
|
|
73
|
+
// openapi3 seals it for the same reason.
|
|
74
|
+
if (model.indexer !== undefined)
|
|
75
|
+
return isNeverType(model.indexer.value);
|
|
76
|
+
if (!sealObjectSchemas)
|
|
77
|
+
return false;
|
|
78
|
+
return !model.derivedModels.some(includeDerivedModel);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Whether `@typespec/openapi3` is sealing object schemas for this compile.
|
|
82
|
+
*
|
|
83
|
+
* ⚠️ **This emitter cannot see the other emitter's options, so the consumer states it twice.**
|
|
84
|
+
* Awkward, and the alternative is worse: assume sealing is on and a project running openapi3 with
|
|
85
|
+
* its default gets validators that reject properties its published document permits. Whichever way
|
|
86
|
+
* the two are configured, the conformance differential compares openness in both directions and
|
|
87
|
+
* fails on a mismatch — so a project that sets one and forgets the other is told immediately rather
|
|
88
|
+
* than shipping a contradiction.
|
|
89
|
+
*/
|
|
90
|
+
let sealObjectSchemas = false;
|
|
91
|
+
export function withSealedObjects(sealed, run) {
|
|
92
|
+
const previous = sealObjectSchemas;
|
|
93
|
+
sealObjectSchemas = sealed;
|
|
94
|
+
try {
|
|
95
|
+
return run();
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
sealObjectSchemas = previous;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The visibility the current walk is emitting for — which decides what counts as HTTP metadata.
|
|
103
|
+
*
|
|
104
|
+
* Visibility decides **which** metadata applies: `@statusCode` is applicable to a response and not
|
|
105
|
+
* to a request, so the same model projects differently in the two directions. `createMetadataInfo`
|
|
106
|
+
* is the predicate `@typespec/openapi3` itself uses, so asking it is what keeps the two artefacts
|
|
107
|
+
* from answering differently. Measured on `type/model/visibility`: openapi3 emits five components
|
|
108
|
+
* for one model, and before this we emitted one carrying every lifecycle property at once.
|
|
109
|
+
*
|
|
110
|
+
* ⚠️ **Position is a SECOND axis, and this only handles the first.** `@header` is metadata at a
|
|
111
|
+
* payload root and ordinary data nested inside a `@body`; `isPayloadProperty` takes a third
|
|
112
|
+
* argument, `ignoreMetadataAnnotations`, which openapi3 threads as emitter context
|
|
113
|
+
* (`schema-emitter.js:210`) and this does not pass. A metadata-carrying model used in **both**
|
|
114
|
+
* positions therefore still loses the header property where the document keeps it.
|
|
115
|
+
*
|
|
116
|
+
* That shape is rare rather than theoretical: it raises `metadata-ignored` from `@typespec/http`,
|
|
117
|
+
* which fires **zero** times across all 65 `@typespec/http-specs` scenarios,
|
|
118
|
+
* and one variant of it makes openapi3 itself fail with `duplicate-type-name`. It is a real gap with
|
|
119
|
+
* no known occurrence — recorded here rather than described as an incident, because it has not been
|
|
120
|
+
* one.
|
|
121
|
+
*
|
|
122
|
+
* Scoped rather than threaded through six signatures, matching the idiom the rest of this module
|
|
123
|
+
* already uses — the walk is deeply recursive and a parameter would have to be carried by every
|
|
124
|
+
* function whether it cared or not.
|
|
125
|
+
*/
|
|
126
|
+
let visibility = Visibility.Read;
|
|
127
|
+
let metadataInfo;
|
|
128
|
+
export function withVisibility(program, next, run) {
|
|
129
|
+
const previousVisibility = visibility;
|
|
130
|
+
const previousInfo = metadataInfo;
|
|
131
|
+
visibility = next;
|
|
132
|
+
metadataInfo ??= createMetadataInfo(program);
|
|
133
|
+
try {
|
|
134
|
+
return run();
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
visibility = previousVisibility;
|
|
138
|
+
metadataInfo = previousInfo;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Whether a property is optional AT THE CURRENT POSITION — PATCH makes update fields optional. */
|
|
142
|
+
function isOptionalAt(program, property) {
|
|
143
|
+
metadataInfo ??= createMetadataInfo(program);
|
|
144
|
+
return metadataInfo.isOptional(property, visibility);
|
|
145
|
+
}
|
|
146
|
+
/** Whether a property belongs in the BODY at the current position, rather than being metadata. */
|
|
147
|
+
function isPayloadProperty(program, property) {
|
|
148
|
+
metadataInfo ??= createMetadataInfo(program);
|
|
149
|
+
return metadataInfo.isPayloadProperty(property, visibility);
|
|
150
|
+
}
|
|
151
|
+
/** Whether this model is shaped differently at `visibility` than at the canonical `Read`. */
|
|
152
|
+
export function isTransformedBy(program, model, at) {
|
|
153
|
+
metadataInfo ??= createMetadataInfo(program);
|
|
154
|
+
return metadataInfo.isTransformed(model, at);
|
|
155
|
+
}
|
|
156
|
+
/** The visibility the current walk is emitting for — the registry keys declarations on it. */
|
|
157
|
+
export function currentVisibility() {
|
|
158
|
+
return visibility;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* openapi3's own rule for which derived models count — `@typespec/openapi3`'s `util.ts`.
|
|
162
|
+
*
|
|
163
|
+
* A template declaration is not a real subtype, and neither is an uninstantiated template; both
|
|
164
|
+
* would otherwise stop a base from being sealed for no reason a reader could see.
|
|
165
|
+
*/
|
|
166
|
+
function includeDerivedModel(model) {
|
|
167
|
+
return (!isTemplateDeclaration(model) &&
|
|
168
|
+
(model.templateMapper?.args === undefined ||
|
|
169
|
+
model.templateMapper.args.length === 0 ||
|
|
170
|
+
model.derivedModels.length > 0));
|
|
171
|
+
}
|
|
172
|
+
/** Record an enum's members so the vocabularies artefact can be generated from them. */
|
|
173
|
+
function noteVocabulary(name, values) {
|
|
174
|
+
vocabularies?.set(name, values);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Record every enum a service DECLARES, not merely every one its success bodies happen to reach.
|
|
178
|
+
*
|
|
179
|
+
* ⚠️ **A vocabulary is a fact about the contract, not about a code path.** Collecting them during the
|
|
180
|
+
* Zod walk meant an enum reachable only through an ERROR body was never emitted — which is exactly
|
|
181
|
+
* what happened to one surface's error-code enum: declared in the spec, published on every
|
|
182
|
+
* operation's failure responses, and absent from the generated vocabularies, so the one list a
|
|
183
|
+
* consumer branches on had to be hand-written beside the generated ones. Walking declarations removes
|
|
184
|
+
* the whole class rather than the instance.
|
|
185
|
+
*/
|
|
186
|
+
export function noteDeclaredVocabularies(enums) {
|
|
187
|
+
for (const target of enums) {
|
|
188
|
+
noteVocabulary(target.name, [...target.members.values()].map((member) => String(member.value ?? member.name)));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Record a named model's PROPERTY NAMES as a runtime vocabulary.
|
|
193
|
+
*
|
|
194
|
+
* ⚠️ **This exists because "a `Record` keyed by an enum" is inexpressible, and the workaround was
|
|
195
|
+
* about to cost a duplicated list.** `ProfileAttributes` is 150-odd known keys; naming them in the
|
|
196
|
+
* spec is what makes the published document state the constraint instead of hiding it behind a
|
|
197
|
+
* hand-written predicate. But a *type* cannot be iterated, and more than one layer usually needs
|
|
198
|
+
* the set at RUN time — so without this the keys would live in the spec AND as a hand-maintained
|
|
199
|
+
* tuple in a shared package — one vocabulary stated twice, which is precisely the
|
|
200
|
+
* drift the deleted predicate existed to prevent.
|
|
201
|
+
*
|
|
202
|
+
* ⚠️ **Selected by an emitter OPTION, deliberately not by a decorator.** A decorator would put a
|
|
203
|
+
* mark in the spec that no document carries — the exact objection that deleted the last four. This
|
|
204
|
+
* changes no schema, states nothing in the contract and enforces nothing; it decides which
|
|
205
|
+
* convenience artefact gets written beside the types, which is what an emitter option is for.
|
|
206
|
+
*
|
|
207
|
+
* Wire names, like everything else here: the document publishes one name per property, and a
|
|
208
|
+
* consumer checking a key against this tuple is checking what arrived.
|
|
209
|
+
*/
|
|
210
|
+
export function noteKeyVocabularies(program, models, wanted) {
|
|
211
|
+
const found = [];
|
|
212
|
+
for (const model of models) {
|
|
213
|
+
if (!wanted.includes(model.name))
|
|
214
|
+
continue;
|
|
215
|
+
found.push(model.name);
|
|
216
|
+
noteVocabulary(model.name, inheritedAndOwnProperties(model).map((property) => rawPropertyKey(program, property)));
|
|
217
|
+
}
|
|
218
|
+
return found;
|
|
219
|
+
}
|
|
220
|
+
/** Record that the emitted schemas reference `name` from `module`. */
|
|
221
|
+
export function noteExternalImport(module, name) {
|
|
222
|
+
if (externalImports === undefined) {
|
|
223
|
+
throw new Error("typespec-http-zod: an external import was noted outside `collectExternalImports`. " +
|
|
224
|
+
"Every walk must run inside one, or its imports land in another surface's file.");
|
|
225
|
+
}
|
|
226
|
+
const names = externalImports.get(module) ?? new Set();
|
|
227
|
+
names.add(name);
|
|
228
|
+
externalImports.set(module, names);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Which package the generated Zod imports its vocabularies from — `undefined` when there is none.
|
|
232
|
+
*
|
|
233
|
+
* ⚠️ **Not a constant, and no longer defaulted.** Each `@service` publishes its own contracts
|
|
234
|
+
* package, so a hardcoded specifier makes the second service's schemas import names that package
|
|
235
|
+
* does not export. Scoped the same way the strict-model set is, so a nested emission cannot leak
|
|
236
|
+
* into its parent.
|
|
237
|
+
*
|
|
238
|
+
* ⚠️ **It used to default to one repository's own package name.** A
|
|
239
|
+
* consumer who configured nothing got validators importing `SPEC_VOCABULARIES` from a package they
|
|
240
|
+
* had never heard of — output that looks right and does not resolve. There is no honest default for
|
|
241
|
+
* "the caller's own package", so the absence is the answer: with no package named, an enum is
|
|
242
|
+
* emitted inline and the output depends on nothing.
|
|
243
|
+
*/
|
|
244
|
+
let contractsPackageName;
|
|
245
|
+
const contractsPackage = () => contractsPackageName;
|
|
246
|
+
export function withContractsPackage(name, run) {
|
|
247
|
+
const previous = contractsPackageName;
|
|
248
|
+
contractsPackageName = name;
|
|
249
|
+
try {
|
|
250
|
+
return run();
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
contractsPackageName = previous;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Run `walk` with a fresh import collector, and hand back what it referenced.
|
|
258
|
+
*
|
|
259
|
+
* Throwing rather than lazily creating the map is deliberate: a walk that runs outside a collector
|
|
260
|
+
* is a wiring mistake whose only symptom would be a generated file missing an import, which the
|
|
261
|
+
* emitted TypeScript reports as an undefined name far from the cause.
|
|
262
|
+
*/
|
|
263
|
+
export function collectExternalImports(walk) {
|
|
264
|
+
const previousImports = externalImports;
|
|
265
|
+
const previousVocabularies = vocabularies;
|
|
266
|
+
const imports = new Map();
|
|
267
|
+
const vocabulary = new Map();
|
|
268
|
+
externalImports = imports;
|
|
269
|
+
vocabularies = vocabulary;
|
|
270
|
+
try {
|
|
271
|
+
return { result: walk(), imports, vocabularies: vocabulary };
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
externalImports = previousImports;
|
|
275
|
+
vocabularies = previousVocabularies;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** Refuse `type`, pointing at its declaration, and carry on so the rest is reported too. */
|
|
279
|
+
function refuse(program, type, why) {
|
|
280
|
+
reportDiagnostic(program, {
|
|
281
|
+
code: "unsupported-type",
|
|
282
|
+
target: type,
|
|
283
|
+
format: { artefact: "Zod", kind: type.kind, why },
|
|
284
|
+
});
|
|
285
|
+
return UNREPRESENTABLE;
|
|
286
|
+
}
|
|
287
|
+
/** Scalars we accept, and the Zod they become. Keyed by the TypeSpec scalar's declared name. */
|
|
288
|
+
const SCALARS = {
|
|
289
|
+
string: "z.string()",
|
|
290
|
+
boolean: "z.boolean()",
|
|
291
|
+
bytes: "z.string()",
|
|
292
|
+
int8: "z.number().int()",
|
|
293
|
+
int16: "z.number().int()",
|
|
294
|
+
int32: "z.number().int()",
|
|
295
|
+
int64: "z.number().int()",
|
|
296
|
+
safeint: "z.number().int()",
|
|
297
|
+
uint8: "z.number().int()",
|
|
298
|
+
uint16: "z.number().int()",
|
|
299
|
+
uint32: "z.number().int()",
|
|
300
|
+
uint64: "z.number().int()",
|
|
301
|
+
integer: "z.number().int()",
|
|
302
|
+
float: "z.number()",
|
|
303
|
+
float32: "z.number()",
|
|
304
|
+
float64: "z.number()",
|
|
305
|
+
decimal: "z.number()",
|
|
306
|
+
decimal128: "z.number()",
|
|
307
|
+
numeric: "z.number()",
|
|
308
|
+
/**
|
|
309
|
+
* Dates cross the wire as strings and are validated as strings.
|
|
310
|
+
*
|
|
311
|
+
* Not `z.iso.datetime()`: response schemas are permissive on purpose, and a
|
|
312
|
+
* stricter format check here would turn a producer emitting a legal-but-unexpected instant into a
|
|
313
|
+
* failed response for a caller who could have parsed it perfectly well.
|
|
314
|
+
*/
|
|
315
|
+
utcDateTime: "z.string()",
|
|
316
|
+
offsetDateTime: "z.string()",
|
|
317
|
+
plainDate: "z.string()",
|
|
318
|
+
plainTime: "z.string()",
|
|
319
|
+
duration: "z.string()",
|
|
320
|
+
url: "z.string()",
|
|
321
|
+
};
|
|
322
|
+
function scalarToZod(program, scalar) {
|
|
323
|
+
// `scalar unixTimestamp32 is utcDateTime;` with `@encode(int32)` on the SCALAR, not on each use.
|
|
324
|
+
const encoded = encodedTypeOf(program, scalar);
|
|
325
|
+
if (encoded !== undefined && encoded !== scalar) {
|
|
326
|
+
return applyConstraints(program, scalarToZod(program, encoded), scalar);
|
|
327
|
+
}
|
|
328
|
+
let current = scalar;
|
|
329
|
+
// Walk the `extends` chain so a named scalar (`scalar chNumber extends string`) resolves to the
|
|
330
|
+
// primitive it is built on rather than being rejected for having a project-specific name.
|
|
331
|
+
while (current !== undefined) {
|
|
332
|
+
const mapped = SCALARS[current.name];
|
|
333
|
+
// Constraints are read from the scalar the property NAMED, not from the primitive it resolves
|
|
334
|
+
// to, so `@pattern(…) scalar TrimmedString extends string` carries its rules to every use.
|
|
335
|
+
if (mapped !== undefined)
|
|
336
|
+
return applyConstraints(program, mapped, scalar);
|
|
337
|
+
current = current.baseScalar;
|
|
338
|
+
}
|
|
339
|
+
reportDiagnostic(program, {
|
|
340
|
+
code: "unsupported-scalar",
|
|
341
|
+
target: scalar,
|
|
342
|
+
format: { artefact: "Zod", name: scalar.name },
|
|
343
|
+
});
|
|
344
|
+
return UNREPRESENTABLE;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* A TypeSpec `enum` becomes a Zod enum over its member values, resolved through the generated
|
|
348
|
+
* vocabulary constant so the document and the runtime cannot describe different sets.
|
|
349
|
+
*
|
|
350
|
+
* ⚠️ **`@externalValues` used to be able to point that arrow the other way** — the spec deferring to
|
|
351
|
+
* a hand-written TypeScript tuple — and while it did, the members here described only the
|
|
352
|
+
* *document*. It is gone, and the tuples are generated from the spec.
|
|
353
|
+
*/
|
|
354
|
+
function enumToZod(_program, target) {
|
|
355
|
+
const values = [...target.members.values()].map((member) => member.value ?? member.name);
|
|
356
|
+
if (values.some((value) => typeof value !== "string")) {
|
|
357
|
+
return `z.union([${values.map((value) => `z.literal(${JSON.stringify(value)})`).join(", ")}])`;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Every string vocabulary resolves through the generated constant rather than being restated.
|
|
361
|
+
*
|
|
362
|
+
* The members are declared once, in the spec, and the vocabularies artefact is generated from them —
|
|
363
|
+
* so the document and the runtime cannot describe different vocabularies. This replaces the
|
|
364
|
+
* `@externalValues` decorator, which pointed the arrow the other way: the spec deferred to a
|
|
365
|
+
* hand-written TypeScript constant, which is the opposite of a spec being a source of truth.
|
|
366
|
+
*/
|
|
367
|
+
noteVocabulary(target.name, values);
|
|
368
|
+
/**
|
|
369
|
+
* Inline unless the consumer named a package to share the tuple through.
|
|
370
|
+
*
|
|
371
|
+
* The indirection exists so a SECOND package — another layer, a client — can branch on the same
|
|
372
|
+
* values without restating them. A consumer who has no such package wants a self-contained
|
|
373
|
+
* validator, and emitting a reference to one they never configured is the only way to get output
|
|
374
|
+
* that cannot resolve.
|
|
375
|
+
*/
|
|
376
|
+
const shared = contractsPackage();
|
|
377
|
+
if (shared === undefined) {
|
|
378
|
+
return `z.enum([${values.map((value) => JSON.stringify(value)).join(", ")}])`;
|
|
379
|
+
}
|
|
380
|
+
noteExternalImport(shared, "SPEC_VOCABULARIES");
|
|
381
|
+
return `z.enum(SPEC_VOCABULARIES.${target.name})`;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* A union becomes `z.enum` when every variant is a string literal, and `z.union` otherwise.
|
|
385
|
+
*
|
|
386
|
+
* `null` variants are lifted out into `.nullable()` rather than emitted as a member, because
|
|
387
|
+
* `z.union([z.string(), z.null()])` and `z.string().nullable()` differ in the error they produce and
|
|
388
|
+
* the latter is what a reader expects.
|
|
389
|
+
*/
|
|
390
|
+
/**
|
|
391
|
+
* Register a declaration the SPEC never wrote, under a name the document publishes.
|
|
392
|
+
*
|
|
393
|
+
* A `@discriminated` union with an envelope has no TypeSpec type for its wrapper — openapi3
|
|
394
|
+
* synthesises one (`tk.model.create`, `schema-emitter.js:436`) and publishes it as
|
|
395
|
+
* `PetWithEnvelopeCat`. The registry keys declarations on a `Type`, and there is no type to key on,
|
|
396
|
+
* so this is the seam that lets one be declared by name instead. Falls back to inlining when no
|
|
397
|
+
* registry is installed, which keeps the property-level walks working.
|
|
398
|
+
*/
|
|
399
|
+
let declareSynthetic;
|
|
400
|
+
export function withSyntheticDeclarations(hook, run) {
|
|
401
|
+
const previous = declareSynthetic;
|
|
402
|
+
declareSynthetic = hook;
|
|
403
|
+
try {
|
|
404
|
+
return run();
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
declareSynthetic = previous;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const capitalise = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
411
|
+
/** Whether a variant already carries the discriminator, under its declared or its wire name. */
|
|
412
|
+
function declaresDiscriminator(program, variant, property) {
|
|
413
|
+
if (variant.kind !== "Model")
|
|
414
|
+
return false;
|
|
415
|
+
return inheritedAndOwnProperties(variant).some((candidate) => candidate.name === property ||
|
|
416
|
+
resolveEncodedName(program, candidate, JSON_MEDIA_TYPE) === property);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* One variant of a `@discriminated` union, as it appears ON THE WIRE.
|
|
420
|
+
*
|
|
421
|
+
* ⚠️ **The union is not a choice between its variant types — it is a choice between wrappings of
|
|
422
|
+
* them**, and this emitter used to ignore the wrapping entirely. `z.discriminatedUnion("kind",
|
|
423
|
+
* [catSchema, dogSchema])` over models that carry no `kind` at all **matches nothing**: every
|
|
424
|
+
* request to a polymorphic endpoint failed, and the differential could not see it because it read a
|
|
425
|
+
* discriminated component as an object.
|
|
426
|
+
*
|
|
427
|
+
* `envelope: "object"` (the default) wraps the variant — `{kind: "cat", value: {…Cat}}` — and
|
|
428
|
+
* openapi3 publishes each wrapper as a component named `union.type.name + capitalize(variantName)`.
|
|
429
|
+
* Mirrored exactly, so the identifiers line up with the component names.
|
|
430
|
+
*
|
|
431
|
+
* `envelope: "none"` injects the discriminator into the variant — `{kind: "cat", …Cat}` — and is
|
|
432
|
+
* emitted **only when the variant declares that property itself**. Otherwise the document does not
|
|
433
|
+
* publish it and the union is refused; see `undeclared-discriminator`.
|
|
434
|
+
*/
|
|
435
|
+
function discriminatedVariant(program, union, variantName, variant, options) {
|
|
436
|
+
const inner = typeToZod(program, variant);
|
|
437
|
+
const tag = `${objectKey(options.discriminatorPropertyName)}: z.literal(${JSON.stringify(variantName)})`;
|
|
438
|
+
// The variant already carries the discriminator, so the document publishes it and there is
|
|
439
|
+
// nothing to add.
|
|
440
|
+
if (options.envelope === "none")
|
|
441
|
+
return inner;
|
|
442
|
+
const wrapper = `${sealObjectSchemas ? "z.strictObject" : "z.object"}({ ${tag}, ${objectKey(options.envelopePropertyName)}: ${inner} })`;
|
|
443
|
+
const name = `${union.name ?? ""}${capitalise(variantName)}`;
|
|
444
|
+
if (union.name === undefined)
|
|
445
|
+
return wrapper;
|
|
446
|
+
return declareSynthetic?.(name, () => wrapper) ?? wrapper;
|
|
447
|
+
}
|
|
448
|
+
function unionToZod(program, union) {
|
|
449
|
+
/**
|
|
450
|
+
* A `@discriminated` union becomes `z.discriminatedUnion`, not `z.union`.
|
|
451
|
+
*
|
|
452
|
+
* Both accept the same values, but a plain union reports "no variant matched" and lists every
|
|
453
|
+
* failure, while a discriminated one reads the tag and reports the single rule that was actually
|
|
454
|
+
* broken. These unions exist to carry cross-field rules that used to hide in a hand-written
|
|
455
|
+
* predicate — publishing the rule and then reporting it unreadably would only move the problem.
|
|
456
|
+
*/
|
|
457
|
+
const [discriminated] = getDiscriminatedUnion(program, union);
|
|
458
|
+
if (discriminated !== undefined) {
|
|
459
|
+
const options = discriminated.options;
|
|
460
|
+
if (discriminated.defaultVariant !== undefined) {
|
|
461
|
+
// "Anything else" has no discriminator value to switch on. Refused by name rather than
|
|
462
|
+
// silently dropped — no corpus scenario exercises it, so guessing would be untested code.
|
|
463
|
+
return refuse(program, union, "a discriminated union with a default variant");
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* ⚠️ **Refused, not compensated for.** With no envelope the discriminator lives inside the
|
|
467
|
+
* variant, and openapi3 publishes the variant unchanged — so unless the variant declares the
|
|
468
|
+
* property, the document names a discriminator it never requires, which OpenAPI 3.1 forbids.
|
|
469
|
+
* Injecting it here would enforce a rule the contract does not state: correct against the wire,
|
|
470
|
+
* derived from nothing anybody published, and indistinguishable from a bug to every other
|
|
471
|
+
* reader. A validator may only enforce what the document says.
|
|
472
|
+
*/
|
|
473
|
+
if (options.envelope === "none") {
|
|
474
|
+
const undeclared = [...discriminated.variants.values()].filter((variant) => !declaresDiscriminator(program, variant, options.discriminatorPropertyName));
|
|
475
|
+
if (undeclared.length > 0) {
|
|
476
|
+
reportDiagnostic(program, {
|
|
477
|
+
code: "undeclared-discriminator",
|
|
478
|
+
target: union,
|
|
479
|
+
format: {
|
|
480
|
+
name: union.name ?? "an anonymous union",
|
|
481
|
+
property: options.discriminatorPropertyName,
|
|
482
|
+
variant: undeclared[0]?.name ?? "one of its variants",
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
return UNREPRESENTABLE;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const members = [...discriminated.variants.entries()].map(([name, variant]) => discriminatedVariant(program, union, name, variant, options));
|
|
489
|
+
const property = options.discriminatorPropertyName;
|
|
490
|
+
return `z.discriminatedUnion(${JSON.stringify(property)}, [${members.join(", ")}])`;
|
|
491
|
+
}
|
|
492
|
+
const variants = [...union.variants.values()].map((variant) => variant.type);
|
|
493
|
+
const nullable = variants.some((type) => type.kind === "Intrinsic" && type.name === "null");
|
|
494
|
+
const real = variants.filter((type) => !(type.kind === "Intrinsic" && type.name === "null"));
|
|
495
|
+
if (real.length === 0) {
|
|
496
|
+
reportDiagnostic(program, {
|
|
497
|
+
code: "empty-union",
|
|
498
|
+
target: union,
|
|
499
|
+
format: { why: "every variant is `null`" },
|
|
500
|
+
});
|
|
501
|
+
return UNREPRESENTABLE;
|
|
502
|
+
}
|
|
503
|
+
const suffix = nullable ? ".nullable()" : "";
|
|
504
|
+
if (real.every((type) => type.kind === "String")) {
|
|
505
|
+
const literals = real.map((type) => JSON.stringify(type.value));
|
|
506
|
+
return `z.enum([${literals.join(", ")}])${suffix}`;
|
|
507
|
+
}
|
|
508
|
+
if (real.length === 1)
|
|
509
|
+
return `${typeToZod(program, real[0])}${suffix}`;
|
|
510
|
+
return `z.union([${real.map((type) => typeToZod(program, type)).join(", ")}])${suffix}`;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* TypeSpec models arrays and dictionaries as a `Model` carrying an **indexer**.
|
|
514
|
+
*
|
|
515
|
+
* ⚠️ **Keyed on the indexer, not on the model's name.** This used to test `name === "Record"`, which
|
|
516
|
+
* silently lost the indexer of any model *derived* from one — `model ProfileAttributes is
|
|
517
|
+
* Record<string | float64 | boolean>` emitted `z.object({})`, a schema that accepts an object and
|
|
518
|
+
* then strips every key in it. For the profile attributes that is silent data loss on a write, and
|
|
519
|
+
* nothing downstream could have told the difference between "the caller sent nothing" and "we threw
|
|
520
|
+
* it away". A named model is still declared and referenced normally; only its *body* comes from the
|
|
521
|
+
* indexer.
|
|
522
|
+
*/
|
|
523
|
+
/** `unknown` — the indexer value of a `...Record<unknown>` spread, which is plain permissiveness. */
|
|
524
|
+
function isUnknownType(type) {
|
|
525
|
+
return type.kind === "Intrinsic" && type.name === "unknown";
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Every property the model declares, **including the ones it inherits**.
|
|
529
|
+
*
|
|
530
|
+
* ⚠️ **`model.properties` holds only a model's OWN properties, and `baseModel` appeared nowhere in
|
|
531
|
+
* this emitter.** `model Extension extends Element` reaches the document as `allOf: [{$ref: Element}]`
|
|
532
|
+
* beside the derived model's own properties, and the sealing keyword openapi3 uses —
|
|
533
|
+
* `unevaluatedProperties` — is chosen precisely because it sees through that. So the document said
|
|
534
|
+
* `Extension` carries `level` **and** the inherited `extension`, while this emitter produced
|
|
535
|
+
* `z.object({level}).strict()`: the inherited property gone, and the model closed against it. The
|
|
536
|
+
* conformance scenario's own documented request body would have been rejected with a 400 naming a key
|
|
537
|
+
* the contract requires.
|
|
538
|
+
*
|
|
539
|
+
* Invisible for two reasons at once. The spec it was first built against declared no `extends` at
|
|
540
|
+
* all, and the differential
|
|
541
|
+
* skipped every schema carrying an `allOf` — so 28 of 226 components were not divergent, they were
|
|
542
|
+
* never compared.
|
|
543
|
+
*
|
|
544
|
+
* Flattened rather than composed. `z.object(...).extend(...)` would mirror the document's structure
|
|
545
|
+
* more literally and cannot be used here: `.extend()` reads `shape` eagerly, so a base whose property
|
|
546
|
+
* refers back to the derived model — `type/model/inheritance/recursive`, the exact case — would throw
|
|
547
|
+
* during module initialisation. Flattening asks nothing of the base but its properties.
|
|
548
|
+
*/
|
|
549
|
+
export function inheritedAndOwnProperties(model) {
|
|
550
|
+
const chain = [];
|
|
551
|
+
for (let link = model; link !== undefined; link = link.baseModel) {
|
|
552
|
+
chain.unshift(link);
|
|
553
|
+
}
|
|
554
|
+
// Base first, so a derived model REDECLARING a property overrides rather than duplicates it.
|
|
555
|
+
const byName = new Map();
|
|
556
|
+
for (const link of chain) {
|
|
557
|
+
for (const property of link.properties.values())
|
|
558
|
+
byName.set(property.name, property);
|
|
559
|
+
}
|
|
560
|
+
return [...byName.values()];
|
|
561
|
+
}
|
|
562
|
+
/** The indexer in force, which a model can inherit — `model X extends Record<unknown>`. */
|
|
563
|
+
export function effectiveIndexer(model) {
|
|
564
|
+
for (let link = model; link !== undefined; link = link.baseModel) {
|
|
565
|
+
if (link.indexer !== undefined)
|
|
566
|
+
return link.indexer;
|
|
567
|
+
}
|
|
568
|
+
return undefined;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* The subtypes a `@discriminator` base stands for, in declaration order.
|
|
572
|
+
*
|
|
573
|
+
* Filtered by openapi3's own {@link includeDerivedModel} so a template declaration is not mistaken
|
|
574
|
+
* for a real subtype — the same rule that decides whether a base may be sealed.
|
|
575
|
+
*/
|
|
576
|
+
export function discriminatedSubtypes(program, model) {
|
|
577
|
+
if (getDiscriminator(program, model) === undefined)
|
|
578
|
+
return [];
|
|
579
|
+
return model.derivedModels.filter((derived) => includeDerivedModel(derived));
|
|
580
|
+
}
|
|
581
|
+
function modelToZod(program, model) {
|
|
582
|
+
/**
|
|
583
|
+
* ⚠️ **A `@discriminator` base is a CHOICE between its subtypes, not a shape of its own.**
|
|
584
|
+
*
|
|
585
|
+
* `@discriminator("kind") model Bird { kind: string; wingspan: int32 }` with four models extending
|
|
586
|
+
* it reaches OpenAPI as a component carrying `discriminator: {propertyName, mapping}`, and the
|
|
587
|
+
* mapping is an instruction: validate the body against whichever subtype the discriminator names.
|
|
588
|
+
* Emitting the base's own properties instead produced `z.object({kind: z.string(), wingspan})`,
|
|
589
|
+
* which accepts `{kind: "eagle"}` while ignoring everything `Eagle` actually declares — a
|
|
590
|
+
* polymorphic endpoint validating none of its polymorphism.
|
|
591
|
+
*
|
|
592
|
+
* `z.discriminatedUnion` is the faithful encoding and composes both ways this corpus needs.
|
|
593
|
+
* Measured on Zod 4.4.3: a union may be an OPTION of another union, which is how
|
|
594
|
+
* `Fish → Shark → SawShark` works; and an option carrying a getter that refers back to the union
|
|
595
|
+
* being defined does **not** throw, because the discriminator is read without enumerating the rest
|
|
596
|
+
* of the shape. `Eagle.friends?: Bird[]` is exactly that, and it decides whether polymorphism and
|
|
597
|
+
* recursion can coexist at all.
|
|
598
|
+
*/
|
|
599
|
+
const subtypes = discriminatedSubtypes(program, model);
|
|
600
|
+
if (subtypes.length > 0) {
|
|
601
|
+
const discriminator = getDiscriminator(program, model);
|
|
602
|
+
const options = subtypes.map((derived) => typeToZod(program, derived));
|
|
603
|
+
return `z.discriminatedUnion(${JSON.stringify(discriminator?.propertyName)}, [${options.join(", ")}])`;
|
|
604
|
+
}
|
|
605
|
+
const indexer = effectiveIndexer(model);
|
|
606
|
+
const indexerKey = indexer?.key.name;
|
|
607
|
+
const declared = inheritedAndOwnProperties(model);
|
|
608
|
+
if (indexerKey === "integer" && indexer !== undefined) {
|
|
609
|
+
return applyConstraints(program, `z.array(${typeToZod(program, indexer.value)})`, model);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* ⚠️ **A string indexer only means "dictionary" when the model declares NO properties.**
|
|
613
|
+
*
|
|
614
|
+
* `model X { id: string; ...Record<unknown> }` — the standard TypeSpec way to say "these fields,
|
|
615
|
+
* plus anything else", and what `@loose` was replaced by — carries both. Returning
|
|
616
|
+
* `z.record(...)` for it emits a schema that accepts an object and then **strips every declared
|
|
617
|
+
* field**, so `respond()`'s drift detection degrades to "is it an object" and the pinned fields
|
|
618
|
+
* silently stop being checked. This function's own history is the other half of the same mistake:
|
|
619
|
+
* it once keyed on the model's *name*, and lost the indexer of anything derived from `Record`.
|
|
620
|
+
*/
|
|
621
|
+
if (indexerKey === "string" && indexer !== undefined && declared.length === 0) {
|
|
622
|
+
return applyConstraints(program, `z.record(z.string(), ${typeToZod(program, indexer.value)})`, model);
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Permissiveness, read from the INDEXER and from nothing else.
|
|
626
|
+
*
|
|
627
|
+
* `...Record<unknown>` is the standard TypeSpec construct and the one `@typespec/openapi3` can
|
|
628
|
+
* see, so it is the only source. There was a second — `@loose`, a decorator of ours, invisible to
|
|
629
|
+
* the document — and it is gone: a model that accepted unknown properties while the published
|
|
630
|
+
* schema said otherwise is a runtime disagreeing with its own contract.
|
|
631
|
+
*
|
|
632
|
+
* An indexer over something other than `unknown` is a typed catchall, where `.loose()` would drop
|
|
633
|
+
* that value type on the floor.
|
|
634
|
+
*/
|
|
635
|
+
const indexerValue = indexerKey === "string" ? indexer?.value : undefined;
|
|
636
|
+
const suffix = indexerValue !== undefined && isUnknownType(indexerValue)
|
|
637
|
+
? ".loose()"
|
|
638
|
+
: indexerValue === undefined
|
|
639
|
+
? ""
|
|
640
|
+
: `.catchall(${typeToZod(program, indexerValue)})`;
|
|
641
|
+
/**
|
|
642
|
+
* A request model REJECTS an unrecognised field rather than stripping it.
|
|
643
|
+
*
|
|
644
|
+
* Silently dropping something a caller sent is the same failure this whole surface exists to
|
|
645
|
+
* remove, one layer out: the request succeeds and does less than it was asked to, with nothing
|
|
646
|
+
* anywhere reporting it. `appointedOn` went missing for exactly that reason and surfaced as a
|
|
647
|
+
* confusing downstream 400 instead of a loud one naming the key.
|
|
648
|
+
*/
|
|
649
|
+
const strict = suffix === "" && isSealed(model) ? ".strict()" : "";
|
|
650
|
+
/**
|
|
651
|
+
* ⚠️ **HTTP metadata is not payload.** A `@header` or `@statusCode` property describes the envelope,
|
|
652
|
+
* so it never appears in the body — `@typespec/openapi3` strips it from the schema it emits, and a
|
|
653
|
+
* validator that kept it would demand a field the wire never carries.
|
|
654
|
+
*
|
|
655
|
+
* Found the moment a model carried its own content-type: RFC 9457 puts
|
|
656
|
+
* `@header("content-type") contentType: "application/problem+json"` on the problem model, the
|
|
657
|
+
* document correctly described a body without it, and this emitter produced
|
|
658
|
+
* `contentType: z.literal(…)` as a required member. Two artefacts from one source, disagreeing
|
|
659
|
+
* about the shape of a body — the exact contradiction ADR 0005 exists to make impossible.
|
|
660
|
+
*/
|
|
661
|
+
/**
|
|
662
|
+
* ⚠️ **A property that refers back into an unfinished declaration is emitted as a GETTER.**
|
|
663
|
+
*
|
|
664
|
+
* `model InnerModel { children?: InnerModel[] }` is a plain recursive tree, and openapi3 publishes
|
|
665
|
+
* it as a self-`$ref`. Emitting `children: z.array(innerModelSchema)` reads the `const` inside its
|
|
666
|
+
* own initialiser, so the module throws on import; deferring the reference behind a getter is the
|
|
667
|
+
* idiom Zod documents for exactly this, and it is the only one that keeps `z.infer` inferring the
|
|
668
|
+
* recursive type rather than collapsing to `any`. `z.lazy(() => innerModelSchema)` runs correctly
|
|
669
|
+
* but does not typecheck — measured: `TS7022`, "implicitly has type 'any' because it does not have
|
|
670
|
+
* a type annotation and is referenced directly or indirectly in its own initializer".
|
|
671
|
+
*/
|
|
672
|
+
const members = declared
|
|
673
|
+
.filter((property) => isPayloadProperty(program, property))
|
|
674
|
+
.map((property) => {
|
|
675
|
+
const key = propertyKey(program, property);
|
|
676
|
+
const { value, deferred } = captureBackEdges(() => propertyToZod(program, property));
|
|
677
|
+
return {
|
|
678
|
+
deferred,
|
|
679
|
+
text: deferred ? `\tget ${key}() {\n\t\treturn ${value};\n\t},` : `\t${key}: ${value},`,
|
|
680
|
+
};
|
|
681
|
+
});
|
|
682
|
+
const entries = members.map((member) => member.text);
|
|
683
|
+
/**
|
|
684
|
+
* A model-level `@refine` goes through the same path as every other constraint, so the predicate's
|
|
685
|
+
* import is recorded exactly once, in one place. Handling it separately here is what dropped the
|
|
686
|
+
* import the first time — the emitted schema referenced a name it never imported, and only the
|
|
687
|
+
* compile check in `test/emit.test.ts` noticed.
|
|
688
|
+
*
|
|
689
|
+
* `.refine()` returns an effect rather than an object, so `applyConstraints` appending it after
|
|
690
|
+
* `.loose()` is also the only order that works.
|
|
691
|
+
*/
|
|
692
|
+
/**
|
|
693
|
+
* ⚠️ **A deferred property forces the CONSTRUCTOR form of the object.**
|
|
694
|
+
*
|
|
695
|
+
* Measured on Zod 4.4.3: `.strict()`, `.loose()` and `.catchall()` all read `shape` eagerly, so
|
|
696
|
+
* each one fires the getter during module initialisation and throws `Cannot access 'X' before
|
|
697
|
+
* initialization` — the very thing the getter exists to avoid. `z.strictObject` and
|
|
698
|
+
* `z.looseObject` take the same shape without reading it. The suffix form stays everywhere else,
|
|
699
|
+
* so a model that does not recurse emits exactly the text it emitted before.
|
|
700
|
+
*
|
|
701
|
+
* A **typed** catchall has no constructor form to move to, so there is nowhere safe to put the
|
|
702
|
+
* getter and the cycle is genuinely unrepresentable here — that is a refusal, and it is named.
|
|
703
|
+
*/
|
|
704
|
+
const lazily = members.some((member) => member.deferred);
|
|
705
|
+
const constructible = suffix === "" || suffix === ".loose()";
|
|
706
|
+
if (lazily && !constructible) {
|
|
707
|
+
reportDiagnostic(program, {
|
|
708
|
+
code: "circular-model",
|
|
709
|
+
target: model,
|
|
710
|
+
format: { name: model.name === "" ? "an anonymous model" : model.name },
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
const shape = entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
714
|
+
const body = lazily && constructible
|
|
715
|
+
? `${suffix === ".loose()" ? "z.looseObject" : strict === ".strict()" ? "z.strictObject" : "z.object"}(${shape})`
|
|
716
|
+
: `z.object(${shape})${suffix}${strict}`;
|
|
717
|
+
return applyConstraints(program, body, model);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* A name as an object key — quoted when it is not a legal JavaScript identifier.
|
|
721
|
+
*
|
|
722
|
+
* ⚠️ **A TypeSpec property can be named anything**, and the corpus proves it: `parameters/spread`
|
|
723
|
+
* declares `` `x-ms-test-header`: string ``. A parameter walk assumed the property name was always
|
|
724
|
+
* safe — its docblock even argued that using the property name rather than the wire name was what
|
|
725
|
+
* avoided this — and emitted `x-ms-test-header: z.string()`, a file that does not parse.
|
|
726
|
+
*
|
|
727
|
+
* Exported so there is one answer to this question. There were two sites and only one of them knew.
|
|
728
|
+
*/
|
|
729
|
+
export function objectKey(name) {
|
|
730
|
+
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* The name a property carries **on the wire**, which is not always the name the spec gave it.
|
|
734
|
+
*
|
|
735
|
+
* ⚠️ **`@encodedName` is a first-party decorator, and a decorator is a declaration rather than a
|
|
736
|
+
* behaviour.** It attaches metadata to the type graph and does nothing on its own; every emitter has
|
|
737
|
+
* to go and ask. `@typespec/openapi3` asks — `resolveEncodedName` at `schema-emitter.js:214` — so the
|
|
738
|
+
* document publishes the wire name. This emitter did not, so the validator was keyed on the TypeSpec
|
|
739
|
+
* name and a request carrying exactly what the contract required failed twice over: the declared
|
|
740
|
+
* property was missing, and the one actually sent was an unrecognised key a sealed model rejects.
|
|
741
|
+
*
|
|
742
|
+
* The corpus scenario for this is called "Testing that you send the right JSON name on the wire",
|
|
743
|
+
* and it sat in the green column throughout — compiling cleanly while emitting the wrong key.
|
|
744
|
+
*
|
|
745
|
+
* **The domain therefore sees the wire name too**, because `types.ts` resolves it identically. A
|
|
746
|
+
* validator that renamed on the way through would be a transform, which JSON Schema cannot express
|
|
747
|
+
* and no document could state — the same reason `@trimmed` was removed.
|
|
748
|
+
*/
|
|
749
|
+
function propertyKey(program, property) {
|
|
750
|
+
return objectKey(rawPropertyKey(program, property));
|
|
751
|
+
}
|
|
752
|
+
/** The same wire name, unquoted — for a vocabulary tuple, where the value is data rather than code. */
|
|
753
|
+
function rawPropertyKey(program, property) {
|
|
754
|
+
return resolveEncodedName(program, property, JSON_MEDIA_TYPE);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* The media type wire names are resolved for.
|
|
758
|
+
*
|
|
759
|
+
* Fixed rather than threaded: everything this emitter produces validates a JSON body, and
|
|
760
|
+
* `@encodedName` is per-media-type. A surface serving XML as well would need this carried from the
|
|
761
|
+
* operation's content type, as openapi3 does — and would need far more than a name to be correct.
|
|
762
|
+
*/
|
|
763
|
+
const JSON_MEDIA_TYPE = "application/json";
|
|
764
|
+
/**
|
|
765
|
+
* The type a value takes **on the wire**, which `@encode` can make different from its declared one.
|
|
766
|
+
*
|
|
767
|
+
* ⚠️ **A first-party decorator, and a decorator is a declaration rather than a behaviour** — every
|
|
768
|
+
* emitter has to go and ask. `@typespec/openapi3` asks, so the document publishes the encoded type;
|
|
769
|
+
* this emitter did not, and validated the DECLARED one. Measured across the corpus, 30 properties
|
|
770
|
+
* where the two disagreed, and each disagreement rejects a body the contract calls valid:
|
|
771
|
+
*
|
|
772
|
+
* `@encode("csv") string[]` document `string` we emitted an array
|
|
773
|
+
* `@encode(seconds, float) duration` document `number` we emitted a string
|
|
774
|
+
* `@encode(string) int32` document `string` we emitted a number
|
|
775
|
+
* `@encode(unixTimestamp) utcDateTime` document `integer` we emitted a string
|
|
776
|
+
*
|
|
777
|
+
* Exactly the shape of the `@encodedName` defect, found by exactly the same kind of arm — there, the
|
|
778
|
+
* key a property goes by; here, the type it goes as.
|
|
779
|
+
*
|
|
780
|
+
* ⚠️ **An array encoding on a QUERY parameter is a style, not a type change — and this docblock used
|
|
781
|
+
* to say the opposite.** It claimed the array encodings needed no special case "because a
|
|
782
|
+
* comma-delimited list IS a string on the wire". True of the wire, and the wrong conclusion: the
|
|
783
|
+
* document keeps `type: array` and publishes `style: pipeDelimited` beside it, because the parameter
|
|
784
|
+
* IS a list and the style says how it was flattened. Emitting `z.string()` made the validator
|
|
785
|
+
* disagree with the contract and gave the handler a string where the operation declared `string[]`.
|
|
786
|
+
*
|
|
787
|
+
* The rule is openapi3's own, from `applyEncoding`:
|
|
788
|
+
*
|
|
789
|
+
* ```js
|
|
790
|
+
* if (isQueryParam(program, typespecType) && isParameterStyleEncoding(encodeData.encoding)) {
|
|
791
|
+
* return targetObject; // the type is left alone
|
|
792
|
+
* }
|
|
793
|
+
* ```
|
|
794
|
+
*
|
|
795
|
+
* Everywhere else the encoding really does change the type — `@encode(unixTimestamp) utcDateTime` is
|
|
796
|
+
* an integer — so this narrows to exactly the case the reference implementation narrows to. The
|
|
797
|
+
* flattening is undone at the parameter, by {@link COLLECTION_DELIMITERS}.
|
|
798
|
+
*/
|
|
799
|
+
const PARAMETER_STYLE_ENCODINGS = new Set([
|
|
800
|
+
"ArrayEncoding.pipeDelimited",
|
|
801
|
+
"ArrayEncoding.spaceDelimited",
|
|
802
|
+
"ArrayEncoding.commaDelimited",
|
|
803
|
+
"ArrayEncoding.newlineDelimited",
|
|
804
|
+
]);
|
|
805
|
+
function encodedTypeOf(program, target) {
|
|
806
|
+
const encoded = getEncode(program, target);
|
|
807
|
+
if (encoded === undefined)
|
|
808
|
+
return undefined;
|
|
809
|
+
if (target.kind === "ModelProperty" &&
|
|
810
|
+
isQueryParam(program, target) &&
|
|
811
|
+
PARAMETER_STYLE_ENCODINGS.has(encoded.encoding ?? "")) {
|
|
812
|
+
return undefined;
|
|
813
|
+
}
|
|
814
|
+
return encoded.type;
|
|
815
|
+
}
|
|
816
|
+
export function propertyToZod(program, property) {
|
|
817
|
+
/**
|
|
818
|
+
* ⚠️ **Constraints go INSIDE `.nullable()`, not after it.**
|
|
819
|
+
*
|
|
820
|
+
* `z.string().nullable().min(1)` does not compile — `.min` is not on `ZodNullable` — so a property
|
|
821
|
+
* typed `string | null` with `@minLength(1)` has to emit `z.string().min(1).nullable()`. The
|
|
822
|
+
* nullability is peeled off here, the constraints applied to what is underneath, and the wrapper
|
|
823
|
+
* put back. Found by a consumer's own typecheck on a nullable-and-constrained property.
|
|
824
|
+
*
|
|
825
|
+
* `.optional()` stays outermost: a constraint after it would apply to the optionality rather than
|
|
826
|
+
* to the value.
|
|
827
|
+
*/
|
|
828
|
+
const nullable = nullableInnerOf(property.type);
|
|
829
|
+
// `@encode` on the property wins over its declared type — see `encodedTypeOf`. The nullable
|
|
830
|
+
// wrapper is kept: `@encode` changes what the value IS, not whether it may be absent.
|
|
831
|
+
const encoded = encodedTypeOf(program, property);
|
|
832
|
+
const declared = encoded ?? nullable ?? property.type;
|
|
833
|
+
const base = nullable === undefined || encoded !== undefined
|
|
834
|
+
? applyConstraints(program, typeToZod(program, declared), property)
|
|
835
|
+
: `${applyConstraints(program, typeToZod(program, declared), property)}.nullable()`;
|
|
836
|
+
/**
|
|
837
|
+
* A TypeSpec default (`basis: string = "s1159"`) becomes `.default(…)`, which in Zod also makes
|
|
838
|
+
* the field optional on the way in. Dropping it turned an optional field with a fallback into a
|
|
839
|
+
* required one — callers omitting it would start getting a 400 for a request that used to work.
|
|
840
|
+
*/
|
|
841
|
+
const defaulted = defaultOf(program, property);
|
|
842
|
+
if (defaulted !== undefined)
|
|
843
|
+
return `${base}.default(${defaulted})`;
|
|
844
|
+
/**
|
|
845
|
+
* ⚠️ **Optionality is a function of the position too, not only of the `?` in the spec.**
|
|
846
|
+
*
|
|
847
|
+
* TypeSpec's PATCH semantics make a property optional in an update request even where the model
|
|
848
|
+
* declares it required — an update sends the fields it is changing. The document says so
|
|
849
|
+
* (`VisibilityModelUpdate` requires nothing) and a validator that still demanded the property
|
|
850
|
+
* would reject every partial update the contract invites. `metadataInfo.isOptional` is the same
|
|
851
|
+
* predicate `@typespec/openapi3` uses to decide the `required` list.
|
|
852
|
+
*/
|
|
853
|
+
return property.optional || isOptionalAt(program, property) ? `${base}.optional()` : base;
|
|
854
|
+
}
|
|
855
|
+
/** A property's declared default, as a JS literal, or `undefined` when it has none. */
|
|
856
|
+
function defaultOf(program, property) {
|
|
857
|
+
const value = property.defaultValue;
|
|
858
|
+
if (value === undefined)
|
|
859
|
+
return undefined;
|
|
860
|
+
if (value.valueKind === "StringValue")
|
|
861
|
+
return JSON.stringify(value.value);
|
|
862
|
+
if (value.valueKind === "NumericValue")
|
|
863
|
+
return String(value.value.asNumber());
|
|
864
|
+
if (value.valueKind === "BooleanValue")
|
|
865
|
+
return String(value.value);
|
|
866
|
+
if (value.valueKind === "EnumValue") {
|
|
867
|
+
const member = value.value;
|
|
868
|
+
return JSON.stringify(member.value ?? member.name);
|
|
869
|
+
}
|
|
870
|
+
// `attendees?: string[] = #[]` — an empty-collection default, emitted as `.default([])`.
|
|
871
|
+
// Only empty ones: a populated literal default would need each element rendered, and no schema here
|
|
872
|
+
// has one, so it stays unsupported rather than half-implemented.
|
|
873
|
+
if (value.valueKind === "ArrayValue") {
|
|
874
|
+
const values = value.values ?? [];
|
|
875
|
+
if (values.length === 0)
|
|
876
|
+
return "[]";
|
|
877
|
+
reportDiagnostic(program, {
|
|
878
|
+
code: "unsupported-default",
|
|
879
|
+
target: property,
|
|
880
|
+
format: { why: "a non-empty array" },
|
|
881
|
+
});
|
|
882
|
+
return UNREPRESENTABLE;
|
|
883
|
+
}
|
|
884
|
+
if (value.valueKind === "ObjectValue") {
|
|
885
|
+
const properties = value.properties;
|
|
886
|
+
if (properties === undefined || properties.size === 0)
|
|
887
|
+
return "{}";
|
|
888
|
+
reportDiagnostic(program, {
|
|
889
|
+
code: "unsupported-default",
|
|
890
|
+
target: property,
|
|
891
|
+
format: { why: "a non-empty object" },
|
|
892
|
+
});
|
|
893
|
+
return UNREPRESENTABLE;
|
|
894
|
+
}
|
|
895
|
+
reportDiagnostic(program, {
|
|
896
|
+
code: "unsupported-default",
|
|
897
|
+
target: property,
|
|
898
|
+
format: { why: `kind "${value.valueKind}"` },
|
|
899
|
+
});
|
|
900
|
+
return UNREPRESENTABLE;
|
|
901
|
+
}
|
|
902
|
+
/** The non-null half of a two-variant `T | null` union, or `undefined` if it is not one. */
|
|
903
|
+
function nullableInnerOf(type) {
|
|
904
|
+
if (type.kind !== "Union")
|
|
905
|
+
return undefined;
|
|
906
|
+
const variants = [...type.variants.values()].map((variant) => variant.type);
|
|
907
|
+
const real = variants.filter((v) => !(v.kind === "Intrinsic" && v.name === "null"));
|
|
908
|
+
if (real.length !== variants.length - 1 || real.length !== 1)
|
|
909
|
+
return undefined;
|
|
910
|
+
return real[0];
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Resolves a type to the identifier of an already-declared schema, or `undefined` to inline it.
|
|
914
|
+
*
|
|
915
|
+
* The registry installs this so a named model is emitted once and referenced everywhere else. Kept
|
|
916
|
+
* as module state rather than threaded through every call because `typeToZod` recurses through
|
|
917
|
+
* unions, arrays and properties, and an extra parameter on each would be carried by every case for
|
|
918
|
+
* the benefit of two.
|
|
919
|
+
*/
|
|
920
|
+
let resolveRef = () => undefined;
|
|
921
|
+
export function withRefResolver(resolver, run) {
|
|
922
|
+
const previous = resolveRef;
|
|
923
|
+
resolveRef = resolver;
|
|
924
|
+
try {
|
|
925
|
+
return run();
|
|
926
|
+
}
|
|
927
|
+
finally {
|
|
928
|
+
resolveRef = previous;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* How many references the current walk has made **back into a declaration still being rendered**.
|
|
933
|
+
*
|
|
934
|
+
* A recursive model is an ordinary construct — `@typespec/openapi3` emits a self-`$ref` for
|
|
935
|
+
* `model InnerModel { children?: InnerModel[] }` and the document is valid — so the reference has to
|
|
936
|
+
* be emitted, not refused. But a `const` cannot read itself while it is being initialised, so the
|
|
937
|
+
* reference must be made **lazy**, and only the enclosing object knows where to put the getter that
|
|
938
|
+
* does it. The registry counts the back edges; {@link captureBackEdges} is how an object claims them.
|
|
939
|
+
*/
|
|
940
|
+
let backEdges = 0;
|
|
941
|
+
/** Called by the registry when it hands out the identifier of a declaration that is still open. */
|
|
942
|
+
export function noteBackEdge() {
|
|
943
|
+
backEdges += 1;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Render `run()`, reporting whether it reached back into a declaration still being rendered.
|
|
947
|
+
*
|
|
948
|
+
* ⚠️ **The count is restored on the way out, and that is the point.** A caller that wraps the
|
|
949
|
+
* reference in a getter has *settled* the back edge — the reference is now lazy and the enclosing
|
|
950
|
+
* object is safe. Letting it propagate would make every ancestor defer as well, and would make the
|
|
951
|
+
* registry report a cycle that has already been dealt with.
|
|
952
|
+
*/
|
|
953
|
+
export function captureBackEdges(run) {
|
|
954
|
+
const before = backEdges;
|
|
955
|
+
const value = run();
|
|
956
|
+
const deferred = backEdges > before;
|
|
957
|
+
backEdges = before;
|
|
958
|
+
return { value, deferred };
|
|
959
|
+
}
|
|
960
|
+
export function typeToZod(program, type) {
|
|
961
|
+
const ref = resolveRef(type);
|
|
962
|
+
if (ref !== undefined)
|
|
963
|
+
return ref;
|
|
964
|
+
return typeToZodBody(program, type);
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* The same walk, except that `type` is never resolved to its own identifier.
|
|
968
|
+
*
|
|
969
|
+
* The registry renders a declaration's *body* with this: asking the resolver first would hand back
|
|
970
|
+
* the identifier the declaration is about to bind, emitting `const xSchema = xSchema`. Nested
|
|
971
|
+
* occurrences still go through {@link typeToZod} and do resolve — which is what makes a recursive
|
|
972
|
+
* model reference itself instead of inlining itself forever.
|
|
973
|
+
*/
|
|
974
|
+
export function typeToZodBody(program, type) {
|
|
975
|
+
switch (type.kind) {
|
|
976
|
+
case "Scalar":
|
|
977
|
+
return scalarToZod(program, type);
|
|
978
|
+
case "Model":
|
|
979
|
+
return modelToZod(program, type);
|
|
980
|
+
case "Enum":
|
|
981
|
+
return enumToZod(program, type);
|
|
982
|
+
case "Union":
|
|
983
|
+
return unionToZod(program, type);
|
|
984
|
+
case "String":
|
|
985
|
+
return `z.literal(${JSON.stringify(type.value)})`;
|
|
986
|
+
case "Number":
|
|
987
|
+
return `z.literal(${type.value})`;
|
|
988
|
+
case "Boolean":
|
|
989
|
+
return `z.literal(${String(type.value)})`;
|
|
990
|
+
case "EnumMember":
|
|
991
|
+
return `z.literal(${JSON.stringify(type.value ?? type.name)})`;
|
|
992
|
+
/**
|
|
993
|
+
* ⚠️ **A named union VARIANT is a type you can refer to, and it is not the union.**
|
|
994
|
+
*
|
|
995
|
+
* `union DogKind { string, Golden: "golden" }` is TypeSpec's extensible-enum idiom, and
|
|
996
|
+
* `kind: DogKind.Golden` resolves to the variant rather than to its value. Unreachable until
|
|
997
|
+
* discriminated subtypes started being declared, at which point the whole
|
|
998
|
+
* `type/model/inheritance/enum-discriminator` scenario stopped compiling with
|
|
999
|
+
* "no rule for this kind" — a dormant seam, not a new defect.
|
|
1000
|
+
*/
|
|
1001
|
+
case "UnionVariant":
|
|
1002
|
+
return typeToZod(program, type.type);
|
|
1003
|
+
case "Intrinsic":
|
|
1004
|
+
if (type.name === "null")
|
|
1005
|
+
return "z.null()";
|
|
1006
|
+
if (type.name === "unknown")
|
|
1007
|
+
return "z.unknown()";
|
|
1008
|
+
if (type.name === "void" || type.name === "never") {
|
|
1009
|
+
return refuse(program, type, `\`${type.name}\` has no runtime representation`);
|
|
1010
|
+
}
|
|
1011
|
+
return refuse(program, type, `intrinsic "${type.name}"`);
|
|
1012
|
+
default:
|
|
1013
|
+
return refuse(program, type, "no rule for this kind");
|
|
1014
|
+
}
|
|
1015
|
+
}
|