typespec-http-zod 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bison Digital
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,269 @@
1
+ # typespec-http-zod
2
+
3
+ Zod validators and framework-free contract types from a TypeSpec HTTP service — agreeing, keyword for
4
+ keyword, with the OpenAPI document [`@typespec/openapi3`](https://typespec.io) publishes from the same
5
+ source.
6
+
7
+ It is a **library and an emitter**. Use the emitter to generate validators for any server — Express,
8
+ Fastify, Elysia, a Workers fetch handler, a typed client — or use the API to build an emitter of your
9
+ own. [`typespec-hono`](https://github.com/bison-digital/typespec-hono) is the second kind, and is
10
+ where the API is proved.
11
+
12
+ ## The governing rule
13
+
14
+ **If the document cannot say it, the validator must not enforce it.**
15
+
16
+ A validator that checks something the published contract never states is a rule no caller reading that
17
+ contract can see. This package ships **zero decorators** for exactly that reason — four once existed,
18
+ and every one let a spec state something `@typespec/openapi3` could not publish — and asserts the
19
+ class is empty rather than trusting it. Where openapi3 decides something, its rule is read from its
20
+ source and copied, refusals included: the same spec has to be representable by both emitters or by
21
+ neither, or a differential between them means nothing.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install typespec-http-zod
27
+ ```
28
+
29
+ ⚠️ **A regular dependency, not a dev one, if your application calls `armFor`.** The emitter itself
30
+ runs at build time, and the generated files import only `import type { ResponseArm }` — a type, which
31
+ erases. But [`./runtime`](#answering-a-request) also exports `armFor`, a **function**, and it exists
32
+ precisely so applications do not re-derive OpenAPI's response precedence by hand. An application that
33
+ calls it and installed this package with `--save-dev` typechecks, builds, and runs in development,
34
+ then fails on deploy:
35
+
36
+ ```
37
+ Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'typespec-http-zod'
38
+ imported from dist/server.js
39
+ ```
40
+
41
+ Measured in a fresh project installed from a `pnpm pack` tarball: identical build output, `pnpm
42
+ install --prod`, **exit 1**; the same bytes with the package in `dependencies`, **exit 0**. If you use
43
+ only the emitted validators and never import `armFor`, `--save-dev` is sufficient.
44
+
45
+ Peers: `@typespec/compiler`, `@typespec/http`, `@typespec/openapi`, and `zod` (the generated output
46
+ imports it). `@typespec/versioning` and `@typespec/streams` are **optional** — they are resolved
47
+ behind a guarded `import()`, so a spec declaring neither needs neither.
48
+
49
+ ```yaml
50
+ # tspconfig.yaml
51
+ emit:
52
+ - typespec-http-zod
53
+ options:
54
+ typespec-http-zod:
55
+ emitter-output-dir: "{project-root}/src/generated"
56
+ ```
57
+
58
+ ## What it emits
59
+
60
+ ⚠️ **Only `schemas.gen.ts` is emitted by the configuration above.** The other three are written only
61
+ when `contracts-output-dir` names somewhere to put them — they are the framework-free contract types
62
+ and the assertions that pair them against the validators, and a consumer that wants validators alone
63
+ should not have them appear uninvited. Add it to get all four:
64
+
65
+ ```yaml
66
+ # tspconfig.yaml
67
+ emit:
68
+ - typespec-http-zod
69
+ options:
70
+ typespec-http-zod:
71
+ emitter-output-dir: "{project-root}/src/generated"
72
+ contracts-output-dir: "{project-root}/src/generated"
73
+ ```
74
+
75
+ | file | what it is | emitted when |
76
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
77
+ | `schemas.gen.ts` | every validator: the component schemas, **and** each operation's path, query, header, body and response validators, plus the arms it may answer with | always |
78
+ | `requests.gen.ts` | framework-free contract types — plain TypeScript, no imports, so they cross layers that must not see a validation library | `contracts-output-dir` is set |
79
+ | `vocabularies.gen.ts` | each enum's members as a runtime tuple, because a generated type cannot be iterated | `contracts-output-dir` is set |
80
+ | `wire-contract.gen.ts` | assertions pairing the emitted Zod against those types, so the two walks cannot disagree silently | that **and** `contracts-package` |
81
+
82
+ Per operation you get `readWidgetPath`, `readWidgetQuery`, `readWidgetHeader`, `readWidgetBody`,
83
+ `readWidgetResponse` and `readWidgetResponses` — named from **the operation id the document
84
+ publishes**, which is not always the bare operation name.
85
+
86
+ ⚠️ **An operation inside a namespace is named after the whole id.** `op readWidget` inside
87
+ `namespace Widgets` has operation id `Widgets_readWidget`, so the identifiers are
88
+ `Widgets_readWidgetPath` and so on. Namespaces are the idiomatic way to group routes, so this is the
89
+ common case rather than the exception — read the emitted file for the names rather than assuming
90
+ them.
91
+
92
+ ⚠️ **A validator that is already a component gets no second name.** `readWidgetResponse` is emitted
93
+ only when the response shape needs a name of its own; where the operation answers with a declared
94
+ model the arm refers to that model's schema directly — `widgetSchema`, not `readWidgetResponse`. The
95
+ same holds for a request body: `op createWidget(@body body: WidgetCreate)` is validated by
96
+ `widgetCreateSchema`. The mapping from an operation to the schema that validates it is published
97
+ through the [API](#building-an-emitter-on-this) as `EmittedService.schemaNames`; from the emitted file
98
+ alone, an operation with no `…Body` const is one whose body is a named component.
99
+
100
+ ## Options
101
+
102
+ | option | what it does |
103
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
104
+ | `seal-object-schemas` | whether a model closed in the spec REJECTS an undeclared property rather than stripping it. ⚠️ **Set this to whatever `@typespec/openapi3`'s option of the same name is set to** — the two emitters answer the same question about the same models and neither can read the other's configuration. Defaults to `false`, which is openapi3's default. |
105
+ | `contracts-output-dir` | where the framework-free contract types go, when they should not sit beside the validators. Omitted, they are not emitted. |
106
+ | `contracts-package` | the specifier the emitted Zod imports shared types from. **No default** — it once defaulted to one repository's package name, so a consumer who configured nothing got validators importing from a package they had never heard of. With none named, enums are emitted inline and the output depends on nothing. |
107
+ | `key-vocabularies` | models whose PROPERTY NAMES are also emitted as a runtime tuple. For a closed key set that is a contract fact but cannot be a `Record` key type. A name matching no model is reported, not ignored. |
108
+ | `runtime-module` | where the generated files import `ResponseArm` from. Defaults to `typespec-http-zod/runtime`. |
109
+ | `services` | per-`@service` overrides, keyed by namespace name, for a spec that publishes more than one surface. |
110
+
111
+ ## Answering a request
112
+
113
+ No server framework appears below, because none is needed: the emitted validators are plain Zod and
114
+ `./runtime` is two exports. Express, Fastify, Elysia or a bare `fetch` handler each supply the
115
+ `params`/`query`/`body` this reads.
116
+
117
+ ```ts
118
+ import { armFor, type ResponseArm } from "typespec-http-zod/runtime";
119
+ import { Widgets_readWidgetPath, Widgets_readWidgetResponses } from "./generated/schemas.gen.js";
120
+
121
+ /** Answer a status with a body the document says that status carries. */
122
+ function respond(arms: readonly ResponseArm[], status: number, body: unknown) {
123
+ const arm = armFor(arms, status);
124
+ if (arm === undefined) throw new Error(`no declared arm for ${status}`);
125
+ return { status, body: arm.schema === undefined ? undefined : arm.schema.parse(body) };
126
+ }
127
+
128
+ export function readWidget(params: Record<string, unknown>, widget: unknown | undefined) {
129
+ const parsed = Widgets_readWidgetPath.safeParse(params);
130
+ if (!parsed.success) return { status: 400, body: parsed.error.issues };
131
+ return widget === undefined
132
+ ? respond(Widgets_readWidgetResponses, 404, { code: "not_found", message: "no such widget" })
133
+ : respond(Widgets_readWidgetResponses, 200, widget);
134
+ }
135
+ ```
136
+
137
+ **`armFor` is the whole reason `./runtime` exists.** An operation can declare `404`, `4XX` and
138
+ `default` at once and all three describe a 404. OpenAPI settles it — an explicit code beats a range,
139
+ and `default` is every status not otherwise listed — and a package that emits arms containing `4XX`
140
+ and `default` without shipping the rule for reading them has shipped a footgun. Measured against the
141
+ arms emitted for such an operation:
142
+
143
+ | answered | arm chosen | body validated against |
144
+ | -------- | ---------- | ---------------------- |
145
+ | `200` | `200` | the success model |
146
+ | `404` | `404` | `NotFound` |
147
+ | `429` | `4XX` | `Throttled` |
148
+ | `500` | `default` | `Unexpected` |
149
+
150
+ ⚠️ **A path or query value is a string on the wire, and the emitted validators already know.** A
151
+ parameter the document types as `integer` is emitted wrapped in a `z.preprocess` that decodes `"1"`
152
+ to `1` — and **decodes rather than coerces**, so `?limit=` stays empty and fails the schema instead of
153
+ arriving as `0`. A flattened list (`?tags=a,b,c`) is split on the delimiter the document's `style`
154
+ implies before the array schema runs. Hand the raw values straight in; do not pre-parse them.
155
+
156
+ ## Building an emitter on this
157
+
158
+ The exported API — `emitHttpZod`, `EmittedService`, `EmittedRoute`, `collectRoutes` and the rest — is
159
+ what an emitter for another framework calls. `emitHttpZod` returns one `EmittedService` per
160
+ `@service`, and `EmittedService.schemaNames` maps each operation id to the identifiers its validators
161
+ were declared under, so a wrapping emitter imports names rather than agreeing about them.
162
+
163
+ ```ts
164
+ const services = await emitHttpZod(context, { defaultRuntimeModule: "my-emitter/runtime" });
165
+ for (const service of services) {
166
+ for (const route of service.routes) {
167
+ const names = service.schemaNames.get(route.operationId);
168
+ // names.path / names.query / names.header / names.body / names.response / names.responses
169
+ }
170
+ }
171
+ ```
172
+
173
+ ⚠️ **`defaultRuntimeModule` is a default, not an override** — a consumer's `runtime-module` still
174
+ wins. It exists so a wrapping emitter whose generated files import more than `ResponseArm` can point
175
+ them at its own module.
176
+
177
+ ## What it refuses, and why
178
+
179
+ Every refusal is a **diagnostic**, not a thrown error: it points at the offending declaration, carries
180
+ a code you can search for, and does not stop the walk — so one compile names every problem rather
181
+ than the first.
182
+
183
+ | code | why |
184
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
185
+ | `unsupported-scalar` | a scalar with no known base and no `@encode`. Give it `extends`, or encode it. |
186
+ | `unsupported-type` | a construct with no runtime representation — `never` is the reachable case. |
187
+ | `unsupported-default` | a default value that is neither a scalar nor an empty collection. |
188
+ | `empty-union` | a union with no representable variants has nothing to validate against. |
189
+ | `circular-model` | a cycle closing through a union variant or dictionary value, neither of which is a property a getter can sit on. Ordinary recursion through a model property is fully supported. |
190
+ | `unknown-key-vocabulary` | a `key-vocabularies` entry naming no model. Reported because the failure mode is silence: a missing vocabulary looks exactly like an empty one. |
191
+ | `unsupported-status-code-range` | OpenAPI keys a range as `1XX`…`5XX`, so `@minValue(494) @maxValue(499)` has nowhere to go. ⚠️ **Refused because `@typespec/openapi3` refuses it**, with the same rule copied from its source. A range covering a bucket exactly — `@minValue(400) @maxValue(499)` — is supported. |
192
+ | `undeclared-discriminator` | ⚠️ **Upstream, and not something you can fix with an emitter option.** `@discriminated(#{envelope: "none"})` puts the discriminator inside each variant on the wire, and openapi3 emits `oneOf` with a `discriminator` keyword while never adding that property to the variant schema — which OpenAPI 3.1 forbids ("the `discriminator` field MUST be a required field"). Tracked as [`microsoft/typespec#7141`](https://github.com/microsoft/typespec/issues/7141), open and maintainer-filed. **It is avoidable in your spec**: declare the discriminator on the variant (`model Cat { kind: "cat"; … }`) and openapi3 publishes it as required, with no divergence and no refusal. |
193
+
194
+ ## Known limits
195
+
196
+ Stated here rather than left as numbers in a baseline file, because a number in a baseline is not a
197
+ stated limitation once a package is published. Each is re-measured by the suite.
198
+
199
+ - **`format` is deliberately not enforced, and that is now CHECKED rather than counted.** Under JSON
200
+ Schema 2020-12 `format` is an annotation, not a validation keyword, so turning one into a check
201
+ would enforce something the document does not assert. 136 annotations go unenforced, and
202
+ `test/vocabulary.test.ts` refuses any format-derived Zod call as a class — so turning it on breaks a
203
+ test rather than moving a number.
204
+ - **4 negotiated response bodies cannot be attributed to a single arm.** OpenAPI lists one
205
+ body per media type against members that each carry their own, so there is no one arm to compare
206
+ them to. The status-to-body mapping is still graded for them.
207
+ - **4 response bodies reduce to no readable kind on one side** — a stream, or a union. Counted, never
208
+ silently passed.
209
+ - **A `@head` operation gets validators here and cannot be served by every router.** That is a fact
210
+ about the server, not about this package — see `typespec-hono`, which refuses it and says why.
211
+ - **`int64` and `uint64` are validated as JavaScript numbers, so values above `2^53-1` are refused —
212
+ and `@encode(string)` is the fix.** The document publishes `type: integer` with `format: int64`,
213
+ which states no bound; the emitted `z.number().int()` refuses anything beyond
214
+ `Number.MAX_SAFE_INTEGER`. That is a constraint the document does not state, and it is kept anyway,
215
+ because it is imposed by the runtime rather than invented here — **above `2^53-1` an integer is no
216
+ longer uniquely representable as a JavaScript number**, so a validator cannot certify that the value
217
+ it holds is the value that was sent. Measured: `9007199254740993` reaches a handler as
218
+ `9007199254740992`, and int64's maximum as `9223372036854776000`, both through `JSON.parse` before
219
+ any validator runs. Accepting them would stamp "valid" on a number nobody sent, which is the one
220
+ answer a validator must never give. `z.int64()` is not an alternative: it is `bigint`-based and
221
+ `JSON.parse` never produces a `bigint`, so it would refuse every JSON body.
222
+
223
+ ⚠️ **TypeSpec's own remedy works and costs one decorator.** `@encode(string) value: int64` emits
224
+ `z.string()`, and openapi3 publishes `type: string` with `format: int64` — the two agree exactly, and
225
+ the digits survive the wire intact. Use it for any 64-bit integer whose values can exceed `2^53-1`:
226
+ identifiers, balances in minor units, timestamps in nanoseconds.
227
+
228
+ - **The emitted output requires `zod`, not `zod/mini`.** The tree-shakeable variant has no chained
229
+ methods at all — measured on 4.4.3, `typeof z.string().optional`, `.nullable` and `.min` are each
230
+ `undefined` — and the emitted validators use `.optional()`, `.nullable()`, `.default()`, `.strict()`,
231
+ `.loose()` and `.catchall()`. A browser or Workers consumer therefore pays for the full `zod` build.
232
+ This is a property of the emitted spelling rather than of the schemas, so it is fixable; it is
233
+ recorded here as a measured fact rather than a plan.
234
+
235
+ ## How it is graded
236
+
237
+ Nothing here is judged by fixtures we wrote alone.
238
+
239
+ - **Conformance differential** — both emitters run from ONE compile of
240
+ [`@typespec/http-specs`](https://www.npmjs.com/package/@typespec/http-specs), Microsoft's own
241
+ scenario corpus, and the assertion is that the validators say what the document says. Components,
242
+ request parameters, declared statuses and response bodies. **0 divergences.**
243
+ - **Round-trip against APIs nobody here designed** — the OpenAPI Initiative's teaching example and the
244
+ Swagger Petstore, converted to TypeSpec and compiled back out, then compared against the originals.
245
+ **22 operations, none lost, no shape disagreements.** Every other suite measures this emitter
246
+ against material that already knows about it; this one asks what a first adopter asks. The
247
+ documents are vendored with digests the suite asserts, never fetched.
248
+ - **Request bodies are compared, and were compared by nothing at all** — 21 by shape and 36 by kind. Parameters were graded and responses were graded; what a caller may SEND, the largest surface of most APIs, had no arm.
249
+ - **Response bodies are compared, not counted** — 24 inline bodies by SHAPE and
250
+ 44 non-object bodies by KIND. This surface was a bare number for a long time, and closing it
251
+ immediately found three positions where the emitter required a string for a raw binary body the
252
+ document says nothing about.
253
+ - **`content-type` and `accept` are compared** against the `content` keys that state them — 77
254
+ positions, no divergences. OpenAPI declares media types through `requestBody.content` and each
255
+ response's `content` rather than as parameters, so the parameter arm sets these aside; a separate
256
+ arm compares them where the document actually puts them.
257
+ - **Depth fixtures** for what a protocol corpus does not contain: it declares **no constraints at
258
+ all**, so `test/reference/constraints.tsp` exercises every constraint keyword once, on the type it
259
+ is legal on.
260
+ - **Every counting arm has a non-vacuity floor.** An arm that stops firing otherwise reports agreement
261
+ about nothing, which has happened here and is why the floors exist.
262
+ - **The emitted output is compiled** by the real compiler under `strict` and
263
+ `exactOptionalPropertyTypes`, because "it emitted" is not "it works".
264
+ - **The package is packed and its tarball inspected**, because `files` is a set of globs and a glob
265
+ that stops matching is indistinguishable from one that matches nothing.
266
+
267
+ ## Licence
268
+
269
+ MIT
@@ -0,0 +1,304 @@
1
+ import { type EmitContext, type Program, type Type } from "@typespec/compiler";
2
+ import { type HttpOperation, type HttpService } from "@typespec/http";
3
+ import { SchemaRegistry } from "./registry.js";
4
+ /**
5
+ * `typespec-http-zod` — Zod validators and framework-free contract types from a TypeSpec HTTP
6
+ * service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
7
+ *
8
+ * **The governing rule: if the document cannot say it, the validator must not enforce it.** A
9
+ * validator that checks something the published contract never states is a rule no caller reading
10
+ * that contract can see. This package ships **zero decorators** for exactly that reason — four were
11
+ * deleted, each of which let a spec state something openapi3 could not publish — and asserts the
12
+ * class is empty rather than trusting it. Where openapi3 decides something, its rule is read from its
13
+ * source and copied, refusals included: the same spec has to be representable by both emitters or by
14
+ * neither, or a differential between them means nothing.
15
+ *
16
+ * **Why not `@typespec/http-server-js`.** The first-party JS server emitter is `0.58.0-alpha.*`,
17
+ * self-describes as "highly experimental", and emits a Node.js `http` router. It also answers a
18
+ * different question: this package emits no server at all. Validators are useful to Express, Fastify,
19
+ * Elysia, a Workers fetch handler or a typed client, and a server generator is a thin consumer of
20
+ * them — `typespec-hono` is one.
21
+ *
22
+ * **Built on the stable 1.x surface only** — `$onEmit` plus `@typespec/http` — rather than on
23
+ * `@typespec/emitter-framework` (pre-1.0) or `@typespec/asset-emitter`, which announces its own
24
+ * replacement in its package description. Fewer moving parts under something that has to keep working.
25
+ */
26
+ export { $lib } from "./lib.js";
27
+ /**
28
+ * The option contract, published so an emitter built on this library can **derive** its own rather
29
+ * than restate it.
30
+ *
31
+ * ⚠️ **Two schemas that have to agree are two lists that will not.** A server generator adds its own
32
+ * keys and forwards the rest; if it restates this one, an option added here is silently rejected as
33
+ * unknown by the wrapper, or accepted and never passed on. Neither failure is visible in output.
34
+ */
35
+ export { EmitterOptionsSchema, type EmitterOptions } from "./lib.js";
36
+ /**
37
+ * How a property name is spelled as an object key — quoted only when it has to be.
38
+ *
39
+ * ⚠️ **Published because a consumer emitting code beside these validators has to spell keys the
40
+ * SAME way, and getting it wrong produces a file that does not parse.** An unquoted
41
+ * `x-ms-test-header` is exactly that, and it happened: a corpus scenario declares one and the
42
+ * emitted module stopped being loadable. This is not a style choice a second emitter may make
43
+ * independently.
44
+ */
45
+ export { objectKey } from "./zod.js";
46
+ /**
47
+ * The success status a row registers.
48
+ *
49
+ * An operation declares exactly one success status. A range (`2XX`) or a wildcard is not a status a
50
+ * handler can return, so it is not something this emitter can turn into a `respond()` call — an
51
+ * operation carrying only those is skipped rather than guessed at.
52
+ */
53
+ export declare function successStatusOf(operation: HttpOperation): number | undefined;
54
+ /**
55
+ * **Every** success status the operation declares, ascending.
56
+ *
57
+ * An operation that answers `202` when the work was merely accepted and `200` when it completed
58
+ * inline declares both, and something has to choose. A route with two declared statuses and no way to
59
+ * choose would always answer the first, publishing a status that can never occur; the arm carrying
60
+ * asserts the two stay in step.
61
+ */
62
+ export declare function successStatusesOf(operation: HttpOperation): number[];
63
+ /**
64
+ * A status as the DOCUMENT keys it: an exact code, a `4XX` bucket, or the catch-all.
65
+ *
66
+ * `"default"` is kept as itself rather than mapped to a number. The document means "any status not
67
+ * listed above", and inventing one would be answering a question the contract left open.
68
+ */
69
+ export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
70
+ /**
71
+ * The body every `@error` response in the service declares, when they all declare the same one.
72
+ *
73
+ * ⚠️ **The emitter does not know what a problem document is, and must not.** This is the general
74
+ * capability — "a service may declare an error response; emit its validator so the runtime can check
75
+ * what it sends" — which is what makes it reusable by any gateway built on this emitter rather than a
76
+ * CM-shaped special case. RFC 9457 happens to be what `cm-api` puts in that slot.
77
+ *
78
+ * ⚠️ **This used to be an INVARIANT the emitter enforced, and it is now merely an observation.** It
79
+ * reported `mixed-error-bodies` — an error, aborting the compile — whenever a service declared two
80
+ * catch-all bodies, or a specific status whose body neither was nor spread the catch-all one. That
81
+ * was a real constraint while every failure arm carried this single schema; now each declared
82
+ * response resolves its own, and a service answering `404` with `NotFoundError` and everything else
83
+ * with `DefaultError` is an ordinary contract that the generated server serves correctly.
84
+ *
85
+ * So the refusal is gone, and what remains is a convenience: the module-level `GENERATED_ERROR_SCHEMA`
86
+ * the route table publishes, which exists precisely when the service has one such body. Where it does
87
+ * not, the constant is not emitted — and a consumer importing it gets a missing export, named at the
88
+ * import, which is the honest report of "there is no such thing here".
89
+ */
90
+ export declare function errorBodyOf(program: Program, service: HttpService): Type | undefined;
91
+ /**
92
+ * Whether a body served under these media types is RAW BINARY rather than a JSON value.
93
+ *
94
+ * ⚠️ **The distinction decides whether a validator may say anything about it at all.** A `bytes`
95
+ * body inside a JSON payload is base64 and the document publishes `type: "string"` with
96
+ * `contentEncoding`; a `bytes` body served as `application/octet-stream` or `image/png` is the bytes
97
+ * themselves, and `@typespec/openapi3` publishes a schema carrying only `contentMediaType` — which is
98
+ * an ANNOTATION under JSON Schema 2020-12, not an assertion. The document therefore states nothing
99
+ * about the value, and a validator that requires a string enforces a rule no caller reading the
100
+ * contract can see.
101
+ *
102
+ * ⚠️ **The same decision was already made for multipart and not for bodies**, which is how the two
103
+ * came to disagree: a binary PART is `z.unknown()` because openapi3 publishes `{}` for it, while a
104
+ * binary BODY stayed `z.string()`. Found by grading response-body kinds, a surface that had been
105
+ * counted and never compared — three positions in `encode/bytes`.
106
+ */
107
+ export declare function isRawBinaryMediaType(contentTypes: readonly string[]): boolean;
108
+ export interface EmittedRoute {
109
+ readonly operationId: string;
110
+ readonly verb: string;
111
+ readonly path: string;
112
+ readonly statusCode: number;
113
+ readonly statusCodes: readonly number[];
114
+ /** Media types the success body can be served as — the basis for `Accept` negotiation. */
115
+ readonly responseContentTypes: readonly string[];
116
+ /**
117
+ * Media types the request body may be SENT as.
118
+ *
119
+ * ⚠️ **This was missing entirely, and its absence was two live defects in the generated server.**
120
+ * The response side has carried `responseContentTypes` from the beginning; the request side carried
121
+ * nothing, so a server built on this library had no way to ask what the incoming body actually is.
122
+ * Both consequences were measured against a Petstore server under `wrangler dev`:
123
+ *
124
+ * - every request body was validated as JSON, so a `multipart/form-data` upload was handed to
125
+ * `c.req.json()` — 17 such registrations in `payload/multipart` alone, and 11 of the 17 request
126
+ * bodies in the Swagger Petstore are not JSON;
127
+ * - a `bytes` body was read with `c.req.text()` whatever it was sent as, which UTF-8-decodes. An
128
+ * 18-byte binary upload arrived at the handler as 23 code points with five bytes replaced by
129
+ * U+FFFD — silently corrupted, answering **200**.
130
+ *
131
+ * A server emitter needs the document's own answer to "what is on the wire", and this is it.
132
+ * What to DO about it — which validator target, which body reader — belongs to the emitter that
133
+ * writes the server, exactly as `responseContentTypes` leaves negotiation to it.
134
+ */
135
+ readonly requestContentTypes: readonly string[];
136
+ readonly summary: string | undefined;
137
+ readonly requestSchema: string | undefined;
138
+ /**
139
+ * The parameters, split by WHERE they arrive — see `parameterSchemasOf`.
140
+ *
141
+ * ⚠️ **A MERGED `paramsSchema` sat beside these and was deleted before 0.1.0 froze the surface.**
142
+ * It was written on every route and read by nothing — not `nameRouteSchemas`, which declares from
143
+ * the split forms, and not the one consumer. Worse, it was computed by a second function under
144
+ * different rules, and both wire fixes the split forms received had been applied only there: it
145
+ * never decoded a path or query value, so `z.number().int()` met `"1"` and refused it, and it keyed
146
+ * headers on the TypeSpec property name rather than the wire name, which 400s every conformant
147
+ * request. It read as "the parameters" and was the field an adopter reached for first.
148
+ */
149
+ readonly pathSchema: string | undefined;
150
+ readonly querySchema: string | undefined;
151
+ readonly headerSchema: string | undefined;
152
+ /** The header schema minus `accept`, used when this route's operations negotiate. */
153
+ readonly negotiatedHeaderSchema: string | undefined;
154
+ /** The `accept` literal this operation answers to, when it declares one. */
155
+ readonly accept: {
156
+ readonly name: string;
157
+ readonly value: string;
158
+ } | undefined;
159
+ /**
160
+ * `undefined` when the operation's success response carries no body — a `204`, or any `@delete`
161
+ * returning `void`.
162
+ *
163
+ * ⚠️ **This used to be required, and requiring it silently deleted routes.** `collectRoutes`
164
+ * skipped any operation `successBodyOf` could not answer for, with no diagnostic: measured across
165
+ * `@typespec/http-specs`, the document declared 540 operations and this emitter mounted 256, with
166
+ * 282 of the 284 missing ones bodyless. `payload/multipart` produced `GENERATED_ROUTES = []`
167
+ * against seventeen declared operations and compiled clean. Even `parameters/path` lost both of
168
+ * its operations, because they answer `NoContentResponse`.
169
+ *
170
+ * A bodyless success is ordinary HTTP, not an edge case. The route is emitted; a server
171
+ * answers the declared status with no body.
172
+ */
173
+ readonly responseSchema: string | undefined;
174
+ /**
175
+ * The input property that receives the UNPARSED body, when the operation declares one.
176
+ *
177
+ * A `bytes` body means the bytes themselves are the contract: where a signature covers exactly
178
+ * what arrived, parsing and re-serialising would verify a different string than the sender signed.
179
+ */
180
+ readonly rawBodyProperty: string | undefined;
181
+ /** Declared failures, each with the schema for its OWN body, in OpenAPI's precedence order. */
182
+ readonly errorArms: readonly {
183
+ readonly status: StatusKey;
184
+ readonly schema: string | undefined;
185
+ }[];
186
+ /**
187
+ * No caller is established, so the operation receives no `ServiceContext`.
188
+ *
189
+ * Whether a tenant exists follows from whether the request was authenticated — there is no user,
190
+ * account or membership to build one from otherwise. Reading it here is what lets a server
191
+ * derive an operation's call shape rather than a hand-written table restating it per row.
192
+ */
193
+ readonly noAuth: boolean;
194
+ /**
195
+ * The OAuth scopes the operation requires, from `@useAuth`.
196
+ *
197
+ * ⚠️ **Per operation, never per surface.** A resource-wide list means either one scope for
198
+ * everything (a read-only grant could write) or the union of them (a read-only grant could not
199
+ * read), and neither is a grant a human would recognise from the consent screen. Emitted into the
200
+ * row so a server applies the gate the DOCUMENT publishes, rather than a hand-written
201
+ * `requireScopes(...)` repeated per route that nothing checks against it.
202
+ */
203
+ readonly scopes: readonly string[];
204
+ /** Which of two success statuses to answer, and the literal property that decides it. */
205
+ readonly statusBy: {
206
+ property: string;
207
+ value: boolean | string;
208
+ status: number;
209
+ } | undefined;
210
+ /** The schema for the SECOND success status, when the operation declares two distinct arms. */
211
+ readonly alternateResponseSchema: string | undefined;
212
+ }
213
+ export declare function collectRoutes(program: Program, registry: SchemaRegistry, service: HttpService): EmittedRoute[];
214
+ /**
215
+ * The identifiers a route's validators are declared under, by what the DOCUMENT calls each one.
216
+ *
217
+ * ⚠️ **Keyed on request locations, not on a router's validator targets.** `@hono/zod-validator`
218
+ * spells these `"param"`, `"query"`, `"header"`, `"json"`; another framework spells them differently
219
+ * or not at all. The location is the contract fact and belongs here; the spelling is the server
220
+ * emitter's, and translating one to the other is a two-line map in the package that knows.
221
+ */
222
+ export interface RouteSchemaNames {
223
+ readonly operationId: string;
224
+ readonly path: string | undefined;
225
+ readonly query: string | undefined;
226
+ readonly header: string | undefined;
227
+ readonly body: string | undefined;
228
+ readonly response: string | undefined;
229
+ readonly alternateResponse: string | undefined;
230
+ /**
231
+ * The `readonly ResponseArm[]` const — what the operation may answer with, and with which body.
232
+ *
233
+ * Always present, because every operation declares at least its success status. An operation whose
234
+ * every arm is bodyless still gets the const, carrying `schema: undefined` per arm, which is the
235
+ * document's own answer rather than an absence a consumer has to interpret.
236
+ */
237
+ readonly responses: string;
238
+ }
239
+ /**
240
+ * One `@service`, emitted — and everything a server emitter needs to render a router over it.
241
+ *
242
+ * ⚠️ **This is the shared seam, and it is deliberately narrow.** It carries no `SchemaRegistry`, no
243
+ * import collector, no error schema and no vocabulary map, because measurement said a server
244
+ * generator reads none of them: the registry is never touched once the names are minted, the external
245
+ * imports serve the very declarations that now live in `schemas.gen.ts`, the error schema fed a route
246
+ * table that no longer exists, and the vocabularies feed this library's own renderer. The smallest
247
+ * surface that works is the one to freeze; anything else is added when something demonstrates a need
248
+ * for it, with the reason recorded.
249
+ */
250
+ export interface EmittedService {
251
+ readonly service: HttpService;
252
+ readonly routes: readonly EmittedRoute[];
253
+ /** The identifiers every per-operation validator was declared under, keyed by operation id. */
254
+ readonly schemaNames: ReadonlyMap<string, RouteSchemaNames>;
255
+ /** Where this service's artefacts were written — a server emitter writes its own beside them. */
256
+ readonly outputDir: string;
257
+ /** Already resolved through the per-service overrides, so a caller re-reads nothing. */
258
+ readonly options: ResolvedServiceOptions;
259
+ }
260
+ /** The options for one service, with every per-service override already applied. */
261
+ export interface ResolvedServiceOptions {
262
+ readonly contractsOutputDir: string | undefined;
263
+ readonly contractsPackage: string | undefined;
264
+ readonly sealObjectSchemas: boolean;
265
+ readonly keyVocabularies: readonly string[];
266
+ readonly runtimeModule: string;
267
+ }
268
+ /** What the generated files import their runtime contract from when nothing says otherwise. */
269
+ export declare const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
270
+ /**
271
+ * What a WRAPPING emitter may change about this one, without changing what a consumer may set.
272
+ *
273
+ * ⚠️ **`defaultRuntimeModule` is a default, not an override.** A consumer's `runtime-module` still
274
+ * wins; this only decides what `schemas.gen.ts` and any file a wrapper emits beside it import from
275
+ * when the consumer says nothing.
276
+ *
277
+ * ⚠️ **It exists because this package's own default is wrong for a wrapper, and shipped that way.**
278
+ * `typespec-hono` emits `import type { AppEnv, Awaitable, Ctx, Result, RouteDeps }` and
279
+ * `import { selectContentType }` from whatever this resolves to. None of those six is exported here —
280
+ * this module exports `ResponseArm` and `armFor`, and nothing else. Worse, `typespec-http-zod` is a
281
+ * TRANSITIVE dependency of a `typespec-hono` consumer, so under a strict `node_modules` the specifier
282
+ * does not resolve at all whatever it exports. Measured in a fresh project installed from tarballs:
283
+ * `tsp compile` succeeded with zero diagnostics and `tsc` then reported **two `TS2307`s**, one in each
284
+ * generated file.
285
+ *
286
+ * Nothing in either suite saw it, because every compile in both harnesses sets `runtime-module`
287
+ * explicitly. The default branch was ungraded across 240 tests.
288
+ */
289
+ export interface EmitHttpZodOptions {
290
+ /** The specifier generated files import their runtime contract from when the consumer sets none. */
291
+ readonly defaultRuntimeModule?: string;
292
+ }
293
+ /**
294
+ * Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
295
+ *
296
+ * ⚠️ **The whole orchestration lives here, once.** The nesting below is load-bearing — a walk that
297
+ * runs outside `collectExternalImports` throws, vocabularies must be collected from what the service
298
+ * DECLARES rather than from what the Zod walk happens to touch, and the error body has to be
299
+ * registered inside the walk or its validator never reaches `schemas.gen.ts`. An emitter built on this
300
+ * library calls this rather than restating it: two copies of a sequence that must agree are two
301
+ * copies that will not, and the symptom would be a subtly different set of declarations in a file
302
+ * whose name is the contract.
303
+ */
304
+ export declare function emitHttpZod(context: EmitContext, wrapper?: EmitHttpZodOptions): Promise<readonly EmittedService[]>;