typespec-http-zod 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +269 -0
- package/dist/src/api.d.ts +304 -0
- package/dist/src/api.js +1425 -0
- package/dist/src/constraints.d.ts +38 -0
- package/dist/src/constraints.js +68 -0
- package/dist/src/emitter.d.ts +13 -0
- package/dist/src/emitter.js +15 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/lib.d.ts +181 -0
- package/dist/src/lib.js +172 -0
- package/dist/src/registry.d.ts +69 -0
- package/dist/src/registry.js +421 -0
- package/dist/src/runtime.d.ts +84 -0
- package/dist/src/runtime.js +25 -0
- package/dist/src/streams.d.ts +40 -0
- package/dist/src/streams.js +40 -0
- package/dist/src/tsp-index.d.ts +12 -0
- package/dist/src/tsp-index.js +12 -0
- package/dist/src/types.d.ts +10 -0
- package/dist/src/types.js +254 -0
- package/dist/src/versioning.d.ts +58 -0
- package/dist/src/versioning.js +57 -0
- package/dist/src/zod.d.ts +124 -0
- package/dist/src/zod.js +1015 -0
- package/lib/main.tsp +4 -0
- package/package.json +94 -0
package/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[]>;
|