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/registry.js
CHANGED
|
@@ -1,36 +1,11 @@
|
|
|
1
1
|
import { getVisibilitySuffix, Visibility } from "@typespec/http";
|
|
2
|
-
import { reportDiagnostic } from "./lib.js";
|
|
3
2
|
import { propertyToTs, typeToTsBody, withTsRefResolver } from "./types.js";
|
|
4
3
|
import { captureBackEdges, currentVisibility, isTransformedBy, noteBackEdge, propertyToZod, typeToZodBody, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
|
|
5
|
-
/**
|
|
6
|
-
* Assigns each named TypeSpec model/enum a single Zod declaration, emitted in dependency order.
|
|
7
|
-
*
|
|
8
|
-
* Without this, a model used by twelve operations is inlined twelve times: the generated file grows
|
|
9
|
-
* quadratically, and — worse — the *type* it infers is a fresh structural type at each site, so
|
|
10
|
-
* `apps/web` gets twelve `Company`s that are only accidentally the same. One declaration per model
|
|
11
|
-
* is what makes the emitted types usable as a shared vocabulary rather than as twelve coincidences.
|
|
12
|
-
*/
|
|
13
|
-
/**
|
|
14
|
-
* A cycle that reaches a declaration through nothing that can defer it.
|
|
15
|
-
*
|
|
16
|
-
* ⚠️ **A recursive model is NOT this.** `model InnerModel { children?: InnerModel[] }` is an ordinary
|
|
17
|
-
* tree — `@typespec/openapi3` publishes a self-`$ref` for it and the document is valid — so it is
|
|
18
|
-
* emitted as a reference, made lazy by a getter on the property that closes the loop (see
|
|
19
|
-
* `modelToZod`). This placeholder is only for a cycle with no property on its path: a named **union**
|
|
20
|
-
* whose variant refers back to a model that is still being rendered has nowhere to hang a getter, so
|
|
21
|
-
* the reference cannot be deferred and the declaration would read itself while initialising.
|
|
22
|
-
*
|
|
23
|
-
* Reported rather than thrown, so the walk names every such cycle instead of dying inside the first.
|
|
24
|
-
*/
|
|
25
|
-
const CYCLE_PLACEHOLDER_SCHEMA = "z.never()";
|
|
26
|
-
function reportCycle(program, type, name) {
|
|
27
|
-
reportDiagnostic(program, { code: "circular-model", target: type, format: { name } });
|
|
28
|
-
}
|
|
29
4
|
/**
|
|
30
5
|
* The name an ANONYMOUS payload model inherits from the single model it spreads.
|
|
31
6
|
*
|
|
32
|
-
*
|
|
33
|
-
* metadata**, and it has no name
|
|
7
|
+
* **`@typespec/http` hands back a synthesised model for a body whose declaration carried
|
|
8
|
+
* metadata**, and it has no name - so `@error model InvalidAuth { @statusCode _: 403; error: string }`
|
|
34
9
|
* reached the registry as `{error}` with `name: ""`, was inlined at its use site, and produced **no
|
|
35
10
|
* validator at all** for a component the document publishes as `InvalidAuth`. Four authentication
|
|
36
11
|
* scenarios, each with an unvalidated 403.
|
|
@@ -39,8 +14,8 @@ function reportCycle(program, type, name) {
|
|
|
39
14
|
* itself, which is why its component keeps the original name. This recovers the same name from
|
|
40
15
|
* `sourceModels`, so both artefacts call the shape the same thing.
|
|
41
16
|
*
|
|
42
|
-
*
|
|
43
|
-
* not claim `A`'s name
|
|
17
|
+
* **Only a PURE spread of one named model.** `{...A, extra: string}` is a different shape and must
|
|
18
|
+
* not claim `A`'s name - so every property has to come from the source. Metadata-stripping removes
|
|
44
19
|
* properties and never adds them, which is exactly the asymmetry this tests for. A collision would be
|
|
45
20
|
* caught by the differential's `component-name-collision` arm regardless.
|
|
46
21
|
*/
|
|
@@ -48,11 +23,11 @@ function spreadSourceOf(type, seen = new Set(),
|
|
|
48
23
|
/**
|
|
49
24
|
* The properties the ORIGINAL type has, carried down the chain rather than re-derived.
|
|
50
25
|
*
|
|
51
|
-
*
|
|
26
|
+
* **Re-deriving them at each link is what blocked `Alias.InnerModel`.** The intermediate model
|
|
52
27
|
* an operation builds holds the spread body's properties AND the operation's own `@path` and
|
|
53
28
|
* `@header` parameters, so checking THAT set against the named ancestor fails on properties the
|
|
54
29
|
* body never had. The question is whether every property of the shape being named comes from the
|
|
55
|
-
* ancestor
|
|
30
|
+
* ancestor - `{name}` does - not whether some intermediate did.
|
|
56
31
|
*
|
|
57
32
|
* The guard is unchanged in what it refuses: `{...A, extra}` still fails, because `extra` is in
|
|
58
33
|
* the original set and not in `A`.
|
|
@@ -74,9 +49,9 @@ required = [...type.properties.keys()]) {
|
|
|
74
49
|
return undefined;
|
|
75
50
|
}
|
|
76
51
|
/**
|
|
77
|
-
*
|
|
52
|
+
* **Follow the chain through ANONYMOUS links.** TypeSpec puts an unnamed model between an
|
|
78
53
|
* operation's parameter model and the model that was spread into it, so stopping at the first
|
|
79
|
-
* source finds `""` and gives up
|
|
54
|
+
* source finds `""` and gives up - measured: the bodies for `Alias.InnerModel` and
|
|
80
55
|
* `Model.CompositeRequestMix` both reported `sources: [{ usage: "spread", name: "" }]` while the
|
|
81
56
|
* document published both as components, and the one case that already worked
|
|
82
57
|
* (`Model.BodyParameter`) was the one whose source happened to be named directly.
|
|
@@ -88,14 +63,14 @@ required = [...type.properties.keys()]) {
|
|
|
88
63
|
return model.name === "" ? spreadSourceOf(model, new Set([...seen, type]), required) : model;
|
|
89
64
|
}
|
|
90
65
|
const spreadSourceName = (type) => spreadSourceOf(type)?.name;
|
|
91
|
-
/** A type earns a name when the spec gave it one
|
|
66
|
+
/** A type earns a name when the spec gave it one - anonymous shapes stay inline where they are used. */
|
|
92
67
|
function declaredNameOf(type) {
|
|
93
68
|
if (type.kind === "Enum")
|
|
94
69
|
return type.name;
|
|
95
70
|
/**
|
|
96
71
|
* A named union earns a declaration too.
|
|
97
72
|
*
|
|
98
|
-
* Without this a union request body is inlined at its use site, so it exports no type
|
|
73
|
+
* Without this a union request body is inlined at its use site, so it exports no type - and
|
|
99
74
|
* `backend/contract.ts` and the wire assertions both pair a model with a schema **by name**. A
|
|
100
75
|
* cross-field rule modelled as a discriminated union (which is how `@refine` is being replaced)
|
|
101
76
|
* would silently stop being checked at the boundary.
|
|
@@ -110,10 +85,10 @@ function declaredNameOf(type) {
|
|
|
110
85
|
if (type.name === "")
|
|
111
86
|
return spreadSourceName(type);
|
|
112
87
|
/**
|
|
113
|
-
*
|
|
88
|
+
* **A template INSTANTIATION carries the template's name, and it is not a declaration.**
|
|
114
89
|
*
|
|
115
90
|
* `PublicSuccess<PublicCompanyDetail>` and `PublicSuccess<PublicOfficers>` are both called
|
|
116
|
-
* `PublicSuccess`, so naming them would export eleven consts called `publicSuccessSchema`
|
|
91
|
+
* `PublicSuccess`, so naming them would export eleven consts called `publicSuccessSchema` - the
|
|
117
92
|
* file does not compile, and if it did, one shape would validate every response. `@typespec/openapi3`
|
|
118
93
|
* inlines instantiations for the same reason (the public document has no `PublicSuccess*`
|
|
119
94
|
* component), so both artefacts agree about what has a name and what does not.
|
|
@@ -128,11 +103,11 @@ const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
|
|
|
128
103
|
/**
|
|
129
104
|
* One model can need MORE THAN ONE declaration.
|
|
130
105
|
*
|
|
131
|
-
*
|
|
106
|
+
* **Keyed on `(type, visibility)`, because a model is not one shape.** `@visibility` and HTTP
|
|
132
107
|
* metadata both mean the same declaration projects differently by position: `@typespec/openapi3`
|
|
133
108
|
* emits `VisibilityModel` and `VisibilityModelCreate` as separate components for exactly this
|
|
134
109
|
* reason. Keyed on the type alone, whichever position was walked first won and every other position
|
|
135
|
-
* silently got its shape
|
|
110
|
+
* silently got its shape - measured on the corpus, one strict model carrying all six lifecycle
|
|
136
111
|
* properties, so a create request was required to send read-only fields.
|
|
137
112
|
*
|
|
138
113
|
* The suffix rule is openapi3's: canonical (`Read`) keeps the bare name, and another visibility only
|
|
@@ -141,10 +116,10 @@ const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
|
|
|
141
116
|
*/
|
|
142
117
|
function keyFor(type, visibility) {
|
|
143
118
|
/**
|
|
144
|
-
*
|
|
119
|
+
* **Keyed on the SPREAD SOURCE where there is one, or one component gets two declarations.**
|
|
145
120
|
*
|
|
146
121
|
* `@typespec/http` synthesises a fresh anonymous model per position, so two operations taking the
|
|
147
|
-
* same body produce two distinct types that both resolve to the name `BodyModel`
|
|
122
|
+
* same body produce two distinct types that both resolve to the name `BodyModel` - and
|
|
148
123
|
* `parameters/body-optionality` emitted `export const bodyModelSchema` twice, a file that does not
|
|
149
124
|
* parse. The document has exactly one `BodyModel` component; collapsing onto the source is what
|
|
150
125
|
* makes the two artefacts agree about how many things there are.
|
|
@@ -173,13 +148,15 @@ export class SchemaRegistry {
|
|
|
173
148
|
#program;
|
|
174
149
|
#declarations = new Map();
|
|
175
150
|
#order = [];
|
|
176
|
-
/** Key
|
|
151
|
+
/** Key -> the identifier it is *going* to bind, so a back edge can name a declaration in flight. */
|
|
177
152
|
#inProgress = new Map();
|
|
153
|
+
/** Keys on a cycle - every one needs a structural type rather than a `z.infer` alias. */
|
|
154
|
+
#cyclic = new Set();
|
|
178
155
|
constructor(program) {
|
|
179
156
|
this.#program = program;
|
|
180
157
|
}
|
|
181
158
|
/**
|
|
182
|
-
* The Zod expression for a type
|
|
159
|
+
* The Zod expression for a type - a bare identifier once the type has a declaration, otherwise
|
|
183
160
|
* inline source. Declaring is depth-first so a model's dependencies are emitted above it, which is
|
|
184
161
|
* what lets the generated file be plain `const` declarations with no forward references.
|
|
185
162
|
*/
|
|
@@ -188,7 +165,7 @@ export class SchemaRegistry {
|
|
|
188
165
|
if (bare === undefined)
|
|
189
166
|
return this.#inline(type);
|
|
190
167
|
/**
|
|
191
|
-
*
|
|
168
|
+
* **A type not reshaped at this visibility IS the canonical declaration - not a copy of it.**
|
|
192
169
|
*
|
|
193
170
|
* Keying on the requested visibility while only suffixing transformed types produced two cache
|
|
194
171
|
* entries sharing one identifier, and the emitted file stopped parsing:
|
|
@@ -207,15 +184,24 @@ export class SchemaRegistry {
|
|
|
207
184
|
if (existing !== undefined)
|
|
208
185
|
return existing.identifier;
|
|
209
186
|
/**
|
|
210
|
-
*
|
|
187
|
+
* **A declaration already in flight is REFERENCED, not re-entered.**
|
|
211
188
|
*
|
|
212
189
|
* Returning the identifier is what turns a recursive model into the self-`$ref` the document
|
|
213
|
-
* publishes. The back edge is noted so the enclosing object knows to defer it behind a getter
|
|
190
|
+
* publishes. The back edge is noted so the enclosing object knows to defer it behind a getter -
|
|
214
191
|
* and if nothing does, {@link captureBackEdges} below still sees it and the cycle is named.
|
|
215
192
|
*/
|
|
216
193
|
const pending = this.#inProgress.get(key);
|
|
217
194
|
if (pending !== undefined) {
|
|
218
195
|
noteBackEdge();
|
|
196
|
+
/**
|
|
197
|
+
* **Every declaration currently in flight is ON this cycle**, and all of them need a
|
|
198
|
+
* structural type - not only the one that ends up deferred. `#inProgress` is exactly the path
|
|
199
|
+
* from the cycle's entry point down to here, so recording it wholesale is both correct and
|
|
200
|
+
* cheap. Recording only the deferred declaration leaves the others as `z.infer` aliases and
|
|
201
|
+
* closes the loop again at the type level - `TS2456`, measured.
|
|
202
|
+
*/
|
|
203
|
+
for (const inFlight of this.#inProgress.keys())
|
|
204
|
+
this.#cyclic.add(inFlight);
|
|
219
205
|
return pending;
|
|
220
206
|
}
|
|
221
207
|
const name = `${bare}${at === Visibility.Read ? "" : getVisibilitySuffix(at, Visibility.Read)}`;
|
|
@@ -225,16 +211,17 @@ export class SchemaRegistry {
|
|
|
225
211
|
this.#inProgress.delete(key);
|
|
226
212
|
/**
|
|
227
213
|
* A back edge that survived the whole body reached this declaration by a path with no object
|
|
228
|
-
* property on it
|
|
229
|
-
*
|
|
214
|
+
* property on it - a named union variant, or a dictionary value - so nothing could make it lazy
|
|
215
|
+
* and the emitted `const` would read itself while initialising. `z.lazy()` defers the whole body
|
|
216
|
+
* instead, which is the only place left to put the deferral once no property can hold it.
|
|
230
217
|
*/
|
|
231
|
-
|
|
232
|
-
reportCycle(this.#program, type, bare);
|
|
233
|
-
const source = rendered.deferred ? CYCLE_PLACEHOLDER_SCHEMA : rendered.value;
|
|
218
|
+
const source = rendered.deferred ? `z.lazy(() => ${rendered.value})` : rendered.value;
|
|
234
219
|
const declaration = {
|
|
235
220
|
identifier,
|
|
236
221
|
typeName: name,
|
|
237
222
|
source,
|
|
223
|
+
...(this.#cyclic.has(key) ? { structural: this.#structuralBodyOf(type) } : {}),
|
|
224
|
+
...(rendered.deferred ? { annotated: true } : {}),
|
|
238
225
|
};
|
|
239
226
|
this.#declarations.set(key, declaration);
|
|
240
227
|
this.#order.push(declaration);
|
|
@@ -244,18 +231,18 @@ export class SchemaRegistry {
|
|
|
244
231
|
/**
|
|
245
232
|
* Declare the BASE CHAIN a component's own declaration implies.
|
|
246
233
|
*
|
|
247
|
-
*
|
|
234
|
+
* **Reachability from an operation is not the same set the document publishes.** openapi3 emits
|
|
248
235
|
* a component for every base in an `allOf` chain whether or not an operation names it, so `Pet` and
|
|
249
|
-
* `Cat`
|
|
236
|
+
* `Cat` - bases of a `Siamese` that IS named - had no validator at all while the document declared
|
|
250
237
|
* them.
|
|
251
238
|
*
|
|
252
|
-
*
|
|
239
|
+
* **Subtypes are deliberately NOT walked here, because nothing would be walking them.** A
|
|
253
240
|
* discriminated base renders as `z.discriminatedUnion` over its subtypes, and rendering that
|
|
254
241
|
* already asks the registry for each one. A loop here looked load-bearing and was measured not to
|
|
255
242
|
* be: removing it left all 23 polymorphism and differential arms green.
|
|
256
243
|
*
|
|
257
|
-
*
|
|
258
|
-
* declaration still in flight
|
|
244
|
+
* **Wrapped in {@link captureBackEdges} and the result discarded.** This call can land on a
|
|
245
|
+
* declaration still in flight - a subtype's base is, by construction, the model that asked for it -
|
|
259
246
|
* and that is not a reference in anyone's emitted expression. Letting the back edge escape would
|
|
260
247
|
* have the enclosing declaration report a cycle it does not have and emit `z.never()`.
|
|
261
248
|
*/
|
|
@@ -268,7 +255,7 @@ export class SchemaRegistry {
|
|
|
268
255
|
/**
|
|
269
256
|
* The Zod for a single property, constraints and optionality included.
|
|
270
257
|
*
|
|
271
|
-
* Used for path/query parameters, which are `ModelProperty`s that belong to no model
|
|
258
|
+
* Used for path/query parameters, which are `ModelProperty`s that belong to no model - so they
|
|
272
259
|
* need the property-level walk rather than the type-level one, or `@minLength` on a path param
|
|
273
260
|
* would be documented and never enforced.
|
|
274
261
|
*/
|
|
@@ -282,14 +269,14 @@ export class SchemaRegistry {
|
|
|
282
269
|
/**
|
|
283
270
|
* A declaration for a shape the spec never wrote, under a name the document publishes.
|
|
284
271
|
*
|
|
285
|
-
* `@discriminated` with an envelope has no TypeSpec type for its wrapper
|
|
272
|
+
* `@discriminated` with an envelope has no TypeSpec type for its wrapper - openapi3 synthesises
|
|
286
273
|
* one and publishes it as `PetWithEnvelopeCat`, so there is a component with no `Type` to key on.
|
|
287
274
|
* Keyed by name instead, and pushed in the same dependency-first order as everything else, so the
|
|
288
275
|
* envelope declarations land above the union that references them.
|
|
289
276
|
*
|
|
290
277
|
* `declareNamed` exposes the same seam to the route emitter, which needs it for a **multipart**
|
|
291
278
|
* body: the document publishes `MultiPartRequest` as a component, and there is no walkable type
|
|
292
|
-
* for it
|
|
279
|
+
* for it - the parts have to be assembled from `@typespec/http`'s own view of the payload.
|
|
293
280
|
*/
|
|
294
281
|
declareNamed(name, build) {
|
|
295
282
|
return this.#declareSynthetic(name, build);
|
|
@@ -310,11 +297,28 @@ export class SchemaRegistry {
|
|
|
310
297
|
this.#order.push(declaration);
|
|
311
298
|
return identifier;
|
|
312
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* The structural TypeScript body for a type on a cycle.
|
|
302
|
+
*
|
|
303
|
+
* **The same walk `requests.gen.ts` uses, resolving named types to their NAMES rather than
|
|
304
|
+
* declaring them.** A cyclic declaration cannot take its type from `z.infer<typeof ...>` - that is
|
|
305
|
+
* the loop - so it needs a type written out. Every name this body references is declared in the
|
|
306
|
+
* same file either structurally (if it is on the cycle too) or as the ordinary `z.infer` alias (if
|
|
307
|
+
* it is not), so nothing further has to be emitted for it.
|
|
308
|
+
*
|
|
309
|
+
* Sharing `types.ts` rather than writing a second TypeScript renderer is what keeps the annotation
|
|
310
|
+
* agreeing with the validator: those two walks are already paired against each other by
|
|
311
|
+
* `wire-contract.gen.ts`, and a third spelling of "what this shape is" would be a third thing to
|
|
312
|
+
* drift.
|
|
313
|
+
*/
|
|
314
|
+
#structuralBodyOf(type) {
|
|
315
|
+
return withTsRefResolver((candidate) => declaredNameOf(candidate), () => typeToTsBody(this.#program, type));
|
|
316
|
+
}
|
|
313
317
|
/**
|
|
314
318
|
* Render `type` without consulting its own declaration, but letting nested types use theirs.
|
|
315
319
|
*
|
|
316
|
-
*
|
|
317
|
-
* in the resolver, which is asked at every node
|
|
320
|
+
* **The "not itself" rule belongs to the TOP of the walk, not to every depth.** It used to live
|
|
321
|
+
* in the resolver, which is asked at every node - so a model reaching itself through a property
|
|
318
322
|
* was inlined again, and again: `type/array` and `type/dictionary` in the conformance corpus both
|
|
319
323
|
* declare `children?: InnerModel[]` and both died with
|
|
320
324
|
* `RangeError: Maximum call stack size exceeded`. Skipping the resolver once, here, is what makes
|
|
@@ -336,7 +340,7 @@ export class SchemaRegistry {
|
|
|
336
340
|
* The same walk as {@link SchemaRegistry}, emitting plain TypeScript instead of Zod.
|
|
337
341
|
*
|
|
338
342
|
* Kept as a separate class rather than a mode on the schema registry because the two produce
|
|
339
|
-
* different *kinds* of declaration
|
|
343
|
+
* different *kinds* of declaration - an `interface` versus a `const` - and their reference resolvers
|
|
340
344
|
* hand back different text. Sharing {@link declaredNameOf} is the part that matters: a model named in
|
|
341
345
|
* one artefact is named the same way in the other, so the emitted assertion can pair them by name.
|
|
342
346
|
*/
|
|
@@ -356,9 +360,9 @@ export class TypeRegistry {
|
|
|
356
360
|
if (existing !== undefined)
|
|
357
361
|
return existing.name;
|
|
358
362
|
/**
|
|
359
|
-
*
|
|
360
|
-
* may refer to itself
|
|
361
|
-
* to write a tree
|
|
363
|
+
* **No laziness is needed on this side, and no refusal either.** A TypeScript `interface`
|
|
364
|
+
* may refer to itself - `interface InnerModel { children?: InnerModel[] }` is the ordinary way
|
|
365
|
+
* to write a tree - so the name alone is the whole answer. The Zod registry has to defer the
|
|
362
366
|
* same edge behind a getter only because a `const` cannot read itself while initialising.
|
|
363
367
|
*/
|
|
364
368
|
if (this.#inProgress.has(type))
|
|
@@ -370,21 +374,21 @@ export class TypeRegistry {
|
|
|
370
374
|
name,
|
|
371
375
|
/**
|
|
372
376
|
* Only a plain object literal can be an `interface`. A permissive response is
|
|
373
|
-
* `{
|
|
377
|
+
* `{ ...pinned } & Record<string, unknown>`, and `export interface X { ... } & ...` does not parse -
|
|
374
378
|
* so an intersection is emitted as a type alias instead.
|
|
375
379
|
*/
|
|
376
380
|
isObject: source.startsWith("{") && !source.includes("} & "),
|
|
377
381
|
/**
|
|
378
|
-
*
|
|
382
|
+
* **A vocabulary alias is NOT exported.**
|
|
379
383
|
*
|
|
380
384
|
* It restates a vocabulary enum's members from the spec, and the vocabularies artefact already
|
|
381
|
-
* exports the same 27 names derived from the runtime tuples
|
|
385
|
+
* exports the same 27 names derived from the runtime tuples - exporting both collides on
|
|
382
386
|
* every one of them the moment the package re-exports this file.
|
|
383
387
|
*
|
|
384
388
|
* Restating rather than referencing is deliberate, and it is what makes the drift visible:
|
|
385
389
|
* the emitted **Zod** defers to the runtime tuple while this type says what the spec
|
|
386
390
|
* *declares*, so the two disagreeing is exactly the lie the document is telling. Referencing
|
|
387
|
-
* the contracts type instead would make every assertion pass and publish the lie unchecked
|
|
391
|
+
* the contracts type instead would make every assertion pass and publish the lie unchecked -
|
|
388
392
|
* which is how `FilingState` came to advertise a `failed` state that cannot occur while
|
|
389
393
|
* omitting the generated one a consumer actually uses.
|
|
390
394
|
*/
|
|
@@ -396,16 +400,17 @@ export class TypeRegistry {
|
|
|
396
400
|
return declaration.name;
|
|
397
401
|
}
|
|
398
402
|
/**
|
|
399
|
-
* The Zod-free type for a single property
|
|
403
|
+
* The Zod-free type for a single property - used for path and query parameters, which are
|
|
400
404
|
* `ModelProperty`s belonging to no model. Without routing them through the registry a named model
|
|
401
405
|
* or enum in a parameter position is inlined rather than referenced.
|
|
402
406
|
*/
|
|
403
|
-
|
|
407
|
+
/** `wireName` names an HTTP parameter as the wire does - see {@link propertyToTs}. */
|
|
408
|
+
expressionForProperty(property, wireName) {
|
|
404
409
|
return withTsRefResolver((candidate) => {
|
|
405
410
|
if (declaredNameOf(candidate) === undefined)
|
|
406
411
|
return undefined;
|
|
407
412
|
return this.expressionFor(candidate);
|
|
408
|
-
}, () => propertyToTs(this.#program, property));
|
|
413
|
+
}, () => propertyToTs(this.#program, property, wireName));
|
|
409
414
|
}
|
|
410
415
|
#inline(type) {
|
|
411
416
|
return withTsRefResolver((candidate) => {
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ZodType } from "zod";
|
|
2
2
|
/**
|
|
3
|
-
* **What generated code imports at run time
|
|
3
|
+
* **What generated code imports at run time - and the only thing this package puts in its output.**
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* **Kept deliberately tiny, and deliberately free of every TypeSpec import.** The main entry point
|
|
6
6
|
* is an emitter: it pulls in `@typespec/compiler` and exists at build time. This module is what an
|
|
7
|
-
* *application* touches
|
|
7
|
+
* *application* touches - an Express or Fastify service reading the arms this emitter wrote - and
|
|
8
8
|
* making it reach the compiler would put a build-time dependency into a server's runtime graph for
|
|
9
9
|
* the sake of two declarations. `test/packaging.test.ts` asserts the separation rather than trusting
|
|
10
10
|
* it.
|
|
@@ -17,41 +17,41 @@ import type { ZodType } from "zod";
|
|
|
17
17
|
* One status an operation may answer with, and the schema the document publishes for it.
|
|
18
18
|
*
|
|
19
19
|
* `when` is present only where an operation declares two success arms discriminated by a property of
|
|
20
|
-
* the result
|
|
20
|
+
* the result - the application reads that property, because only it knows how to see inside its own
|
|
21
21
|
* result envelope. Everything else about the choice is decided at generation time.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
* error response looks like, and the generated server passed on only the success statuses
|
|
23
|
+
* **Failure arms are here too, and for a while they were not.** The document declares what an
|
|
24
|
+
* error response looks like, and the generated server passed on only the success statuses - so an
|
|
25
25
|
* application could not check what it sends when something goes wrong, because the schema never
|
|
26
26
|
* arrived. Measured on one service: `401` and two `default` responses declared, zero emitted.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
28
|
+
* **The arms arrive in OpenAPI's precedence order, so the FIRST match is the right one.** An exact
|
|
29
29
|
* code, then a range, then the catch-all. That ordering is not a convention invented here: the
|
|
30
30
|
* Responses Object says "if a response is defined using an explicit code, the explicit code
|
|
31
31
|
* definition takes precedence over the range definition for that code", and `default` means every
|
|
32
32
|
* status not otherwise listed. Sorting once at generation time is what keeps every application from
|
|
33
|
-
* re-deriving the rule
|
|
33
|
+
* re-deriving the rule - and from getting it silently wrong when a `404` and a `4XX` both apply.
|
|
34
34
|
*
|
|
35
|
-
*
|
|
35
|
+
* **`schema: undefined` is a statement, not an absence.** A response the document says carries no
|
|
36
36
|
* body has no validator, and that is the truthful answer. It is also why the generated arms are
|
|
37
37
|
* annotated with `satisfies readonly ResponseArm[]` rather than `as const`: measured, `as const`
|
|
38
38
|
* catches a wrong `status` but is **blind** to a misspelled or omitted `schema`, because a bodyless
|
|
39
|
-
* arm is legitimate
|
|
39
|
+
* arm is legitimate - so the typo produces a valid-looking arm that validates nothing.
|
|
40
40
|
*/
|
|
41
41
|
export interface ResponseArm {
|
|
42
42
|
/**
|
|
43
43
|
* The status this arm answers with: an exact code, a `4XX`-style range, or the catch-all.
|
|
44
44
|
*
|
|
45
|
-
*
|
|
45
|
+
* **`"default"` is carried as itself, not resolved to a number.** OpenAPI's `default` response
|
|
46
46
|
* means "any status not listed", and an emitter picking one would be answering a question the
|
|
47
47
|
* contract deliberately left to the application. The application maps its own failure to a status;
|
|
48
48
|
* this says which schema the body must match once it has.
|
|
49
49
|
*
|
|
50
|
-
*
|
|
50
|
+
* **A range is carried as itself for the same reason, and it used to be dropped entirely.**
|
|
51
51
|
* `@minValue(400) @maxValue(499) @statusCode _: int32` is a `4XX` entry in the document; the
|
|
52
52
|
* emitter handled `"*"` and exact numbers and emitted nothing at all for the third case, so a
|
|
53
53
|
* service could publish a whole class of failures with no arm to check them against. Expanding
|
|
54
|
-
* `4XX` to a hundred arms would be inventing statuses the contract never listed
|
|
54
|
+
* `4XX` to a hundred arms would be inventing statuses the contract never listed - the range IS
|
|
55
55
|
* what the document says, and matching against it is the application's to do, once, here.
|
|
56
56
|
*/
|
|
57
57
|
readonly status: number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
|
|
@@ -62,7 +62,7 @@ export interface ResponseArm {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
/**
|
|
65
|
-
* The arm that governs a status
|
|
65
|
+
* The arm that governs a status - **the rule the OpenAPI specification states, applied once here.**
|
|
66
66
|
*
|
|
67
67
|
* An application knows which status it is answering with; the document knows which schema that status
|
|
68
68
|
* has to match. Joining the two means resolving an overlap, because `404`, `4XX` and `default` can
|
|
@@ -71,8 +71,8 @@ export interface ResponseArm {
|
|
|
71
71
|
* code, the explicit code definition takes precedence over the range definition for that code".
|
|
72
72
|
* `default` is by definition every status not otherwise listed, so it is last.
|
|
73
73
|
*
|
|
74
|
-
*
|
|
75
|
-
* Left to applications it gets written as "the first arm with a status
|
|
74
|
+
* **Shipped beside the arms rather than left to the caller, because this is where it goes wrong.**
|
|
75
|
+
* Left to applications it gets written as "the first arm with a status >= 400" - which is literally
|
|
76
76
|
* what one consumer's wiring did before ranges existed, and which silently skips every range arm
|
|
77
77
|
* while looking correct. A package that emits an array containing `4XX` and `default` and does not
|
|
78
78
|
* ship the rule for reading it has shipped a footgun.
|
package/dist/src/runtime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The arm that governs a status
|
|
2
|
+
* The arm that governs a status - **the rule the OpenAPI specification states, applied once here.**
|
|
3
3
|
*
|
|
4
4
|
* An application knows which status it is answering with; the document knows which schema that status
|
|
5
5
|
* has to match. Joining the two means resolving an overlap, because `404`, `4XX` and `default` can
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* code, the explicit code definition takes precedence over the range definition for that code".
|
|
9
9
|
* `default` is by definition every status not otherwise listed, so it is last.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* Left to applications it gets written as "the first arm with a status
|
|
11
|
+
* **Shipped beside the arms rather than left to the caller, because this is where it goes wrong.**
|
|
12
|
+
* Left to applications it gets written as "the first arm with a status >= 400" - which is literally
|
|
13
13
|
* what one consumer's wiring did before ranges existed, and which silently skips every range arm
|
|
14
14
|
* while looking correct. A package that emits an array containing `4XX` and `default` and does not
|
|
15
15
|
* ship the rule for reading it has shipped a footgun.
|
package/dist/src/streams.d.ts
CHANGED
|
@@ -2,39 +2,39 @@ import type { Program, Type } from "@typespec/compiler";
|
|
|
2
2
|
/**
|
|
3
3
|
* Optional support for `@typespec/streams`.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* **Lazily imported, and absent is a normal answer.** It is a separate library most specs never
|
|
6
6
|
* install, so a static import would make this emitter unusable without it. The structure follows
|
|
7
|
-
* `@typespec/openapi3`'s own `sse-module.js`
|
|
8
|
-
* and `undefined` when it is missing
|
|
7
|
+
* `@typespec/openapi3`'s own `sse-module.js` - an optional peer dependency, a guarded `import()`,
|
|
8
|
+
* and `undefined` when it is missing - because an emitter that resolves it differently from the
|
|
9
9
|
* reference implementation will disagree with it about which specs it can serve. openapi3 needs
|
|
10
10
|
* `@typespec/events` and `@typespec/sse` as well, to build the per-event `itemSchema` OpenAPI 3.2
|
|
11
11
|
* carries; this emitter only needs to know WHAT is streamed, so one is enough.
|
|
12
12
|
*
|
|
13
13
|
* **Why this exists at all.** An SSE operation returns `SSEStream<UnnamedEvents>`, and under
|
|
14
|
-
* OpenAPI 3.1 its response body is `{"type": "string"}`
|
|
15
|
-
* PAYLOADS (`Unnamed.Info`, `Named.ResponseCreated`,
|
|
14
|
+
* OpenAPI 3.1 its response body is `{"type": "string"}` - the event stream itself. The event
|
|
15
|
+
* PAYLOADS (`Unnamed.Info`, `Named.ResponseCreated`, ...) are published as components that no path
|
|
16
16
|
* references: openapi3 builds `itemSchema` describing them for OpenAPI 3.2, and under 3.1 emits the
|
|
17
17
|
* components as a by-product. Measured: the 3.1 document mentions neither `itemSchema` nor
|
|
18
18
|
* `contentSchema`, yet declares all five payload models.
|
|
19
19
|
*
|
|
20
20
|
* They are still part of the published vocabulary, and reachable in the TypeSpec graph even though
|
|
21
|
-
* the 3.1 paths do not reach them
|
|
21
|
+
* the 3.1 paths do not reach them - `getStreamOf` hands back whatever the stream carries, an
|
|
22
22
|
* `@events` union for SSE and the item model for JSON Lines. So the payloads get validators, and a
|
|
23
23
|
* consumer streaming events has something to check each `data:` against.
|
|
24
24
|
*/
|
|
25
25
|
export interface StreamModule {
|
|
26
|
-
/** The type a stream carries
|
|
26
|
+
/** The type a stream carries - an `@events` union for SSE, the item model for JSON Lines. */
|
|
27
27
|
readonly streamOf: (program: Program, type: Type) => Type | undefined;
|
|
28
28
|
}
|
|
29
29
|
export declare function resolveStreamModule(): Promise<StreamModule | undefined>;
|
|
30
30
|
export declare function withStreamModule<T>(module: StreamModule | undefined, run: () => T): T;
|
|
31
31
|
/**
|
|
32
|
-
* What an operation streams, or `undefined` when it streams nothing
|
|
32
|
+
* What an operation streams, or `undefined` when it streams nothing - which is every operation in a
|
|
33
33
|
* spec that does not use these libraries, and every ordinary operation in one that does.
|
|
34
34
|
*
|
|
35
|
-
*
|
|
35
|
+
* **Whatever the stream carries, not only an `@events` union.** The first cut required
|
|
36
36
|
* `isEvents`, which fixed SSE and left `JsonlStream<Info>` behind: JSON Lines carries the item model
|
|
37
|
-
* directly. The guard bought nothing
|
|
37
|
+
* directly. The guard bought nothing - the streamed type is the published vocabulary in both cases -
|
|
38
38
|
* and dropping it removed a second optional dependency along with the special case.
|
|
39
39
|
*/
|
|
40
40
|
export declare function streamedTypeOf(program: Program, returnType: Type): Type | undefined;
|
package/dist/src/streams.js
CHANGED
|
@@ -27,12 +27,12 @@ export function withStreamModule(module, run) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
|
-
* What an operation streams, or `undefined` when it streams nothing
|
|
30
|
+
* What an operation streams, or `undefined` when it streams nothing - which is every operation in a
|
|
31
31
|
* spec that does not use these libraries, and every ordinary operation in one that does.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
33
|
+
* **Whatever the stream carries, not only an `@events` union.** The first cut required
|
|
34
34
|
* `isEvents`, which fixed SSE and left `JsonlStream<Info>` behind: JSON Lines carries the item model
|
|
35
|
-
* directly. The guard bought nothing
|
|
35
|
+
* directly. The guard bought nothing - the streamed type is the published vocabulary in both cases -
|
|
36
36
|
* and dropping it removed a second optional dependency along with the special case.
|
|
37
37
|
*/
|
|
38
38
|
export function streamedTypeOf(program, returnType) {
|
package/dist/src/tsp-index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What TypeSpec's compiler loads for this library.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `@trimmed`, `@loose`, `@externalValues` and `@refine`
|
|
4
|
+
* **There is deliberately no `$decorators` export.** This emitter's ancestor defined four -
|
|
5
|
+
* `@trimmed`, `@loose`, `@externalValues` and `@refine` - and every one of them let the spec state
|
|
6
6
|
* something `@typespec/openapi3` could not publish, so the emitted validator enforced a rule no
|
|
7
7
|
* caller reading the contract could see. A decorator here is not a convenience; it is a second
|
|
8
8
|
* contract. If a spec needs to say something, it says it in a way the document carries.
|
package/dist/src/tsp-index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What TypeSpec's compiler loads for this library.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `@trimmed`, `@loose`, `@externalValues` and `@refine`
|
|
4
|
+
* **There is deliberately no `$decorators` export.** This emitter's ancestor defined four -
|
|
5
|
+
* `@trimmed`, `@loose`, `@externalValues` and `@refine` - and every one of them let the spec state
|
|
6
6
|
* something `@typespec/openapi3` could not publish, so the emitted validator enforced a rule no
|
|
7
7
|
* caller reading the contract could see. A decorator here is not a convenience; it is a second
|
|
8
8
|
* contract. If a spec needs to say something, it says it in a way the document carries.
|
package/dist/src/types.d.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { type ModelProperty, type Program, type Type } from "@typespec/compiler";
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* A property line, including its optionality marker.
|
|
4
|
+
*
|
|
5
|
+
* `wireName` overrides the key for an HTTP PARAMETER, whose wire name comes from `@header("x-thing")`
|
|
6
|
+
* or `@query("$select")` rather than from any JSON encoding. `resolveEncodedName` cannot see those,
|
|
7
|
+
* so a header renamed on the wire was keyed here by its TypeScript property name while the emitted
|
|
8
|
+
* validator keyed it by the wire name. A consumer checking its handlers against the generated input
|
|
9
|
+
* types then failed to typecheck against a server that was correct: `z.object({ "x-thing": ... })`
|
|
10
|
+
* on one side and `{ thing: string }` on the other.
|
|
11
|
+
*/
|
|
12
|
+
export declare function propertyToTs(program: Program, property: ModelProperty, wireName?: string): string;
|
|
4
13
|
export declare function withTsRefResolver<T>(resolver: (type: Type) => string | undefined, run: () => T): T;
|
|
5
14
|
export declare function typeToTs(program: Program, type: Type): string;
|
|
6
15
|
/**
|
|
7
|
-
* The same walk, except that `type` is never resolved to its own name
|
|
16
|
+
* The same walk, except that `type` is never resolved to its own name - see `SchemaRegistry.#inline`
|
|
8
17
|
* for why the "not itself" rule has to sit at the top of the walk rather than inside the resolver.
|
|
9
18
|
*/
|
|
10
19
|
export declare function typeToTsBody(program: Program, type: Type): string;
|