typespec-http-zod 0.1.0 → 0.3.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/README.md +98 -215
- package/dist/src/api.d.ts +40 -40
- package/dist/src/api.js +202 -167
- package/dist/src/constraints.d.ts +7 -7
- package/dist/src/constraints.js +8 -8
- package/dist/src/emitter.d.ts +1 -1
- package/dist/src/emitter.js +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/lib.d.ts +16 -34
- package/dist/src/lib.js +36 -44
- package/dist/src/registry.d.ts +59 -8
- package/dist/src/registry.js +77 -72
- package/dist/src/runtime.d.ts +16 -16
- package/dist/src/runtime.js +3 -3
- package/dist/src/streams.d.ts +10 -10
- package/dist/src/streams.js +3 -3
- package/dist/src/tsp-index.d.ts +2 -2
- package/dist/src/tsp-index.js +2 -2
- package/dist/src/types.d.ts +12 -3
- package/dist/src/types.js +67 -33
- package/dist/src/versioning.d.ts +6 -6
- package/dist/src/versioning.js +3 -3
- package/dist/src/zod.d.ts +22 -22
- package/dist/src/zod.js +201 -147
- package/lib/main.tsp +1 -1
- package/package.json +1 -1
package/dist/src/zod.js
CHANGED
|
@@ -5,7 +5,7 @@ import { reportDiagnostic } from "./lib.js";
|
|
|
5
5
|
/**
|
|
6
6
|
* What a walk returns after reporting a refusal.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* **Reporting does not unwind, which is the point.** A throw abandoned the compile at the first
|
|
9
9
|
* unrepresentable construct; reporting lets the walk carry on and name every one of them in a single
|
|
10
10
|
* run. The walk still has to return a `string`, and `z.never()` is the honest placeholder: valid
|
|
11
11
|
* Zod, rejects everything, and unreachable in practice because the error diagnostic has already
|
|
@@ -13,53 +13,53 @@ import { reportDiagnostic } from "./lib.js";
|
|
|
13
13
|
*/
|
|
14
14
|
const UNREPRESENTABLE = "z.never()";
|
|
15
15
|
/**
|
|
16
|
-
* TypeSpec type
|
|
16
|
+
* TypeSpec type -> Zod source text.
|
|
17
17
|
*
|
|
18
|
-
* **Deliberately partial, and it fails loudly rather than guessing.** A general TypeSpec
|
|
18
|
+
* **Deliberately partial, and it fails loudly rather than guessing.** A general TypeSpec->Zod
|
|
19
19
|
* converter has to answer for every construct the language can express; this one only has to answer
|
|
20
20
|
* for the constructs our own specs are allowed to use, which is what makes it a bounded problem
|
|
21
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
|
|
22
|
+
* runtime path). Anything outside that set throws with the offending type named - an emitter that
|
|
23
23
|
* silently produced `z.any()` for a construct it did not understand would push a hole all the way
|
|
24
24
|
* into `respond()`, where the whole point is that drift is caught.
|
|
25
25
|
*/
|
|
26
26
|
/**
|
|
27
|
-
* External module
|
|
27
|
+
* External module -> the constants the emitted schemas reference from it.
|
|
28
28
|
*
|
|
29
29
|
* Collected during emission rather than pre-scanned: only the enums actually reachable from an
|
|
30
30
|
* operation should produce an import, and reachability is exactly what the walk already computes.
|
|
31
31
|
*
|
|
32
|
-
*
|
|
32
|
+
* **Scoped to one emission, not module-global-and-cleared.** The previous shape was a module
|
|
33
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
|
|
34
|
+
* and one read. A second `@service` namespace - a public surface beside an internal one - would
|
|
35
35
|
* have merged one surface's imports into the other's file: an unused import if lucky, a missing one
|
|
36
36
|
* if not. Neither is discoverable while there is only one service, so it is fixed before there are
|
|
37
37
|
* two rather than found by them. Same save/restore idiom as {@link withRefResolver}.
|
|
38
38
|
*/
|
|
39
39
|
let externalImports;
|
|
40
40
|
/**
|
|
41
|
-
* Enum name
|
|
41
|
+
* Enum name -> its declared members, for every vocabulary the surface actually reaches.
|
|
42
42
|
*
|
|
43
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
|
|
44
|
+
* and the spec merely *described* them for the document's benefit - two lists that agreed only by
|
|
45
45
|
* inspection, and by the time this flipped, two of them did not: `PersonalAttribute` published four
|
|
46
46
|
* of six values wrong (the charge-authentication answers an MR01 cannot be filed without) and
|
|
47
47
|
* `SubmissionStatusCode` published all five wrong. Generating them removes the second list.
|
|
48
48
|
*/
|
|
49
49
|
let vocabularies;
|
|
50
50
|
/**
|
|
51
|
-
* Whether a model is SEALED
|
|
51
|
+
* Whether a model is SEALED - closed to properties nobody declared.
|
|
52
52
|
*
|
|
53
|
-
*
|
|
53
|
+
* **A property of the model's own shape, not of where it is used.** This was previously "the
|
|
54
54
|
* model is reachable from a request body", computed by a whole-graph pre-pass. That rule is wrong in
|
|
55
55
|
* a way that is invisible while it happens to agree: there is one declaration per model, so adding a
|
|
56
56
|
* single operation that accepts `ProblemItem` in a body would flip `problemItemSchema` from
|
|
57
57
|
* stripping to rejecting **in every response that uses it too**. Strictness would be decided by the
|
|
58
58
|
* rest of the spec rather than by the model.
|
|
59
59
|
*
|
|
60
|
-
*
|
|
60
|
+
* **This mirrors `@typespec/openapi3`'s `shouldSealSchema` deliberately, line for line.** The
|
|
61
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
|
|
62
|
+
* the only way to guarantee that is to ask it the same way - not to arrive at the same answer by a
|
|
63
63
|
* different route and measure that they still agree. The exception for models with derived models is
|
|
64
64
|
* theirs: sealing a base would make a subtype's own properties "unevaluated" against it.
|
|
65
65
|
*
|
|
@@ -69,7 +69,7 @@ let vocabularies;
|
|
|
69
69
|
* `seal-object-schemas` in `EmitterOptions`, defaulting to openapi3's own default.
|
|
70
70
|
*/
|
|
71
71
|
function isSealed(model) {
|
|
72
|
-
// `Record<never>` is closed whatever the option says
|
|
72
|
+
// `Record<never>` is closed whatever the option says - the spec has stated it outright, and
|
|
73
73
|
// openapi3 seals it for the same reason.
|
|
74
74
|
if (model.indexer !== undefined)
|
|
75
75
|
return isNeverType(model.indexer.value);
|
|
@@ -80,11 +80,11 @@ function isSealed(model) {
|
|
|
80
80
|
/**
|
|
81
81
|
* Whether `@typespec/openapi3` is sealing object schemas for this compile.
|
|
82
82
|
*
|
|
83
|
-
*
|
|
83
|
+
* **This emitter cannot see the other emitter's options, so the consumer states it twice.**
|
|
84
84
|
* Awkward, and the alternative is worse: assume sealing is on and a project running openapi3 with
|
|
85
85
|
* its default gets validators that reject properties its published document permits. Whichever way
|
|
86
86
|
* the two are configured, the conformance differential compares openness in both directions and
|
|
87
|
-
* fails on a mismatch
|
|
87
|
+
* fails on a mismatch - so a project that sets one and forgets the other is told immediately rather
|
|
88
88
|
* than shipping a contradiction.
|
|
89
89
|
*/
|
|
90
90
|
let sealObjectSchemas = false;
|
|
@@ -99,7 +99,7 @@ export function withSealedObjects(sealed, run) {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
|
-
* The visibility the current walk is emitting for
|
|
102
|
+
* The visibility the current walk is emitting for - which decides what counts as HTTP metadata.
|
|
103
103
|
*
|
|
104
104
|
* Visibility decides **which** metadata applies: `@statusCode` is applicable to a response and not
|
|
105
105
|
* to a request, so the same model projects differently in the two directions. `createMetadataInfo`
|
|
@@ -107,7 +107,7 @@ export function withSealedObjects(sealed, run) {
|
|
|
107
107
|
* from answering differently. Measured on `type/model/visibility`: openapi3 emits five components
|
|
108
108
|
* for one model, and before this we emitted one carrying every lifecycle property at once.
|
|
109
109
|
*
|
|
110
|
-
*
|
|
110
|
+
* **Position is a SECOND axis, and this only handles the first.** `@header` is metadata at a
|
|
111
111
|
* payload root and ordinary data nested inside a `@body`; `isPayloadProperty` takes a third
|
|
112
112
|
* argument, `ignoreMetadataAnnotations`, which openapi3 threads as emitter context
|
|
113
113
|
* (`schema-emitter.js:210`) and this does not pass. A metadata-carrying model used in **both**
|
|
@@ -116,11 +116,11 @@ export function withSealedObjects(sealed, run) {
|
|
|
116
116
|
* That shape is rare rather than theoretical: it raises `metadata-ignored` from `@typespec/http`,
|
|
117
117
|
* which fires **zero** times across all 65 `@typespec/http-specs` scenarios,
|
|
118
118
|
* and one variant of it makes openapi3 itself fail with `duplicate-type-name`. It is a real gap with
|
|
119
|
-
* no known occurrence
|
|
119
|
+
* no known occurrence - recorded here rather than described as an incident, because it has not been
|
|
120
120
|
* one.
|
|
121
121
|
*
|
|
122
122
|
* Scoped rather than threaded through six signatures, matching the idiom the rest of this module
|
|
123
|
-
* already uses
|
|
123
|
+
* already uses - the walk is deeply recursive and a parameter would have to be carried by every
|
|
124
124
|
* function whether it cared or not.
|
|
125
125
|
*/
|
|
126
126
|
let visibility = Visibility.Read;
|
|
@@ -138,7 +138,7 @@ export function withVisibility(program, next, run) {
|
|
|
138
138
|
metadataInfo = previousInfo;
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
-
/** Whether a property is optional AT THE CURRENT POSITION
|
|
141
|
+
/** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */
|
|
142
142
|
function isOptionalAt(program, property) {
|
|
143
143
|
metadataInfo ??= createMetadataInfo(program);
|
|
144
144
|
return metadataInfo.isOptional(property, visibility);
|
|
@@ -153,12 +153,12 @@ export function isTransformedBy(program, model, at) {
|
|
|
153
153
|
metadataInfo ??= createMetadataInfo(program);
|
|
154
154
|
return metadataInfo.isTransformed(model, at);
|
|
155
155
|
}
|
|
156
|
-
/** The visibility the current walk is emitting for
|
|
156
|
+
/** The visibility the current walk is emitting for - the registry keys declarations on it. */
|
|
157
157
|
export function currentVisibility() {
|
|
158
158
|
return visibility;
|
|
159
159
|
}
|
|
160
160
|
/**
|
|
161
|
-
* openapi3's own rule for which derived models count
|
|
161
|
+
* openapi3's own rule for which derived models count - `@typespec/openapi3`'s `util.ts`.
|
|
162
162
|
*
|
|
163
163
|
* A template declaration is not a real subtype, and neither is an uninstantiated template; both
|
|
164
164
|
* would otherwise stop a base from being sealed for no reason a reader could see.
|
|
@@ -176,8 +176,8 @@ function noteVocabulary(name, values) {
|
|
|
176
176
|
/**
|
|
177
177
|
* Record every enum a service DECLARES, not merely every one its success bodies happen to reach.
|
|
178
178
|
*
|
|
179
|
-
*
|
|
180
|
-
* Zod walk meant an enum reachable only through an ERROR body was never emitted
|
|
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
181
|
* what happened to one surface's error-code enum: declared in the spec, published on every
|
|
182
182
|
* operation's failure responses, and absent from the generated vocabularies, so the one list a
|
|
183
183
|
* consumer branches on had to be hand-written beside the generated ones. Walking declarations removes
|
|
@@ -191,16 +191,16 @@ export function noteDeclaredVocabularies(enums) {
|
|
|
191
191
|
/**
|
|
192
192
|
* Record a named model's PROPERTY NAMES as a runtime vocabulary.
|
|
193
193
|
*
|
|
194
|
-
*
|
|
194
|
+
* **This exists because "a `Record` keyed by an enum" is inexpressible, and the workaround was
|
|
195
195
|
* about to cost a duplicated list.** `ProfileAttributes` is 150-odd known keys; naming them in the
|
|
196
196
|
* spec is what makes the published document state the constraint instead of hiding it behind a
|
|
197
197
|
* hand-written predicate. But a *type* cannot be iterated, and more than one layer usually needs
|
|
198
|
-
* the set at RUN time
|
|
199
|
-
* tuple in a shared package
|
|
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
200
|
* drift the deleted predicate existed to prevent.
|
|
201
201
|
*
|
|
202
|
-
*
|
|
203
|
-
* mark in the spec that no document carries
|
|
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
204
|
* changes no schema, states nothing in the contract and enforces nothing; it decides which
|
|
205
205
|
* convenience artefact gets written beside the types, which is what an emitter option is for.
|
|
206
206
|
*
|
|
@@ -228,16 +228,16 @@ export function noteExternalImport(module, name) {
|
|
|
228
228
|
externalImports.set(module, names);
|
|
229
229
|
}
|
|
230
230
|
/**
|
|
231
|
-
* Which package the generated Zod imports its vocabularies from
|
|
231
|
+
* Which package the generated Zod imports its vocabularies from - `undefined` when there is none.
|
|
232
232
|
*
|
|
233
|
-
*
|
|
233
|
+
* **Not a constant, and no longer defaulted.** Each `@service` publishes its own contracts
|
|
234
234
|
* package, so a hardcoded specifier makes the second service's schemas import names that package
|
|
235
235
|
* does not export. Scoped the same way the strict-model set is, so a nested emission cannot leak
|
|
236
236
|
* into its parent.
|
|
237
237
|
*
|
|
238
|
-
*
|
|
238
|
+
* **It used to default to one repository's own package name.** A
|
|
239
239
|
* consumer who configured nothing got validators importing `SPEC_VOCABULARIES` from a package they
|
|
240
|
-
* had never heard of
|
|
240
|
+
* had never heard of - output that looks right and does not resolve. There is no honest default for
|
|
241
241
|
* "the caller's own package", so the absence is the answer: with no package named, an enum is
|
|
242
242
|
* emitted inline and the output depends on nothing.
|
|
243
243
|
*/
|
|
@@ -331,24 +331,38 @@ function scalarToZod(program, scalar) {
|
|
|
331
331
|
while (current !== undefined) {
|
|
332
332
|
const mapped = SCALARS[current.name];
|
|
333
333
|
// Constraints are read from the scalar the property NAMED, not from the primitive it resolves
|
|
334
|
-
// to, so `@pattern(
|
|
334
|
+
// to, so `@pattern(...) scalar TrimmedString extends string` carries its rules to every use.
|
|
335
335
|
if (mapped !== undefined)
|
|
336
336
|
return applyConstraints(program, mapped, scalar);
|
|
337
337
|
current = current.baseScalar;
|
|
338
338
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
339
|
+
/**
|
|
340
|
+
* **A scalar with no known base is `z.unknown()`, and it used to be a refusal.**
|
|
341
|
+
*
|
|
342
|
+
* **`z.never()` was the exact inversion of what the document says.** `scalar Mystery;` is
|
|
343
|
+
* published by `@typespec/openapi3` as `"Mystery": {}` - the empty schema, which under JSON Schema
|
|
344
|
+
* asserts nothing and therefore accepts **everything**. This emitter refused the compile and, on the
|
|
345
|
+
* way, wrote `value: z.never()`, which accepts **nothing**. Measured against the emitted schema:
|
|
346
|
+
* `"hello"`, `42` and `null` were all rejected, so a service built on it would answer 400 to every
|
|
347
|
+
* request the document calls valid.
|
|
348
|
+
*
|
|
349
|
+
* Refusing was the wrong answer twice over: the document represents the spec perfectly, so under the
|
|
350
|
+
* governing rule this emitter must too - the same source has to be representable by both or by
|
|
351
|
+
* neither. And `z.unknown()` is not a guess, it is the precise reading of `{}`: a validator that
|
|
352
|
+
* enforces nothing is what a schema asserting nothing means.
|
|
353
|
+
*
|
|
354
|
+
* No diagnostic, because openapi3 raises none. Copying its rule includes copying its silence; a
|
|
355
|
+
* warning here would report a problem the published contract does not have. This is the same
|
|
356
|
+
* treatment a raw binary body already gets, for the same reason.
|
|
357
|
+
*/
|
|
358
|
+
return applyConstraints(program, "z.unknown()", scalar);
|
|
345
359
|
}
|
|
346
360
|
/**
|
|
347
361
|
* A TypeSpec `enum` becomes a Zod enum over its member values, resolved through the generated
|
|
348
362
|
* vocabulary constant so the document and the runtime cannot describe different sets.
|
|
349
363
|
*
|
|
350
|
-
*
|
|
351
|
-
* a hand-written TypeScript tuple
|
|
364
|
+
* **`@externalValues` used to be able to point that arrow the other way** - the spec deferring to
|
|
365
|
+
* a hand-written TypeScript tuple - and while it did, the members here described only the
|
|
352
366
|
* *document*. It is gone, and the tuples are generated from the spec.
|
|
353
367
|
*/
|
|
354
368
|
function enumToZod(_program, target) {
|
|
@@ -359,7 +373,7 @@ function enumToZod(_program, target) {
|
|
|
359
373
|
/**
|
|
360
374
|
* Every string vocabulary resolves through the generated constant rather than being restated.
|
|
361
375
|
*
|
|
362
|
-
* The members are declared once, in the spec, and the vocabularies artefact is generated from them
|
|
376
|
+
* The members are declared once, in the spec, and the vocabularies artefact is generated from them -
|
|
363
377
|
* so the document and the runtime cannot describe different vocabularies. This replaces the
|
|
364
378
|
* `@externalValues` decorator, which pointed the arrow the other way: the spec deferred to a
|
|
365
379
|
* hand-written TypeScript constant, which is the opposite of a spec being a source of truth.
|
|
@@ -368,7 +382,7 @@ function enumToZod(_program, target) {
|
|
|
368
382
|
/**
|
|
369
383
|
* Inline unless the consumer named a package to share the tuple through.
|
|
370
384
|
*
|
|
371
|
-
* The indirection exists so a SECOND package
|
|
385
|
+
* The indirection exists so a SECOND package - another layer, a client - can branch on the same
|
|
372
386
|
* values without restating them. A consumer who has no such package wants a self-contained
|
|
373
387
|
* validator, and emitting a reference to one they never configured is the only way to get output
|
|
374
388
|
* that cannot resolve.
|
|
@@ -390,7 +404,7 @@ function enumToZod(_program, target) {
|
|
|
390
404
|
/**
|
|
391
405
|
* Register a declaration the SPEC never wrote, under a name the document publishes.
|
|
392
406
|
*
|
|
393
|
-
* A `@discriminated` union with an envelope has no TypeSpec type for its wrapper
|
|
407
|
+
* A `@discriminated` union with an envelope has no TypeSpec type for its wrapper - openapi3
|
|
394
408
|
* synthesises one (`tk.model.create`, `schema-emitter.js:436`) and publishes it as
|
|
395
409
|
* `PetWithEnvelopeCat`. The registry keys declarations on a `Type`, and there is no type to key on,
|
|
396
410
|
* so this is the seam that lets one be declared by name instead. Falls back to inlining when no
|
|
@@ -418,17 +432,17 @@ function declaresDiscriminator(program, variant, property) {
|
|
|
418
432
|
/**
|
|
419
433
|
* One variant of a `@discriminated` union, as it appears ON THE WIRE.
|
|
420
434
|
*
|
|
421
|
-
*
|
|
435
|
+
* **The union is not a choice between its variant types - it is a choice between wrappings of
|
|
422
436
|
* them**, and this emitter used to ignore the wrapping entirely. `z.discriminatedUnion("kind",
|
|
423
437
|
* [catSchema, dogSchema])` over models that carry no `kind` at all **matches nothing**: every
|
|
424
438
|
* request to a polymorphic endpoint failed, and the differential could not see it because it read a
|
|
425
439
|
* discriminated component as an object.
|
|
426
440
|
*
|
|
427
|
-
* `envelope: "object"` (the default) wraps the variant
|
|
441
|
+
* `envelope: "object"` (the default) wraps the variant - `{kind: "cat", value: {...Cat}}` - and
|
|
428
442
|
* openapi3 publishes each wrapper as a component named `union.type.name + capitalize(variantName)`.
|
|
429
443
|
* Mirrored exactly, so the identifiers line up with the component names.
|
|
430
444
|
*
|
|
431
|
-
* `envelope: "none"` injects the discriminator into the variant
|
|
445
|
+
* `envelope: "none"` injects the discriminator into the variant - `{kind: "cat", ...Cat}` - and is
|
|
432
446
|
* emitted **only when the variant declares that property itself**. Otherwise the document does not
|
|
433
447
|
* publish it and the union is refused; see `undeclared-discriminator`.
|
|
434
448
|
*/
|
|
@@ -452,19 +466,19 @@ function unionToZod(program, union) {
|
|
|
452
466
|
* Both accept the same values, but a plain union reports "no variant matched" and lists every
|
|
453
467
|
* failure, while a discriminated one reads the tag and reports the single rule that was actually
|
|
454
468
|
* broken. These unions exist to carry cross-field rules that used to hide in a hand-written
|
|
455
|
-
* predicate
|
|
469
|
+
* predicate - publishing the rule and then reporting it unreadably would only move the problem.
|
|
456
470
|
*/
|
|
457
471
|
const [discriminated] = getDiscriminatedUnion(program, union);
|
|
458
472
|
if (discriminated !== undefined) {
|
|
459
473
|
const options = discriminated.options;
|
|
460
474
|
if (discriminated.defaultVariant !== undefined) {
|
|
461
475
|
// "Anything else" has no discriminator value to switch on. Refused by name rather than
|
|
462
|
-
// silently dropped
|
|
476
|
+
// silently dropped - no corpus scenario exercises it, so guessing would be untested code.
|
|
463
477
|
return refuse(program, union, "a discriminated union with a default variant");
|
|
464
478
|
}
|
|
465
479
|
/**
|
|
466
|
-
*
|
|
467
|
-
* variant, and openapi3 publishes the variant unchanged
|
|
480
|
+
* **Refused, not compensated for.** With no envelope the discriminator lives inside the
|
|
481
|
+
* variant, and openapi3 publishes the variant unchanged - so unless the variant declares the
|
|
468
482
|
* property, the document names a discriminator it never requires, which OpenAPI 3.1 forbids.
|
|
469
483
|
* Injecting it here would enforce a rule the contract does not state: correct against the wire,
|
|
470
484
|
* derived from nothing anybody published, and indistinguishable from a bug to every other
|
|
@@ -512,25 +526,25 @@ function unionToZod(program, union) {
|
|
|
512
526
|
/**
|
|
513
527
|
* TypeSpec models arrays and dictionaries as a `Model` carrying an **indexer**.
|
|
514
528
|
*
|
|
515
|
-
*
|
|
516
|
-
* silently lost the indexer of any model *derived* from one
|
|
529
|
+
* **Keyed on the indexer, not on the model's name.** This used to test `name === "Record"`, which
|
|
530
|
+
* silently lost the indexer of any model *derived* from one - `model ProfileAttributes is
|
|
517
531
|
* Record<string | float64 | boolean>` emitted `z.object({})`, a schema that accepts an object and
|
|
518
532
|
* then strips every key in it. For the profile attributes that is silent data loss on a write, and
|
|
519
533
|
* nothing downstream could have told the difference between "the caller sent nothing" and "we threw
|
|
520
534
|
* it away". A named model is still declared and referenced normally; only its *body* comes from the
|
|
521
535
|
* indexer.
|
|
522
536
|
*/
|
|
523
|
-
/** `unknown`
|
|
537
|
+
/** `unknown` - the indexer value of a `...Record<unknown>` spread, which is plain permissiveness. */
|
|
524
538
|
function isUnknownType(type) {
|
|
525
539
|
return type.kind === "Intrinsic" && type.name === "unknown";
|
|
526
540
|
}
|
|
527
541
|
/**
|
|
528
542
|
* Every property the model declares, **including the ones it inherits**.
|
|
529
543
|
*
|
|
530
|
-
*
|
|
544
|
+
* **`model.properties` holds only a model's OWN properties, and `baseModel` appeared nowhere in
|
|
531
545
|
* 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`
|
|
546
|
+
* beside the derived model's own properties, and the sealing keyword openapi3 uses -
|
|
547
|
+
* `unevaluatedProperties` - is chosen precisely because it sees through that. So the document said
|
|
534
548
|
* `Extension` carries `level` **and** the inherited `extension`, while this emitter produced
|
|
535
549
|
* `z.object({level}).strict()`: the inherited property gone, and the model closed against it. The
|
|
536
550
|
* conformance scenario's own documented request body would have been rejected with a 400 naming a key
|
|
@@ -538,12 +552,12 @@ function isUnknownType(type) {
|
|
|
538
552
|
*
|
|
539
553
|
* Invisible for two reasons at once. The spec it was first built against declared no `extends` at
|
|
540
554
|
* all, and the differential
|
|
541
|
-
* skipped every schema carrying an `allOf`
|
|
555
|
+
* skipped every schema carrying an `allOf` - so 28 of 226 components were not divergent, they were
|
|
542
556
|
* never compared.
|
|
543
557
|
*
|
|
544
558
|
* Flattened rather than composed. `z.object(...).extend(...)` would mirror the document's structure
|
|
545
559
|
* more literally and cannot be used here: `.extend()` reads `shape` eagerly, so a base whose property
|
|
546
|
-
* refers back to the derived model
|
|
560
|
+
* refers back to the derived model - `type/model/inheritance/recursive`, the exact case - would throw
|
|
547
561
|
* during module initialisation. Flattening asks nothing of the base but its properties.
|
|
548
562
|
*/
|
|
549
563
|
export function inheritedAndOwnProperties(model) {
|
|
@@ -559,7 +573,7 @@ export function inheritedAndOwnProperties(model) {
|
|
|
559
573
|
}
|
|
560
574
|
return [...byName.values()];
|
|
561
575
|
}
|
|
562
|
-
/** The indexer in force, which a model can inherit
|
|
576
|
+
/** The indexer in force, which a model can inherit - `model X extends Record<unknown>`. */
|
|
563
577
|
export function effectiveIndexer(model) {
|
|
564
578
|
for (let link = model; link !== undefined; link = link.baseModel) {
|
|
565
579
|
if (link.indexer !== undefined)
|
|
@@ -571,7 +585,7 @@ export function effectiveIndexer(model) {
|
|
|
571
585
|
* The subtypes a `@discriminator` base stands for, in declaration order.
|
|
572
586
|
*
|
|
573
587
|
* Filtered by openapi3's own {@link includeDerivedModel} so a template declaration is not mistaken
|
|
574
|
-
* for a real subtype
|
|
588
|
+
* for a real subtype - the same rule that decides whether a base may be sealed.
|
|
575
589
|
*/
|
|
576
590
|
export function discriminatedSubtypes(program, model) {
|
|
577
591
|
if (getDiscriminator(program, model) === undefined)
|
|
@@ -580,18 +594,18 @@ export function discriminatedSubtypes(program, model) {
|
|
|
580
594
|
}
|
|
581
595
|
function modelToZod(program, model) {
|
|
582
596
|
/**
|
|
583
|
-
*
|
|
597
|
+
* **A `@discriminator` base is a CHOICE between its subtypes, not a shape of its own.**
|
|
584
598
|
*
|
|
585
599
|
* `@discriminator("kind") model Bird { kind: string; wingspan: int32 }` with four models extending
|
|
586
600
|
* it reaches OpenAPI as a component carrying `discriminator: {propertyName, mapping}`, and the
|
|
587
601
|
* mapping is an instruction: validate the body against whichever subtype the discriminator names.
|
|
588
602
|
* 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
|
|
603
|
+
* which accepts `{kind: "eagle"}` while ignoring everything `Eagle` actually declares - a
|
|
590
604
|
* polymorphic endpoint validating none of its polymorphism.
|
|
591
605
|
*
|
|
592
606
|
* `z.discriminatedUnion` is the faithful encoding and composes both ways this corpus needs.
|
|
593
607
|
* Measured on Zod 4.4.3: a union may be an OPTION of another union, which is how
|
|
594
|
-
* `Fish
|
|
608
|
+
* `Fish -> Shark -> SawShark` works; and an option carrying a getter that refers back to the union
|
|
595
609
|
* being defined does **not** throw, because the discriminator is read without enumerating the rest
|
|
596
610
|
* of the shape. `Eagle.friends?: Bird[]` is exactly that, and it decides whether polymorphism and
|
|
597
611
|
* recursion can coexist at all.
|
|
@@ -609,10 +623,10 @@ function modelToZod(program, model) {
|
|
|
609
623
|
return applyConstraints(program, `z.array(${typeToZod(program, indexer.value)})`, model);
|
|
610
624
|
}
|
|
611
625
|
/**
|
|
612
|
-
*
|
|
626
|
+
* **A string indexer only means "dictionary" when the model declares NO properties.**
|
|
613
627
|
*
|
|
614
|
-
* `model X { id: string; ...Record<unknown> }`
|
|
615
|
-
* plus anything else", and what `@loose` was replaced by
|
|
628
|
+
* `model X { id: string; ...Record<unknown> }` - the standard TypeSpec way to say "these fields,
|
|
629
|
+
* plus anything else", and what `@loose` was replaced by - carries both. Returning
|
|
616
630
|
* `z.record(...)` for it emits a schema that accepts an object and then **strips every declared
|
|
617
631
|
* field**, so `respond()`'s drift detection degrades to "is it an object" and the pinned fields
|
|
618
632
|
* silently stop being checked. This function's own history is the other half of the same mistake:
|
|
@@ -625,8 +639,8 @@ function modelToZod(program, model) {
|
|
|
625
639
|
* Permissiveness, read from the INDEXER and from nothing else.
|
|
626
640
|
*
|
|
627
641
|
* `...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
|
|
629
|
-
* the document
|
|
642
|
+
* see, so it is the only source. There was a second - `@loose`, a decorator of ours, invisible to
|
|
643
|
+
* the document - and it is gone: a model that accepted unknown properties while the published
|
|
630
644
|
* schema said otherwise is a runtime disagreeing with its own contract.
|
|
631
645
|
*
|
|
632
646
|
* An indexer over something other than `unknown` is a typed catchall, where `.loose()` would drop
|
|
@@ -648,29 +662,45 @@ function modelToZod(program, model) {
|
|
|
648
662
|
*/
|
|
649
663
|
const strict = suffix === "" && isSealed(model) ? ".strict()" : "";
|
|
650
664
|
/**
|
|
651
|
-
*
|
|
652
|
-
* so it never appears in the body
|
|
665
|
+
* **HTTP metadata is not payload.** A `@header` or `@statusCode` property describes the envelope,
|
|
666
|
+
* so it never appears in the body - `@typespec/openapi3` strips it from the schema it emits, and a
|
|
653
667
|
* validator that kept it would demand a field the wire never carries.
|
|
654
668
|
*
|
|
655
669
|
* Found the moment a model carried its own content-type: RFC 9457 puts
|
|
656
670
|
* `@header("content-type") contentType: "application/problem+json"` on the problem model, the
|
|
657
671
|
* document correctly described a body without it, and this emitter produced
|
|
658
|
-
* `contentType: z.literal(
|
|
659
|
-
* about the shape of a body
|
|
672
|
+
* `contentType: z.literal(...)` as a required member. Two artefacts from one source, disagreeing
|
|
673
|
+
* about the shape of a body - the exact contradiction ADR 0005 exists to make impossible.
|
|
660
674
|
*/
|
|
661
675
|
/**
|
|
662
|
-
*
|
|
676
|
+
* **A property that refers back into an unfinished declaration is emitted as a GETTER.**
|
|
663
677
|
*
|
|
664
678
|
* `model InnerModel { children?: InnerModel[] }` is a plain recursive tree, and openapi3 publishes
|
|
665
679
|
* it as a self-`$ref`. Emitting `children: z.array(innerModelSchema)` reads the `const` inside its
|
|
666
680
|
* own initialiser, so the module throws on import; deferring the reference behind a getter is the
|
|
667
681
|
* idiom Zod documents for exactly this, and it is the only one that keeps `z.infer` inferring the
|
|
668
682
|
* recursive type rather than collapsing to `any`. `z.lazy(() => innerModelSchema)` runs correctly
|
|
669
|
-
* but does not typecheck
|
|
683
|
+
* but does not typecheck - measured: `TS7022`, "implicitly has type 'any' because it does not have
|
|
670
684
|
* a type annotation and is referenced directly or indirectly in its own initializer".
|
|
671
685
|
*/
|
|
672
686
|
const members = declared
|
|
673
687
|
.filter((property) => isPayloadProperty(program, property))
|
|
688
|
+
/**
|
|
689
|
+
* **A `never` property is DROPPED, exactly as the document drops it.** `model N { value:
|
|
690
|
+
* never; other: string }` is published by `@typespec/openapi3` as `{other}` with
|
|
691
|
+
* `required: ["other"]` - `value` is absent entirely, because a property that can hold no value
|
|
692
|
+
* is not part of the payload.
|
|
693
|
+
*
|
|
694
|
+
* This emitter kept it and wrote `value: z.never()`, which made the model unsatisfiable: measured,
|
|
695
|
+
* `{"other":"x"}` - the exact body the document describes - was REJECTED with "expected never,
|
|
696
|
+
* received undefined". So the validator generated from a document rejected every request that
|
|
697
|
+
* document calls valid, which is the divergence class this package exists to prevent.
|
|
698
|
+
*
|
|
699
|
+
* Dropping it makes the two artefacts agree and leaves nothing to refuse. `never` in a position
|
|
700
|
+
* that is not a property - a body, a variant - is still refused by `typeToZodBody`, because there
|
|
701
|
+
* it is not something the document quietly omits.
|
|
702
|
+
*/
|
|
703
|
+
.filter((property) => !isNeverType(property.type))
|
|
674
704
|
.map((property) => {
|
|
675
705
|
const key = propertyKey(program, property);
|
|
676
706
|
const { value, deferred } = captureBackEdges(() => propertyToZod(program, property));
|
|
@@ -683,46 +713,45 @@ function modelToZod(program, model) {
|
|
|
683
713
|
/**
|
|
684
714
|
* A model-level `@refine` goes through the same path as every other constraint, so the predicate's
|
|
685
715
|
* import is recorded exactly once, in one place. Handling it separately here is what dropped the
|
|
686
|
-
* import the first time
|
|
716
|
+
* import the first time - the emitted schema referenced a name it never imported, and only the
|
|
687
717
|
* compile check in `test/emit.test.ts` noticed.
|
|
688
718
|
*
|
|
689
719
|
* `.refine()` returns an effect rather than an object, so `applyConstraints` appending it after
|
|
690
720
|
* `.loose()` is also the only order that works.
|
|
691
721
|
*/
|
|
692
722
|
/**
|
|
693
|
-
*
|
|
723
|
+
* **A deferred property forces the CONSTRUCTOR form of the object.**
|
|
694
724
|
*
|
|
695
725
|
* Measured on Zod 4.4.3: `.strict()`, `.loose()` and `.catchall()` all read `shape` eagerly, so
|
|
696
726
|
* each one fires the getter during module initialisation and throws `Cannot access 'X' before
|
|
697
|
-
* initialization`
|
|
727
|
+
* initialization` - the very thing the getter exists to avoid. `z.strictObject` and
|
|
698
728
|
* `z.looseObject` take the same shape without reading it. The suffix form stays everywhere else,
|
|
699
729
|
* so a model that does not recurse emits exactly the text it emitted before.
|
|
700
730
|
*
|
|
701
|
-
* A
|
|
702
|
-
*
|
|
731
|
+
* **A TYPED catchall has no constructor form, and that used to be a refusal.** There is nowhere
|
|
732
|
+
* safe to put the getter - `.catchall()` reads `shape` eagerly - so the cycle was reported as
|
|
733
|
+
* `circular-model`. `z.lazy()` is the place to put it: wrapping the whole expression defers the
|
|
734
|
+
* getter past module initialisation, and by the time the schema is forced every declaration exists.
|
|
735
|
+
* Measured on 4.4.3: `z.lazy(() => z.object({ get kid() {...} }).catchall(z.number()))` parses a
|
|
736
|
+
* nested value and still applies the catchall.
|
|
703
737
|
*/
|
|
704
738
|
const lazily = members.some((member) => member.deferred);
|
|
705
739
|
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
740
|
const shape = entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
714
|
-
const body = lazily
|
|
715
|
-
?
|
|
741
|
+
const body = lazily
|
|
742
|
+
? constructible
|
|
743
|
+
? `${suffix === ".loose()" ? "z.looseObject" : strict === ".strict()" ? "z.strictObject" : "z.object"}(${shape})`
|
|
744
|
+
: `z.lazy(() => z.object(${shape})${suffix}${strict})`
|
|
716
745
|
: `z.object(${shape})${suffix}${strict}`;
|
|
717
746
|
return applyConstraints(program, body, model);
|
|
718
747
|
}
|
|
719
748
|
/**
|
|
720
|
-
* A name as an object key
|
|
749
|
+
* A name as an object key - quoted when it is not a legal JavaScript identifier.
|
|
721
750
|
*
|
|
722
|
-
*
|
|
751
|
+
* **A TypeSpec property can be named anything**, and the corpus proves it: `parameters/spread`
|
|
723
752
|
* declares `` `x-ms-test-header`: string ``. A parameter walk assumed the property name was always
|
|
724
|
-
* safe
|
|
725
|
-
* avoided this
|
|
753
|
+
* safe - its docblock even argued that using the property name rather than the wire name was what
|
|
754
|
+
* avoided this - and emitted `x-ms-test-header: z.string()`, a file that does not parse.
|
|
726
755
|
*
|
|
727
756
|
* Exported so there is one answer to this question. There were two sites and only one of them knew.
|
|
728
757
|
*/
|
|
@@ -732,24 +761,24 @@ export function objectKey(name) {
|
|
|
732
761
|
/**
|
|
733
762
|
* The name a property carries **on the wire**, which is not always the name the spec gave it.
|
|
734
763
|
*
|
|
735
|
-
*
|
|
764
|
+
* **`@encodedName` is a first-party decorator, and a decorator is a declaration rather than a
|
|
736
765
|
* 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
|
|
766
|
+
* to go and ask. `@typespec/openapi3` asks - `resolveEncodedName` at `schema-emitter.js:214` - so the
|
|
738
767
|
* document publishes the wire name. This emitter did not, so the validator was keyed on the TypeSpec
|
|
739
768
|
* name and a request carrying exactly what the contract required failed twice over: the declared
|
|
740
769
|
* property was missing, and the one actually sent was an unrecognised key a sealed model rejects.
|
|
741
770
|
*
|
|
742
771
|
* 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
|
|
772
|
+
* and it sat in the green column throughout - compiling cleanly while emitting the wrong key.
|
|
744
773
|
*
|
|
745
774
|
* **The domain therefore sees the wire name too**, because `types.ts` resolves it identically. A
|
|
746
775
|
* validator that renamed on the way through would be a transform, which JSON Schema cannot express
|
|
747
|
-
* and no document could state
|
|
776
|
+
* and no document could state - the same reason `@trimmed` was removed.
|
|
748
777
|
*/
|
|
749
778
|
function propertyKey(program, property) {
|
|
750
779
|
return objectKey(rawPropertyKey(program, property));
|
|
751
780
|
}
|
|
752
|
-
/** The same wire name, unquoted
|
|
781
|
+
/** The same wire name, unquoted - for a vocabulary tuple, where the value is data rather than code. */
|
|
753
782
|
function rawPropertyKey(program, property) {
|
|
754
783
|
return resolveEncodedName(program, property, JSON_MEDIA_TYPE);
|
|
755
784
|
}
|
|
@@ -758,13 +787,13 @@ function rawPropertyKey(program, property) {
|
|
|
758
787
|
*
|
|
759
788
|
* Fixed rather than threaded: everything this emitter produces validates a JSON body, and
|
|
760
789
|
* `@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
|
|
790
|
+
* operation's content type, as openapi3 does - and would need far more than a name to be correct.
|
|
762
791
|
*/
|
|
763
792
|
const JSON_MEDIA_TYPE = "application/json";
|
|
764
793
|
/**
|
|
765
794
|
* The type a value takes **on the wire**, which `@encode` can make different from its declared one.
|
|
766
795
|
*
|
|
767
|
-
*
|
|
796
|
+
* **A first-party decorator, and a decorator is a declaration rather than a behaviour** - every
|
|
768
797
|
* emitter has to go and ask. `@typespec/openapi3` asks, so the document publishes the encoded type;
|
|
769
798
|
* this emitter did not, and validated the DECLARED one. Measured across the corpus, 30 properties
|
|
770
799
|
* where the two disagreed, and each disagreement rejects a body the contract calls valid:
|
|
@@ -774,10 +803,10 @@ const JSON_MEDIA_TYPE = "application/json";
|
|
|
774
803
|
* `@encode(string) int32` document `string` we emitted a number
|
|
775
804
|
* `@encode(unixTimestamp) utcDateTime` document `integer` we emitted a string
|
|
776
805
|
*
|
|
777
|
-
* Exactly the shape of the `@encodedName` defect, found by exactly the same kind of arm
|
|
806
|
+
* Exactly the shape of the `@encodedName` defect, found by exactly the same kind of arm - there, the
|
|
778
807
|
* key a property goes by; here, the type it goes as.
|
|
779
808
|
*
|
|
780
|
-
*
|
|
809
|
+
* **An array encoding on a QUERY parameter is a style, not a type change - and this docblock used
|
|
781
810
|
* to say the opposite.** It claimed the array encodings needed no special case "because a
|
|
782
811
|
* comma-delimited list IS a string on the wire". True of the wire, and the wrong conclusion: the
|
|
783
812
|
* document keeps `type: array` and publishes `style: pipeDelimited` beside it, because the parameter
|
|
@@ -792,8 +821,8 @@ const JSON_MEDIA_TYPE = "application/json";
|
|
|
792
821
|
* }
|
|
793
822
|
* ```
|
|
794
823
|
*
|
|
795
|
-
* Everywhere else the encoding really does change the type
|
|
796
|
-
* an integer
|
|
824
|
+
* Everywhere else the encoding really does change the type - `@encode(unixTimestamp) utcDateTime` is
|
|
825
|
+
* an integer - so this narrows to exactly the case the reference implementation narrows to. The
|
|
797
826
|
* flattening is undone at the parameter, by {@link COLLECTION_DELIMITERS}.
|
|
798
827
|
*/
|
|
799
828
|
const PARAMETER_STYLE_ENCODINGS = new Set([
|
|
@@ -815,9 +844,9 @@ function encodedTypeOf(program, target) {
|
|
|
815
844
|
}
|
|
816
845
|
export function propertyToZod(program, property) {
|
|
817
846
|
/**
|
|
818
|
-
*
|
|
847
|
+
* **Constraints go INSIDE `.nullable()`, not after it.**
|
|
819
848
|
*
|
|
820
|
-
* `z.string().nullable().min(1)` does not compile
|
|
849
|
+
* `z.string().nullable().min(1)` does not compile - `.min` is not on `ZodNullable` - so a property
|
|
821
850
|
* typed `string | null` with `@minLength(1)` has to emit `z.string().min(1).nullable()`. The
|
|
822
851
|
* nullability is peeled off here, the constraints applied to what is underneath, and the wrapper
|
|
823
852
|
* put back. Found by a consumer's own typecheck on a nullable-and-constrained property.
|
|
@@ -826,7 +855,7 @@ export function propertyToZod(program, property) {
|
|
|
826
855
|
* to the value.
|
|
827
856
|
*/
|
|
828
857
|
const nullable = nullableInnerOf(property.type);
|
|
829
|
-
// `@encode` on the property wins over its declared type
|
|
858
|
+
// `@encode` on the property wins over its declared type - see `encodedTypeOf`. The nullable
|
|
830
859
|
// wrapper is kept: `@encode` changes what the value IS, not whether it may be absent.
|
|
831
860
|
const encoded = encodedTypeOf(program, property);
|
|
832
861
|
const declared = encoded ?? nullable ?? property.type;
|
|
@@ -834,70 +863,95 @@ export function propertyToZod(program, property) {
|
|
|
834
863
|
? applyConstraints(program, typeToZod(program, declared), property)
|
|
835
864
|
: `${applyConstraints(program, typeToZod(program, declared), property)}.nullable()`;
|
|
836
865
|
/**
|
|
837
|
-
* A TypeSpec default (`basis: string = "s1159"`) becomes `.default(
|
|
866
|
+
* A TypeSpec default (`basis: string = "s1159"`) becomes `.default(...)`, which in Zod also makes
|
|
838
867
|
* the field optional on the way in. Dropping it turned an optional field with a fallback into a
|
|
839
|
-
* required one
|
|
868
|
+
* required one - callers omitting it would start getting a 400 for a request that used to work.
|
|
840
869
|
*/
|
|
841
870
|
const defaulted = defaultOf(program, property);
|
|
842
871
|
if (defaulted !== undefined)
|
|
843
872
|
return `${base}.default(${defaulted})`;
|
|
844
873
|
/**
|
|
845
|
-
*
|
|
874
|
+
* **Optionality is a function of the position too, not only of the `?` in the spec.**
|
|
846
875
|
*
|
|
847
876
|
* TypeSpec's PATCH semantics make a property optional in an update request even where the model
|
|
848
|
-
* declares it required
|
|
877
|
+
* declares it required - an update sends the fields it is changing. The document says so
|
|
849
878
|
* (`VisibilityModelUpdate` requires nothing) and a validator that still demanded the property
|
|
850
879
|
* would reject every partial update the contract invites. `metadataInfo.isOptional` is the same
|
|
851
880
|
* predicate `@typespec/openapi3` uses to decide the `required` list.
|
|
852
881
|
*/
|
|
853
882
|
return property.optional || isOptionalAt(program, property) ? `${base}.optional()` : base;
|
|
854
883
|
}
|
|
855
|
-
/**
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
884
|
+
/**
|
|
885
|
+
* A default value as a JS literal, or `undefined` where this emitter cannot render one.
|
|
886
|
+
*
|
|
887
|
+
* **Composite defaults used to be REFUSED, and the refusal was wrong on the emitter's own
|
|
888
|
+
* governing rule.** `#["a", "b"]` and `#{ x: 1 }` were reported as `unsupported-default` with the
|
|
889
|
+
* reason "a populated literal default would need each element rendered, and no schema here has one".
|
|
890
|
+
* That was a statement about this function rather than about what can be represented: `.default()`
|
|
891
|
+
* takes any JS value, and `@typespec/openapi3` publishes these exactly -
|
|
892
|
+
* `default: ["a","b"]`, `default: {"x":1,"label":"hi"}`, and nested arrays too, measured from one
|
|
893
|
+
* compile of a spec carrying all three. So the document could say it, Zod could enforce it, and only
|
|
894
|
+
* this emitter refused - which is the same spec being representable by one emitter and not the other,
|
|
895
|
+
* the one thing a differential between them cannot tolerate.
|
|
896
|
+
*
|
|
897
|
+
* **The refusal also emitted `.default(z.never())`**, because `UNREPRESENTABLE` is a schema
|
|
898
|
+
* expression and this position wants a VALUE. Had the diagnostic ever been downgraded to a warning,
|
|
899
|
+
* that would have compiled and set every such default to a Zod object.
|
|
900
|
+
*
|
|
901
|
+
* A value is pure data - strings, numbers, booleans, null, and arrays and objects of those - so it
|
|
902
|
+
* renders as JSON. Anything else stays a refusal, and `ScalarValue` is the live case: a default like
|
|
903
|
+
* `utcDateTime.fromISO(...)` is a constructor call rather than a literal.
|
|
904
|
+
*/
|
|
905
|
+
function renderValue(value) {
|
|
860
906
|
if (value.valueKind === "StringValue")
|
|
861
907
|
return JSON.stringify(value.value);
|
|
862
908
|
if (value.valueKind === "NumericValue")
|
|
863
909
|
return String(value.value.asNumber());
|
|
864
910
|
if (value.valueKind === "BooleanValue")
|
|
865
911
|
return String(value.value);
|
|
912
|
+
if (value.valueKind === "NullValue")
|
|
913
|
+
return "null";
|
|
866
914
|
if (value.valueKind === "EnumValue") {
|
|
867
915
|
const member = value.value;
|
|
868
916
|
return JSON.stringify(member.value ?? member.name);
|
|
869
917
|
}
|
|
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
918
|
if (value.valueKind === "ArrayValue") {
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
code: "unsupported-default",
|
|
879
|
-
target: property,
|
|
880
|
-
format: { why: "a non-empty array" },
|
|
881
|
-
});
|
|
882
|
-
return UNREPRESENTABLE;
|
|
919
|
+
const rendered = value.values.map(renderValue);
|
|
920
|
+
// One unrenderable element makes the whole literal unrenderable - a partial array would be a
|
|
921
|
+
// different default from the one the spec declares, which is worse than refusing.
|
|
922
|
+
return rendered.some((entry) => entry === undefined) ? undefined : `[${rendered.join(", ")}]`;
|
|
883
923
|
}
|
|
884
924
|
if (value.valueKind === "ObjectValue") {
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
}
|
|
893
|
-
return UNREPRESENTABLE;
|
|
925
|
+
const entries = [];
|
|
926
|
+
for (const [name, descriptor] of value.properties) {
|
|
927
|
+
const rendered = renderValue(descriptor.value);
|
|
928
|
+
if (rendered === undefined)
|
|
929
|
+
return undefined;
|
|
930
|
+
entries.push(`${JSON.stringify(name)}: ${rendered}`);
|
|
931
|
+
}
|
|
932
|
+
return `{${entries.join(", ")}}`;
|
|
894
933
|
}
|
|
934
|
+
return undefined;
|
|
935
|
+
}
|
|
936
|
+
/** A property's declared default, as a JS literal, or `undefined` when it has none. */
|
|
937
|
+
function defaultOf(program, property) {
|
|
938
|
+
const value = property.defaultValue;
|
|
939
|
+
if (value === undefined)
|
|
940
|
+
return undefined;
|
|
941
|
+
const rendered = renderValue(value);
|
|
942
|
+
if (rendered !== undefined)
|
|
943
|
+
return rendered;
|
|
895
944
|
reportDiagnostic(program, {
|
|
896
945
|
code: "unsupported-default",
|
|
897
946
|
target: property,
|
|
898
947
|
format: { why: `kind "${value.valueKind}"` },
|
|
899
948
|
});
|
|
900
|
-
|
|
949
|
+
/**
|
|
950
|
+
* **No `.default(...)` at all, rather than a default this emitter invented.** Returning
|
|
951
|
+
* `UNREPRESENTABLE` here put a schema expression where a value belongs. The property keeps its
|
|
952
|
+
* declared shape and loses only the fallback, which the diagnostic names.
|
|
953
|
+
*/
|
|
954
|
+
return undefined;
|
|
901
955
|
}
|
|
902
956
|
/** The non-null half of a two-variant `T | null` union, or `undefined` if it is not one. */
|
|
903
957
|
function nullableInnerOf(type) {
|
|
@@ -931,8 +985,8 @@ export function withRefResolver(resolver, run) {
|
|
|
931
985
|
/**
|
|
932
986
|
* How many references the current walk has made **back into a declaration still being rendered**.
|
|
933
987
|
*
|
|
934
|
-
* A recursive model is an ordinary construct
|
|
935
|
-
* `model InnerModel { children?: InnerModel[] }` and the document is valid
|
|
988
|
+
* A recursive model is an ordinary construct - `@typespec/openapi3` emits a self-`$ref` for
|
|
989
|
+
* `model InnerModel { children?: InnerModel[] }` and the document is valid - so the reference has to
|
|
936
990
|
* be emitted, not refused. But a `const` cannot read itself while it is being initialised, so the
|
|
937
991
|
* reference must be made **lazy**, and only the enclosing object knows where to put the getter that
|
|
938
992
|
* does it. The registry counts the back edges; {@link captureBackEdges} is how an object claims them.
|
|
@@ -945,8 +999,8 @@ export function noteBackEdge() {
|
|
|
945
999
|
/**
|
|
946
1000
|
* Render `run()`, reporting whether it reached back into a declaration still being rendered.
|
|
947
1001
|
*
|
|
948
|
-
*
|
|
949
|
-
* reference in a getter has *settled* the back edge
|
|
1002
|
+
* **The count is restored on the way out, and that is the point.** A caller that wraps the
|
|
1003
|
+
* reference in a getter has *settled* the back edge - the reference is now lazy and the enclosing
|
|
950
1004
|
* object is safe. Letting it propagate would make every ancestor defer as well, and would make the
|
|
951
1005
|
* registry report a cycle that has already been dealt with.
|
|
952
1006
|
*/
|
|
@@ -968,7 +1022,7 @@ export function typeToZod(program, type) {
|
|
|
968
1022
|
*
|
|
969
1023
|
* The registry renders a declaration's *body* with this: asking the resolver first would hand back
|
|
970
1024
|
* the identifier the declaration is about to bind, emitting `const xSchema = xSchema`. Nested
|
|
971
|
-
* occurrences still go through {@link typeToZod} and do resolve
|
|
1025
|
+
* occurrences still go through {@link typeToZod} and do resolve - which is what makes a recursive
|
|
972
1026
|
* model reference itself instead of inlining itself forever.
|
|
973
1027
|
*/
|
|
974
1028
|
export function typeToZodBody(program, type) {
|
|
@@ -990,13 +1044,13 @@ export function typeToZodBody(program, type) {
|
|
|
990
1044
|
case "EnumMember":
|
|
991
1045
|
return `z.literal(${JSON.stringify(type.value ?? type.name)})`;
|
|
992
1046
|
/**
|
|
993
|
-
*
|
|
1047
|
+
* **A named union VARIANT is a type you can refer to, and it is not the union.**
|
|
994
1048
|
*
|
|
995
1049
|
* `union DogKind { string, Golden: "golden" }` is TypeSpec's extensible-enum idiom, and
|
|
996
1050
|
* `kind: DogKind.Golden` resolves to the variant rather than to its value. Unreachable until
|
|
997
1051
|
* discriminated subtypes started being declared, at which point the whole
|
|
998
1052
|
* `type/model/inheritance/enum-discriminator` scenario stopped compiling with
|
|
999
|
-
* "no rule for this kind"
|
|
1053
|
+
* "no rule for this kind" - a dormant seam, not a new defect.
|
|
1000
1054
|
*/
|
|
1001
1055
|
case "UnionVariant":
|
|
1002
1056
|
return typeToZod(program, type.type);
|