typespec-http-zod 0.1.0

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