typespec-http-zod 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -215
- package/dist/src/api.d.ts +40 -40
- package/dist/src/api.js +202 -167
- package/dist/src/constraints.d.ts +7 -7
- package/dist/src/constraints.js +8 -8
- package/dist/src/emitter.d.ts +1 -1
- package/dist/src/emitter.js +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/lib.d.ts +16 -34
- package/dist/src/lib.js +36 -44
- package/dist/src/registry.d.ts +59 -8
- package/dist/src/registry.js +77 -72
- package/dist/src/runtime.d.ts +16 -16
- package/dist/src/runtime.js +3 -3
- package/dist/src/streams.d.ts +10 -10
- package/dist/src/streams.js +3 -3
- package/dist/src/tsp-index.d.ts +2 -2
- package/dist/src/tsp-index.js +2 -2
- package/dist/src/types.d.ts +12 -3
- package/dist/src/types.js +67 -33
- package/dist/src/versioning.d.ts +6 -6
- package/dist/src/versioning.js +3 -3
- package/dist/src/zod.d.ts +22 -22
- package/dist/src/zod.js +201 -147
- package/lib/main.tsp +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
1
|
# typespec-http-zod
|
|
2
2
|
|
|
3
|
-
Zod validators and framework-free
|
|
4
|
-
keyword, with the OpenAPI document [`@typespec/openapi3`](https://typespec.io) publishes from the same
|
|
5
|
-
source.
|
|
3
|
+
Generate Zod validators and framework-free TypeScript types from a TypeSpec HTTP service.
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
The emitted validators agree, keyword for keyword, with the OpenAPI document
|
|
6
|
+
[`@typespec/openapi3`](https://typespec.io) publishes from the same source. Use them with Express,
|
|
7
|
+
Fastify, Elysia, a Workers fetch handler, a typed client, or a test suite. No server framework is
|
|
8
|
+
required.
|
|
11
9
|
|
|
12
|
-
|
|
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.
|
|
10
|
+
It is also a library. [`typespec-hono`](https://github.com/bison-digital/typespec-hono) is an emitter
|
|
11
|
+
built on its API.
|
|
22
12
|
|
|
23
13
|
## Install
|
|
24
14
|
|
|
@@ -26,28 +16,58 @@ neither, or a differential between them means nothing.
|
|
|
26
16
|
npm install typespec-http-zod
|
|
27
17
|
```
|
|
28
18
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
19
|
+
Install it as a regular dependency if your application calls `armFor`. If you use only the emitted
|
|
20
|
+
validators, a dev dependency is enough. See [installing](docs/guides.md#installing).
|
|
21
|
+
|
|
22
|
+
Peer dependencies: `@typespec/compiler`, `@typespec/http`, `@typespec/openapi`, and `zod`.
|
|
23
|
+
`@typespec/versioning` and `@typespec/streams` are optional.
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
35
26
|
|
|
36
27
|
```
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
main.tsp your API definition
|
|
29
|
+
tspconfig.yaml which emitters to run
|
|
30
|
+
src/
|
|
31
|
+
generated/ written by `tsp compile`, never edited by hand
|
|
32
|
+
schemas.gen.ts
|
|
33
|
+
index.ts your code
|
|
39
34
|
```
|
|
40
35
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
### `main.tsp`
|
|
37
|
+
|
|
38
|
+
```tsp
|
|
39
|
+
import "@typespec/http";
|
|
40
|
+
|
|
41
|
+
using Http;
|
|
42
|
+
|
|
43
|
+
@service(#{ title: "Widget API" })
|
|
44
|
+
namespace WidgetApi;
|
|
45
|
+
|
|
46
|
+
model Widget {
|
|
47
|
+
id: string;
|
|
48
|
+
|
|
49
|
+
@minLength(1)
|
|
50
|
+
name: string;
|
|
51
|
+
|
|
52
|
+
@minValue(0)
|
|
53
|
+
quantity: int32;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@error
|
|
57
|
+
model NotFound {
|
|
58
|
+
@statusCode statusCode: 404;
|
|
59
|
+
code: string;
|
|
60
|
+
message: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@route("/widgets/{widgetId}")
|
|
64
|
+
@get
|
|
65
|
+
op readWidget(@path widgetId: int32): Widget | NotFound;
|
|
66
|
+
```
|
|
44
67
|
|
|
45
|
-
|
|
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.
|
|
68
|
+
### `tspconfig.yaml`
|
|
48
69
|
|
|
49
70
|
```yaml
|
|
50
|
-
# tspconfig.yaml
|
|
51
71
|
emit:
|
|
52
72
|
- typespec-http-zod
|
|
53
73
|
options:
|
|
@@ -55,70 +75,41 @@ options:
|
|
|
55
75
|
emitter-output-dir: "{project-root}/src/generated"
|
|
56
76
|
```
|
|
57
77
|
|
|
58
|
-
|
|
78
|
+
Then run `tsp compile .`.
|
|
59
79
|
|
|
60
|
-
|
|
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:
|
|
80
|
+
### `src/generated/schemas.gen.ts` (generated)
|
|
64
81
|
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
```ts
|
|
83
|
+
export const widgetSchema = z.object({
|
|
84
|
+
id: z.string(),
|
|
85
|
+
name: z.string().min(1),
|
|
86
|
+
quantity: z.number().int().min(0),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const notFoundSchema = z.object({
|
|
90
|
+
code: z.string(),
|
|
91
|
+
message: z.string(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const readWidgetPath = z.object({
|
|
95
|
+
widgetId: z.preprocess(decodeNumber, z.number().int()),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export const readWidgetResponses = [
|
|
99
|
+
{ status: 200, schema: widgetSchema },
|
|
100
|
+
{ status: 404, schema: notFoundSchema },
|
|
101
|
+
] satisfies readonly ResponseArm[];
|
|
73
102
|
```
|
|
74
103
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
104
|
+
Path and query values arrive as strings. The emitted validator decodes `"1"` to `1` before the
|
|
105
|
+
schema runs, so hand the raw values straight in.
|
|
106
|
+
|
|
107
|
+
### `src/index.ts`
|
|
116
108
|
|
|
117
109
|
```ts
|
|
118
110
|
import { armFor, type ResponseArm } from "typespec-http-zod/runtime";
|
|
119
|
-
import {
|
|
111
|
+
import { readWidgetPath, readWidgetResponses } from "./generated/schemas.gen.js";
|
|
120
112
|
|
|
121
|
-
/** Answer a status with a body the document says that status carries. */
|
|
122
113
|
function respond(arms: readonly ResponseArm[], status: number, body: unknown) {
|
|
123
114
|
const arm = armFor(arms, status);
|
|
124
115
|
if (arm === undefined) throw new Error(`no declared arm for ${status}`);
|
|
@@ -126,143 +117,35 @@ function respond(arms: readonly ResponseArm[], status: number, body: unknown) {
|
|
|
126
117
|
}
|
|
127
118
|
|
|
128
119
|
export function readWidget(params: Record<string, unknown>, widget: unknown | undefined) {
|
|
129
|
-
const parsed =
|
|
120
|
+
const parsed = readWidgetPath.safeParse(params);
|
|
130
121
|
if (!parsed.success) return { status: 400, body: parsed.error.issues };
|
|
122
|
+
|
|
131
123
|
return widget === undefined
|
|
132
|
-
? respond(
|
|
133
|
-
: respond(
|
|
124
|
+
? respond(readWidgetResponses, 404, { code: "not_found", message: "no such widget" })
|
|
125
|
+
: respond(readWidgetResponses, 200, widget);
|
|
134
126
|
}
|
|
135
127
|
```
|
|
136
128
|
|
|
137
|
-
|
|
138
|
-
|
|
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:
|
|
129
|
+
`armFor` resolves which arm governs a status, applying OpenAPI's precedence: an exact code first,
|
|
130
|
+
then a range such as `4XX`, then `default`.
|
|
142
131
|
|
|
143
|
-
|
|
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.
|
|
132
|
+
## What it emits
|
|
155
133
|
|
|
156
|
-
|
|
134
|
+
| file | contents | emitted when |
|
|
135
|
+
| ---------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------- |
|
|
136
|
+
| `schemas.gen.ts` | component schemas, per-operation path, query, header, body and response validators, and the response arms | always |
|
|
137
|
+
| `requests.gen.ts` | framework-free TypeScript types, with no imports | `contracts-output-dir` is set |
|
|
138
|
+
| `vocabularies.gen.ts` | each enum's members as a runtime tuple | `contracts-output-dir` is set |
|
|
139
|
+
| `wire-contract.gen.ts` | assertions pairing the emitted Zod against those types | that and `contracts-package` |
|
|
157
140
|
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
141
|
+
Identifiers are named from the operation id the document publishes. An operation inside a namespace
|
|
142
|
+
carries the whole id, so `op readWidget` in `namespace Widgets` becomes `Widgets_readWidgetPath`.
|
|
162
143
|
|
|
163
|
-
|
|
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
|
-
```
|
|
144
|
+
## Docs
|
|
172
145
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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.
|
|
146
|
+
- [Guides](docs/guides.md): installing, validating a request, answering with the right body, content
|
|
147
|
+
types, and building an emitter on the API.
|
|
148
|
+
- [Reference](docs/reference.md): every option, every diagnostic, and the known limits.
|
|
266
149
|
|
|
267
150
|
## Licence
|
|
268
151
|
|
package/dist/src/api.d.ts
CHANGED
|
@@ -2,13 +2,13 @@ import { type EmitContext, type Program, type Type } from "@typespec/compiler";
|
|
|
2
2
|
import { type HttpOperation, type HttpService } from "@typespec/http";
|
|
3
3
|
import { SchemaRegistry } from "./registry.js";
|
|
4
4
|
/**
|
|
5
|
-
* `typespec-http-zod`
|
|
5
|
+
* `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
|
|
6
6
|
* service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
|
|
7
7
|
*
|
|
8
8
|
* **The governing rule: if the document cannot say it, the validator must not enforce it.** A
|
|
9
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
|
|
11
|
-
* deleted, each of which let a spec state something openapi3 could not publish
|
|
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
12
|
* class is empty rather than trusting it. Where openapi3 decides something, its rule is read from its
|
|
13
13
|
* source and copied, refusals included: the same spec has to be representable by both emitters or by
|
|
14
14
|
* neither, or a differential between them means nothing.
|
|
@@ -17,9 +17,9 @@ import { SchemaRegistry } from "./registry.js";
|
|
|
17
17
|
* self-describes as "highly experimental", and emits a Node.js `http` router. It also answers a
|
|
18
18
|
* different question: this package emits no server at all. Validators are useful to Express, Fastify,
|
|
19
19
|
* Elysia, a Workers fetch handler or a typed client, and a server generator is a thin consumer of
|
|
20
|
-
* them
|
|
20
|
+
* them - `typespec-hono` is one.
|
|
21
21
|
*
|
|
22
|
-
* **Built on the stable 1.x surface only**
|
|
22
|
+
* **Built on the stable 1.x surface only** - `$onEmit` plus `@typespec/http` - rather than on
|
|
23
23
|
* `@typespec/emitter-framework` (pre-1.0) or `@typespec/asset-emitter`, which announces its own
|
|
24
24
|
* replacement in its package description. Fewer moving parts under something that has to keep working.
|
|
25
25
|
*/
|
|
@@ -28,15 +28,15 @@ export { $lib } from "./lib.js";
|
|
|
28
28
|
* The option contract, published so an emitter built on this library can **derive** its own rather
|
|
29
29
|
* than restate it.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
31
|
+
* **Two schemas that have to agree are two lists that will not.** A server generator adds its own
|
|
32
32
|
* keys and forwards the rest; if it restates this one, an option added here is silently rejected as
|
|
33
33
|
* unknown by the wrapper, or accepted and never passed on. Neither failure is visible in output.
|
|
34
34
|
*/
|
|
35
35
|
export { EmitterOptionsSchema, type EmitterOptions } from "./lib.js";
|
|
36
36
|
/**
|
|
37
|
-
* How a property name is spelled as an object key
|
|
37
|
+
* How a property name is spelled as an object key - quoted only when it has to be.
|
|
38
38
|
*
|
|
39
|
-
*
|
|
39
|
+
* **Published because a consumer emitting code beside these validators has to spell keys the
|
|
40
40
|
* SAME way, and getting it wrong produces a file that does not parse.** An unquoted
|
|
41
41
|
* `x-ms-test-header` is exactly that, and it happened: a corpus scenario declares one and the
|
|
42
42
|
* emitted module stopped being loadable. This is not a style choice a second emitter may make
|
|
@@ -47,7 +47,7 @@ export { objectKey } from "./zod.js";
|
|
|
47
47
|
* The success status a row registers.
|
|
48
48
|
*
|
|
49
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
|
|
50
|
+
* handler can return, so it is not something this emitter can turn into a `respond()` call - an
|
|
51
51
|
* operation carrying only those is skipped rather than guessed at.
|
|
52
52
|
*/
|
|
53
53
|
export declare function successStatusOf(operation: HttpOperation): number | undefined;
|
|
@@ -70,13 +70,13 @@ export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
|
|
|
70
70
|
/**
|
|
71
71
|
* The body every `@error` response in the service declares, when they all declare the same one.
|
|
72
72
|
*
|
|
73
|
-
*
|
|
74
|
-
* capability
|
|
75
|
-
* what it sends"
|
|
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
76
|
* CM-shaped special case. RFC 9457 happens to be what `cm-api` puts in that slot.
|
|
77
77
|
*
|
|
78
|
-
*
|
|
79
|
-
* reported `mixed-error-bodies`
|
|
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
80
|
* catch-all bodies, or a specific status whose body neither was nor spread the catch-all one. That
|
|
81
81
|
* was a real constraint while every failure arm carried this single schema; now each declared
|
|
82
82
|
* response resolves its own, and a service answering `404` with `NotFoundError` and everything else
|
|
@@ -84,25 +84,25 @@ export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
|
|
|
84
84
|
*
|
|
85
85
|
* So the refusal is gone, and what remains is a convenience: the module-level `GENERATED_ERROR_SCHEMA`
|
|
86
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
|
|
87
|
+
* not, the constant is not emitted - and a consumer importing it gets a missing export, named at the
|
|
88
88
|
* import, which is the honest report of "there is no such thing here".
|
|
89
89
|
*/
|
|
90
90
|
export declare function errorBodyOf(program: Program, service: HttpService): Type | undefined;
|
|
91
91
|
/**
|
|
92
92
|
* Whether a body served under these media types is RAW BINARY rather than a JSON value.
|
|
93
93
|
*
|
|
94
|
-
*
|
|
94
|
+
* **The distinction decides whether a validator may say anything about it at all.** A `bytes`
|
|
95
95
|
* body inside a JSON payload is base64 and the document publishes `type: "string"` with
|
|
96
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`
|
|
97
|
+
* themselves, and `@typespec/openapi3` publishes a schema carrying only `contentMediaType` - which is
|
|
98
98
|
* an ANNOTATION under JSON Schema 2020-12, not an assertion. The document therefore states nothing
|
|
99
99
|
* about the value, and a validator that requires a string enforces a rule no caller reading the
|
|
100
100
|
* contract can see.
|
|
101
101
|
*
|
|
102
|
-
*
|
|
102
|
+
* **The same decision was already made for multipart and not for bodies**, which is how the two
|
|
103
103
|
* came to disagree: a binary PART is `z.unknown()` because openapi3 publishes `{}` for it, while a
|
|
104
104
|
* binary BODY stayed `z.string()`. Found by grading response-body kinds, a surface that had been
|
|
105
|
-
* counted and never compared
|
|
105
|
+
* counted and never compared - three positions in `encode/bytes`.
|
|
106
106
|
*/
|
|
107
107
|
export declare function isRawBinaryMediaType(contentTypes: readonly string[]): boolean;
|
|
108
108
|
export interface EmittedRoute {
|
|
@@ -111,35 +111,35 @@ export interface EmittedRoute {
|
|
|
111
111
|
readonly path: string;
|
|
112
112
|
readonly statusCode: number;
|
|
113
113
|
readonly statusCodes: readonly number[];
|
|
114
|
-
/** Media types the success body can be served as
|
|
114
|
+
/** Media types the success body can be served as - the basis for `Accept` negotiation. */
|
|
115
115
|
readonly responseContentTypes: readonly string[];
|
|
116
116
|
/**
|
|
117
117
|
* Media types the request body may be SENT as.
|
|
118
118
|
*
|
|
119
|
-
*
|
|
119
|
+
* **This was missing entirely, and its absence was two live defects in the generated server.**
|
|
120
120
|
* The response side has carried `responseContentTypes` from the beginning; the request side carried
|
|
121
121
|
* nothing, so a server built on this library had no way to ask what the incoming body actually is.
|
|
122
122
|
* Both consequences were measured against a Petstore server under `wrangler dev`:
|
|
123
123
|
*
|
|
124
124
|
* - every request body was validated as JSON, so a `multipart/form-data` upload was handed to
|
|
125
|
-
* `c.req.json()`
|
|
125
|
+
* `c.req.json()` - 17 such registrations in `payload/multipart` alone, and 11 of the 17 request
|
|
126
126
|
* bodies in the Swagger Petstore are not JSON;
|
|
127
127
|
* - a `bytes` body was read with `c.req.text()` whatever it was sent as, which UTF-8-decodes. An
|
|
128
128
|
* 18-byte binary upload arrived at the handler as 23 code points with five bytes replaced by
|
|
129
|
-
* U+FFFD
|
|
129
|
+
* U+FFFD - silently corrupted, answering **200**.
|
|
130
130
|
*
|
|
131
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
|
|
132
|
+
* What to DO about it - which validator target, which body reader - belongs to the emitter that
|
|
133
133
|
* writes the server, exactly as `responseContentTypes` leaves negotiation to it.
|
|
134
134
|
*/
|
|
135
135
|
readonly requestContentTypes: readonly string[];
|
|
136
136
|
readonly summary: string | undefined;
|
|
137
137
|
readonly requestSchema: string | undefined;
|
|
138
138
|
/**
|
|
139
|
-
* The parameters, split by WHERE they arrive
|
|
139
|
+
* The parameters, split by WHERE they arrive - see `parameterSchemasOf`.
|
|
140
140
|
*
|
|
141
|
-
*
|
|
142
|
-
* It was written on every route and read by nothing
|
|
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
143
|
* the split forms, and not the one consumer. Worse, it was computed by a second function under
|
|
144
144
|
* different rules, and both wire fixes the split forms received had been applied only there: it
|
|
145
145
|
* never decoded a path or query value, so `z.number().int()` met `"1"` and refused it, and it keyed
|
|
@@ -157,10 +157,10 @@ export interface EmittedRoute {
|
|
|
157
157
|
readonly value: string;
|
|
158
158
|
} | undefined;
|
|
159
159
|
/**
|
|
160
|
-
* `undefined` when the operation's success response carries no body
|
|
160
|
+
* `undefined` when the operation's success response carries no body - a `204`, or any `@delete`
|
|
161
161
|
* returning `void`.
|
|
162
162
|
*
|
|
163
|
-
*
|
|
163
|
+
* **This used to be required, and requiring it silently deleted routes.** `collectRoutes`
|
|
164
164
|
* skipped any operation `successBodyOf` could not answer for, with no diagnostic: measured across
|
|
165
165
|
* `@typespec/http-specs`, the document declared 540 operations and this emitter mounted 256, with
|
|
166
166
|
* 282 of the 284 missing ones bodyless. `payload/multipart` produced `GENERATED_ROUTES = []`
|
|
@@ -186,7 +186,7 @@ export interface EmittedRoute {
|
|
|
186
186
|
/**
|
|
187
187
|
* No caller is established, so the operation receives no `ServiceContext`.
|
|
188
188
|
*
|
|
189
|
-
* Whether a tenant exists follows from whether the request was authenticated
|
|
189
|
+
* Whether a tenant exists follows from whether the request was authenticated - there is no user,
|
|
190
190
|
* account or membership to build one from otherwise. Reading it here is what lets a server
|
|
191
191
|
* derive an operation's call shape rather than a hand-written table restating it per row.
|
|
192
192
|
*/
|
|
@@ -194,7 +194,7 @@ export interface EmittedRoute {
|
|
|
194
194
|
/**
|
|
195
195
|
* The OAuth scopes the operation requires, from `@useAuth`.
|
|
196
196
|
*
|
|
197
|
-
*
|
|
197
|
+
* **Per operation, never per surface.** A resource-wide list means either one scope for
|
|
198
198
|
* everything (a read-only grant could write) or the union of them (a read-only grant could not
|
|
199
199
|
* read), and neither is a grant a human would recognise from the consent screen. Emitted into the
|
|
200
200
|
* row so a server applies the gate the DOCUMENT publishes, rather than a hand-written
|
|
@@ -214,7 +214,7 @@ export declare function collectRoutes(program: Program, registry: SchemaRegistry
|
|
|
214
214
|
/**
|
|
215
215
|
* The identifiers a route's validators are declared under, by what the DOCUMENT calls each one.
|
|
216
216
|
*
|
|
217
|
-
*
|
|
217
|
+
* **Keyed on request locations, not on a router's validator targets.** `@hono/zod-validator`
|
|
218
218
|
* spells these `"param"`, `"query"`, `"header"`, `"json"`; another framework spells them differently
|
|
219
219
|
* or not at all. The location is the contract fact and belongs here; the spelling is the server
|
|
220
220
|
* emitter's, and translating one to the other is a two-line map in the package that knows.
|
|
@@ -228,7 +228,7 @@ export interface RouteSchemaNames {
|
|
|
228
228
|
readonly response: string | undefined;
|
|
229
229
|
readonly alternateResponse: string | undefined;
|
|
230
230
|
/**
|
|
231
|
-
* The `readonly ResponseArm[]` const
|
|
231
|
+
* The `readonly ResponseArm[]` const - what the operation may answer with, and with which body.
|
|
232
232
|
*
|
|
233
233
|
* Always present, because every operation declares at least its success status. An operation whose
|
|
234
234
|
* every arm is bodyless still gets the const, carrying `schema: undefined` per arm, which is the
|
|
@@ -237,9 +237,9 @@ export interface RouteSchemaNames {
|
|
|
237
237
|
readonly responses: string;
|
|
238
238
|
}
|
|
239
239
|
/**
|
|
240
|
-
* One `@service`, emitted
|
|
240
|
+
* One `@service`, emitted - and everything a server emitter needs to render a router over it.
|
|
241
241
|
*
|
|
242
|
-
*
|
|
242
|
+
* **This is the shared seam, and it is deliberately narrow.** It carries no `SchemaRegistry`, no
|
|
243
243
|
* import collector, no error schema and no vocabulary map, because measurement said a server
|
|
244
244
|
* generator reads none of them: the registry is never touched once the names are minted, the external
|
|
245
245
|
* imports serve the very declarations that now live in `schemas.gen.ts`, the error schema fed a route
|
|
@@ -252,7 +252,7 @@ export interface EmittedService {
|
|
|
252
252
|
readonly routes: readonly EmittedRoute[];
|
|
253
253
|
/** The identifiers every per-operation validator was declared under, keyed by operation id. */
|
|
254
254
|
readonly schemaNames: ReadonlyMap<string, RouteSchemaNames>;
|
|
255
|
-
/** Where this service's artefacts were written
|
|
255
|
+
/** Where this service's artefacts were written - a server emitter writes its own beside them. */
|
|
256
256
|
readonly outputDir: string;
|
|
257
257
|
/** Already resolved through the per-service overrides, so a caller re-reads nothing. */
|
|
258
258
|
readonly options: ResolvedServiceOptions;
|
|
@@ -270,13 +270,13 @@ export declare const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
|
|
|
270
270
|
/**
|
|
271
271
|
* What a WRAPPING emitter may change about this one, without changing what a consumer may set.
|
|
272
272
|
*
|
|
273
|
-
*
|
|
273
|
+
* **`defaultRuntimeModule` is a default, not an override.** A consumer's `runtime-module` still
|
|
274
274
|
* wins; this only decides what `schemas.gen.ts` and any file a wrapper emits beside it import from
|
|
275
275
|
* when the consumer says nothing.
|
|
276
276
|
*
|
|
277
|
-
*
|
|
277
|
+
* **It exists because this package's own default is wrong for a wrapper, and shipped that way.**
|
|
278
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
|
|
279
|
+
* `import { selectContentType }` from whatever this resolves to. None of those six is exported here -
|
|
280
280
|
* this module exports `ResponseArm` and `armFor`, and nothing else. Worse, `typespec-http-zod` is a
|
|
281
281
|
* TRANSITIVE dependency of a `typespec-hono` consumer, so under a strict `node_modules` the specifier
|
|
282
282
|
* does not resolve at all whatever it exports. Measured in a fresh project installed from tarballs:
|
|
@@ -293,7 +293,7 @@ export interface EmitHttpZodOptions {
|
|
|
293
293
|
/**
|
|
294
294
|
* Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
|
|
295
295
|
*
|
|
296
|
-
*
|
|
296
|
+
* **The whole orchestration lives here, once.** The nesting below is load-bearing - a walk that
|
|
297
297
|
* runs outside `collectExternalImports` throws, vocabularies must be collected from what the service
|
|
298
298
|
* DECLARES rather than from what the Zod walk happens to touch, and the error body has to be
|
|
299
299
|
* registered inside the walk or its validator never reaches `schemas.gen.ts`. An emitter built on this
|