typespec-http-zod 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -215
- package/dist/src/api.d.ts +40 -40
- package/dist/src/api.js +202 -167
- package/dist/src/constraints.d.ts +7 -7
- package/dist/src/constraints.js +8 -8
- package/dist/src/emitter.d.ts +1 -1
- package/dist/src/emitter.js +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/lib.d.ts +16 -34
- package/dist/src/lib.js +36 -44
- package/dist/src/registry.d.ts +59 -8
- package/dist/src/registry.js +77 -72
- package/dist/src/runtime.d.ts +16 -16
- package/dist/src/runtime.js +3 -3
- package/dist/src/streams.d.ts +10 -10
- package/dist/src/streams.js +3 -3
- package/dist/src/tsp-index.d.ts +2 -2
- package/dist/src/tsp-index.js +2 -2
- package/dist/src/types.d.ts +12 -3
- package/dist/src/types.js +67 -33
- package/dist/src/versioning.d.ts +6 -6
- package/dist/src/versioning.js +3 -3
- package/dist/src/zod.d.ts +22 -22
- package/dist/src/zod.js +201 -147
- package/lib/main.tsp +1 -1
- package/package.json +1 -1
package/dist/src/api.js
CHANGED
|
@@ -7,13 +7,13 @@ import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams
|
|
|
7
7
|
import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
|
|
8
8
|
import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
|
|
9
9
|
/**
|
|
10
|
-
* `typespec-http-zod`
|
|
10
|
+
* `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
|
|
11
11
|
* service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
|
|
12
12
|
*
|
|
13
13
|
* **The governing rule: if the document cannot say it, the validator must not enforce it.** A
|
|
14
14
|
* validator that checks something the published contract never states is a rule no caller reading
|
|
15
|
-
* that contract can see. This package ships **zero decorators** for exactly that reason
|
|
16
|
-
* deleted, each of which let a spec state something openapi3 could not publish
|
|
15
|
+
* that contract can see. This package ships **zero decorators** for exactly that reason - four were
|
|
16
|
+
* deleted, each of which let a spec state something openapi3 could not publish - and asserts the
|
|
17
17
|
* class is empty rather than trusting it. Where openapi3 decides something, its rule is read from its
|
|
18
18
|
* source and copied, refusals included: the same spec has to be representable by both emitters or by
|
|
19
19
|
* neither, or a differential between them means nothing.
|
|
@@ -22,9 +22,9 @@ import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies,
|
|
|
22
22
|
* self-describes as "highly experimental", and emits a Node.js `http` router. It also answers a
|
|
23
23
|
* different question: this package emits no server at all. Validators are useful to Express, Fastify,
|
|
24
24
|
* Elysia, a Workers fetch handler or a typed client, and a server generator is a thin consumer of
|
|
25
|
-
* them
|
|
25
|
+
* them - `typespec-hono` is one.
|
|
26
26
|
*
|
|
27
|
-
* **Built on the stable 1.x surface only**
|
|
27
|
+
* **Built on the stable 1.x surface only** - `$onEmit` plus `@typespec/http` - rather than on
|
|
28
28
|
* `@typespec/emitter-framework` (pre-1.0) or `@typespec/asset-emitter`, which announces its own
|
|
29
29
|
* replacement in its package description. Fewer moving parts under something that has to keep working.
|
|
30
30
|
*/
|
|
@@ -33,15 +33,15 @@ export { $lib } from "./lib.js";
|
|
|
33
33
|
* The option contract, published so an emitter built on this library can **derive** its own rather
|
|
34
34
|
* than restate it.
|
|
35
35
|
*
|
|
36
|
-
*
|
|
36
|
+
* **Two schemas that have to agree are two lists that will not.** A server generator adds its own
|
|
37
37
|
* keys and forwards the rest; if it restates this one, an option added here is silently rejected as
|
|
38
38
|
* unknown by the wrapper, or accepted and never passed on. Neither failure is visible in output.
|
|
39
39
|
*/
|
|
40
40
|
export { EmitterOptionsSchema } from "./lib.js";
|
|
41
41
|
/**
|
|
42
|
-
* How a property name is spelled as an object key
|
|
42
|
+
* How a property name is spelled as an object key - quoted only when it has to be.
|
|
43
43
|
*
|
|
44
|
-
*
|
|
44
|
+
* **Published because a consumer emitting code beside these validators has to spell keys the
|
|
45
45
|
* SAME way, and getting it wrong produces a file that does not parse.** An unquoted
|
|
46
46
|
* `x-ms-test-header` is exactly that, and it happened: a corpus scenario declares one and the
|
|
47
47
|
* emitted module stopped being loadable. This is not a style choice a second emitter may make
|
|
@@ -53,7 +53,7 @@ const PACKAGE_NAME = "typespec-http-zod";
|
|
|
53
53
|
/**
|
|
54
54
|
* The header every emitted file carries.
|
|
55
55
|
*
|
|
56
|
-
*
|
|
56
|
+
* **This once named one repository's build script** - `pnpm --filter ... generate`, a command that
|
|
57
57
|
* exists in exactly one checkout. A generated file is the most-read artefact an emitter produces, and
|
|
58
58
|
* a banner sending the reader to a script they do not have is worse than no instruction at all.
|
|
59
59
|
*
|
|
@@ -67,7 +67,7 @@ const GENERATED_BANNER = `// GENERATED by ${PACKAGE_NAME} from the TypeSpec serv
|
|
|
67
67
|
* The success status a row registers.
|
|
68
68
|
*
|
|
69
69
|
* An operation declares exactly one success status. A range (`2XX`) or a wildcard is not a status a
|
|
70
|
-
* handler can return, so it is not something this emitter can turn into a `respond()` call
|
|
70
|
+
* handler can return, so it is not something this emitter can turn into a `respond()` call - an
|
|
71
71
|
* operation carrying only those is skipped rather than guessed at.
|
|
72
72
|
*/
|
|
73
73
|
export function successStatusOf(operation) {
|
|
@@ -93,19 +93,19 @@ export function successStatusesOf(operation) {
|
|
|
93
93
|
/** The five buckets OpenAPI can express a status RANGE as, in the order it names them. */
|
|
94
94
|
const STATUS_RANGE_GROUPS = [1, 2, 3, 4, 5];
|
|
95
95
|
/**
|
|
96
|
-
* What the document will key this response under
|
|
96
|
+
* What the document will key this response under - **openapi3's rule, copied, not re-derived.**
|
|
97
97
|
*
|
|
98
|
-
*
|
|
98
|
+
* **`HttpStatusCodesEntry` is `HttpStatusCodeRange | number | "*"`, and the third case was
|
|
99
99
|
* silently dropped.** A response declared `@minValue(400) @maxValue(499) @statusCode _: int32` is a
|
|
100
100
|
* `4XX` entry in the document and was emitted NOWHERE here: not refused, not warned about, just
|
|
101
|
-
* absent from the arms
|
|
101
|
+
* absent from the arms - so a service could publish a whole class of failures and hand `respond`
|
|
102
102
|
* nothing to check them against. Two of the two operations in `response/status-code-range` lost an
|
|
103
103
|
* arm each this way, and the emitter's own `mixed-error-bodies` refusal was firing over the same
|
|
104
104
|
* scenario for an unrelated and by then obsolete reason, which is how it stayed hidden.
|
|
105
105
|
*
|
|
106
|
-
*
|
|
106
|
+
* **A partial range is REFUSED, because `@typespec/openapi3` refuses it.** `494-499` does not
|
|
107
107
|
* cover the `4XX` bucket, and bucketing it anyway would have the server claim that a `400` carries
|
|
108
|
-
* `ErrorInRange`
|
|
108
|
+
* `ErrorInRange` - a rule no document states, since openapi3 declines to publish a document at all.
|
|
109
109
|
* Agreeing with the reference implementation is the whole point of copying its line: the same spec
|
|
110
110
|
* is either representable by both or by neither.
|
|
111
111
|
*
|
|
@@ -158,24 +158,24 @@ function isSuccessKey(status) {
|
|
|
158
158
|
/**
|
|
159
159
|
* Every FAILURE arm the operation declares, each with the schema for ITS OWN body.
|
|
160
160
|
*
|
|
161
|
-
*
|
|
161
|
+
* **None of these reached the generated output at all, and a census is the proof.** Measured on one
|
|
162
162
|
* service, the document declared `200`x9, `201`x3, `202`x1, `401`x1 and `default`x2, and only the
|
|
163
163
|
* first three were emitted. So the schema for a failure never arrived, and no application could have
|
|
164
164
|
* checked what it sends when something goes wrong even if it wanted to.
|
|
165
165
|
*
|
|
166
|
-
*
|
|
166
|
+
* **Then every arm that did arrive carried the SERVICE-WIDE error schema.** The spike declares
|
|
167
167
|
* `@error model WebhookUnauthorized { ...ProblemDetails; @statusCode 401 }` and openapi3 publishes
|
|
168
168
|
* the 401 as `$ref: WebhookUnauthorized` while we answered with `problemDetailsSchema`. The shapes
|
|
169
|
-
* were identical there, so nothing was mis-validated
|
|
169
|
+
* were identical there, so nothing was mis-validated - but the component the document names for that
|
|
170
170
|
* response had no declaration at all.
|
|
171
171
|
*
|
|
172
|
-
*
|
|
172
|
+
* **`"default"` used to be the one arm with no body of its own**, filled in downstream from the
|
|
173
173
|
* service-wide schema. That was backwards: a catch-all response declares a body like any other
|
|
174
174
|
* response, and `response/status-code-range` gives two operations whose `default` is a DIFFERENT
|
|
175
175
|
* model from every other failure they declare. Reading each response's own body answers all three at
|
|
176
176
|
* once, and is why the emitter no longer needs to believe a service has one error shape.
|
|
177
177
|
*
|
|
178
|
-
* Arms come out in the order OpenAPI resolves them
|
|
178
|
+
* Arms come out in the order OpenAPI resolves them - exact codes, then ranges, then the catch-all -
|
|
179
179
|
* so a consumer scanning for the first match applies the precedence the specification states.
|
|
180
180
|
*/
|
|
181
181
|
function errorArmsOf(program, operation, registry) {
|
|
@@ -194,11 +194,11 @@ function errorArmsOf(program, operation, registry) {
|
|
|
194
194
|
/**
|
|
195
195
|
* Where an arm sits in OpenAPI's own precedence order.
|
|
196
196
|
*
|
|
197
|
-
*
|
|
197
|
+
* **The specification decides this, not us.** The Responses Object says a range MAY be given as
|
|
198
198
|
* an uppercase `X` wildcard, and that "if a response is defined using an explicit code, the explicit
|
|
199
199
|
* code definition takes precedence over the range definition for that code". `default` is by
|
|
200
200
|
* definition every status not otherwise listed, so it is last. Emitting the arms already sorted is
|
|
201
|
-
* what lets a consumer take the first match and be right
|
|
201
|
+
* what lets a consumer take the first match and be right - the alternative is every application
|
|
202
202
|
* re-deriving the rule, and getting it wrong quietly when a range and an exact code both apply.
|
|
203
203
|
*/
|
|
204
204
|
function statusPrecedenceOf(status) {
|
|
@@ -206,7 +206,7 @@ function statusPrecedenceOf(status) {
|
|
|
206
206
|
return 2;
|
|
207
207
|
return typeof status === "number" ? 0 : 1;
|
|
208
208
|
}
|
|
209
|
-
/** A response's own body, as an emitted Zod expression
|
|
209
|
+
/** A response's own body, as an emitted Zod expression - `undefined` where it declares none. */
|
|
210
210
|
function bodySchemaOf(response, registry) {
|
|
211
211
|
for (const content of response.responses) {
|
|
212
212
|
if (content.body?.bodyKind !== "single")
|
|
@@ -218,11 +218,11 @@ function bodySchemaOf(response, registry) {
|
|
|
218
218
|
return undefined;
|
|
219
219
|
}
|
|
220
220
|
/**
|
|
221
|
-
* Whether the operation declares `@useAuth(NoAuth)`
|
|
221
|
+
* Whether the operation declares `@useAuth(NoAuth)` - no caller is established for it.
|
|
222
222
|
*
|
|
223
|
-
*
|
|
223
|
+
* **`@useAuth(NoAuth)` reaches OpenAPI as `security: []`, which is why this is derivable at all.**
|
|
224
224
|
* Whether an operation needs a caller is a contract fact the document publishes. What KIND of caller
|
|
225
|
-
*
|
|
225
|
+
* - account-scoped, resource-scoped - is not: no OpenAPI keyword expresses it, and a server emitter
|
|
226
226
|
* that guesses is enforcing something no document states.
|
|
227
227
|
*/
|
|
228
228
|
function noAuthFor(program, operation) {
|
|
@@ -231,13 +231,13 @@ function noAuthFor(program, operation) {
|
|
|
231
231
|
/**
|
|
232
232
|
* The body every `@error` response in the service declares, when they all declare the same one.
|
|
233
233
|
*
|
|
234
|
-
*
|
|
235
|
-
* capability
|
|
236
|
-
* what it sends"
|
|
234
|
+
* **The emitter does not know what a problem document is, and must not.** This is the general
|
|
235
|
+
* capability - "a service may declare an error response; emit its validator so the runtime can check
|
|
236
|
+
* what it sends" - which is what makes it reusable by any gateway built on this emitter rather than a
|
|
237
237
|
* CM-shaped special case. RFC 9457 happens to be what `cm-api` puts in that slot.
|
|
238
238
|
*
|
|
239
|
-
*
|
|
240
|
-
* reported `mixed-error-bodies`
|
|
239
|
+
* **This used to be an INVARIANT the emitter enforced, and it is now merely an observation.** It
|
|
240
|
+
* reported `mixed-error-bodies` - an error, aborting the compile - whenever a service declared two
|
|
241
241
|
* catch-all bodies, or a specific status whose body neither was nor spread the catch-all one. That
|
|
242
242
|
* was a real constraint while every failure arm carried this single schema; now each declared
|
|
243
243
|
* response resolves its own, and a service answering `404` with `NotFoundError` and everything else
|
|
@@ -245,7 +245,7 @@ function noAuthFor(program, operation) {
|
|
|
245
245
|
*
|
|
246
246
|
* So the refusal is gone, and what remains is a convenience: the module-level `GENERATED_ERROR_SCHEMA`
|
|
247
247
|
* the route table publishes, which exists precisely when the service has one such body. Where it does
|
|
248
|
-
* not, the constant is not emitted
|
|
248
|
+
* not, the constant is not emitted - and a consumer importing it gets a missing export, named at the
|
|
249
249
|
* import, which is the honest report of "there is no such thing here".
|
|
250
250
|
*/
|
|
251
251
|
export function errorBodyOf(program, service) {
|
|
@@ -254,13 +254,13 @@ export function errorBodyOf(program, service) {
|
|
|
254
254
|
for (const operation of service.operations) {
|
|
255
255
|
for (const response of operation.responses) {
|
|
256
256
|
/**
|
|
257
|
-
*
|
|
257
|
+
* `@error` sits on the RESPONSE type, which is not always the body type.
|
|
258
258
|
*
|
|
259
259
|
* Two declaration styles are both idiomatic and both occur. One returns the error model bare
|
|
260
260
|
* (`op x(): Foo | ProblemDetails`), so response type and body type are the same; the other wraps
|
|
261
|
-
* it (`@error model ErrorResponse { @statusCode
|
|
261
|
+
* it (`@error model ErrorResponse { @statusCode ...; @body body: Problem }`), so the `@error` is
|
|
262
262
|
* on the wrapper and the body is a plain model. Testing the body missed the second style
|
|
263
|
-
* entirely
|
|
263
|
+
* entirely - measured on a surface declaring an error on all
|
|
264
264
|
* eleven operations and this found none of them.
|
|
265
265
|
*/
|
|
266
266
|
if (!isErrorModel(program, response.type))
|
|
@@ -270,15 +270,15 @@ export function errorBodyOf(program, service) {
|
|
|
270
270
|
if (body?.bodyKind !== "single")
|
|
271
271
|
continue;
|
|
272
272
|
/**
|
|
273
|
-
*
|
|
273
|
+
* **An error response can declare no body either.** `tsp-openapi3` renders a bare
|
|
274
274
|
* `default` response as `DefaultResponse<Body = void>`, so the Swagger Petstore reaches
|
|
275
|
-
* here with the `void` intrinsic
|
|
275
|
+
* here with the `void` intrinsic - which was then registered as the service's error schema
|
|
276
276
|
* and refused by the Zod walk. There is nothing there to validate.
|
|
277
277
|
*/
|
|
278
278
|
const type = bodyTypeOf(body.type);
|
|
279
279
|
if (type === undefined)
|
|
280
280
|
continue;
|
|
281
|
-
// `"*"` is what `@error` resolves to when the model declares no status
|
|
281
|
+
// `"*"` is what `@error` resolves to when the model declares no status - the operation's
|
|
282
282
|
// general "any failure" contract, and the shape the runtime constructs.
|
|
283
283
|
(response.statusCodes === "*" ? canonical : specific).add(type);
|
|
284
284
|
}
|
|
@@ -286,7 +286,7 @@ export function errorBodyOf(program, service) {
|
|
|
286
286
|
}
|
|
287
287
|
/**
|
|
288
288
|
* Two catch-all bodies means there is no "the" service-wide error body, so there is nothing to
|
|
289
|
-
* publish
|
|
289
|
+
* publish - which is a reason to emit no constant, not a defect to refuse. Each operation's own
|
|
290
290
|
* `default` arm carries its own schema, and that is what the server validates against.
|
|
291
291
|
*/
|
|
292
292
|
if (canonical.size > 1)
|
|
@@ -295,7 +295,7 @@ export function errorBodyOf(program, service) {
|
|
|
295
295
|
if (body !== undefined)
|
|
296
296
|
return body;
|
|
297
297
|
/**
|
|
298
|
-
* A service can decline to declare a catch-all and still have one error shape
|
|
298
|
+
* A service can decline to declare a catch-all and still have one error shape - ten specific
|
|
299
299
|
* statuses carrying one problem model between them. That is a service-wide
|
|
300
300
|
* body by any honest reading. Two different ones is not, and picking the first would be naming a
|
|
301
301
|
* contract fact by iteration order.
|
|
@@ -305,10 +305,10 @@ export function errorBodyOf(program, service) {
|
|
|
305
305
|
/**
|
|
306
306
|
* A response's body type, or `undefined` where it declares no body.
|
|
307
307
|
*
|
|
308
|
-
*
|
|
308
|
+
* **`void` and `never` ARE "no body", wherever they appear.** Answering that in one place is the
|
|
309
309
|
* whole point: the fact reached this emitter by three different roads and each one had to learn it
|
|
310
|
-
* separately otherwise. A `204` with no content at all; an `op remove(): void`; and
|
|
311
|
-
* actually broke the Swagger Petstore
|
|
310
|
+
* separately otherwise. A `204` with no content at all; an `op remove(): void`; and - the road that
|
|
311
|
+
* actually broke the Swagger Petstore - `tsp-openapi3` writing a `default` error response as
|
|
312
312
|
* `DefaultResponse<Body = void>`. Returned as a type, any of them is walked and refused with
|
|
313
313
|
* `Cannot emit Zod for Intrinsic: 'void' has no runtime representation'`.
|
|
314
314
|
*/
|
|
@@ -318,16 +318,16 @@ function bodyTypeOf(type) {
|
|
|
318
318
|
/**
|
|
319
319
|
* The declared body type of the operation's success response, if it has one.
|
|
320
320
|
*
|
|
321
|
-
*
|
|
321
|
+
* **`void` and `never` ARE "no body", and reading them as types was a defect.** A response with
|
|
322
322
|
* no content reaches this in two shapes: as no body at all, which `undefined` already covered, and
|
|
323
|
-
* as a body whose type is `void`
|
|
323
|
+
* as a body whose type is `void` - which is what `tsp-openapi3` writes when it converts a `204`,
|
|
324
324
|
* and what `op remove(): void` means. The second used to be returned as a type, walked, and refused
|
|
325
325
|
* with `Cannot emit Zod for Intrinsic: 'void' has no runtime representation'`. That took out the
|
|
326
326
|
* Swagger Petstore, the most widely implemented reference API there is, on the round-trip suite's
|
|
327
327
|
* first run.
|
|
328
328
|
*
|
|
329
329
|
* One fact arriving by two roads. Answering it here rather than at each of the four call sites is
|
|
330
|
-
* what keeps them from drifting
|
|
330
|
+
* what keeps them from drifting - `collectRoutes`, the contract types, the dual-status
|
|
331
331
|
* discriminator and the alternate-arm schema all want the same answer to the same question.
|
|
332
332
|
*/
|
|
333
333
|
function successBodyOf(operation, status) {
|
|
@@ -345,10 +345,10 @@ function successBodyOf(operation, status) {
|
|
|
345
345
|
/**
|
|
346
346
|
* The media types this operation's success response can be served as.
|
|
347
347
|
*
|
|
348
|
-
*
|
|
348
|
+
* **The fact that decides whether two operations are a collision or a CHOICE.** TypeSpec models
|
|
349
349
|
* content negotiation as several operations on one route, each accepting a different media type;
|
|
350
350
|
* OpenAPI models it as one path entry whose response lists them all. This emitter read neither, so
|
|
351
|
-
* it registered every operation and Hono
|
|
351
|
+
* it registered every operation and Hono - which matches in registration order - left all but the
|
|
352
352
|
* first as dead code that looks mounted.
|
|
353
353
|
*
|
|
354
354
|
* Empty when the response carries no body, which is not the same as a body served as anything.
|
|
@@ -365,17 +365,17 @@ function responseContentTypesOf(operation, status) {
|
|
|
365
365
|
}
|
|
366
366
|
return [...types];
|
|
367
367
|
}
|
|
368
|
-
/** Whether a type is an array
|
|
368
|
+
/** Whether a type is an array - TypeSpec models one as a `Model` carrying an integer indexer. */
|
|
369
369
|
function isArrayType(type) {
|
|
370
370
|
return type.kind === "Model" && type.indexer?.key.name === "integer";
|
|
371
371
|
}
|
|
372
372
|
/**
|
|
373
373
|
* How to choose between an operation's two success statuses, read from the declared arms.
|
|
374
374
|
*
|
|
375
|
-
*
|
|
375
|
+
* **A discriminator, not a trial-parse.** "Try the 200 schema, and if it fails use the 202" is the
|
|
376
376
|
* banned fallback wearing a different hat: a genuine validation failure on the first arm would be
|
|
377
|
-
* reported as the second arm's shape. Each arm pins a **required literal** property instead
|
|
378
|
-
* `awaiting: true` on the accepted one
|
|
377
|
+
* reported as the second arm's shape. Each arm pins a **required literal** property instead -
|
|
378
|
+
* `awaiting: true` on the accepted one - and a consumer reads that one property.
|
|
379
379
|
*
|
|
380
380
|
* This replaces hand-written per-operation closures, which existed only because a spec declaring the
|
|
381
381
|
* SAME body model for both statuses could not say which was which.
|
|
@@ -406,17 +406,17 @@ function alternateSchemaFor(operation, status, registry) {
|
|
|
406
406
|
/**
|
|
407
407
|
* The Zod for a **multipart** request body, built from its parts.
|
|
408
408
|
*
|
|
409
|
-
*
|
|
410
|
-
* was mounted with NO body validator at all**
|
|
409
|
+
* **`requestBodyOf` returns a type only for `bodyKind === "single"`, so every multipart operation
|
|
410
|
+
* was mounted with NO body validator at all** - 19 routes across `payload/multipart` and 12
|
|
411
411
|
* components the document declares that nothing here could check. Not a refusal, not a diagnostic:
|
|
412
412
|
* silently unvalidated, which is the failure mode this emitter exists to remove.
|
|
413
413
|
*
|
|
414
|
-
* `HttpPart<T>` is unwrapped to `T`, which is what the document publishes
|
|
414
|
+
* `HttpPart<T>` is unwrapped to `T`, which is what the document publishes - `HttpPart<string>` is
|
|
415
415
|
* `{type: "string"}` and `HttpPart<Address>` is a `$ref`.
|
|
416
416
|
*
|
|
417
|
-
*
|
|
417
|
+
* **A binary part is `z.unknown()`, deliberately.** `SCALARS` maps `bytes` to `z.string()`, which
|
|
418
418
|
* is right for a JSON body (base64) and wrong here: in a multipart part `bytes` is raw binary, and
|
|
419
|
-
* openapi3 publishes `{}` for it
|
|
419
|
+
* openapi3 publishes `{}` for it - no type at all. Emitting `z.string()` would enforce a rule the
|
|
420
420
|
* document does not state AND reject what a server actually receives, since `c.req.parseBody()`
|
|
421
421
|
* hands back a `File`. The type of a value is positional, exactly as its visibility is.
|
|
422
422
|
*/
|
|
@@ -424,7 +424,7 @@ function multipartSchemaOf(body, registry) {
|
|
|
424
424
|
const entries = body.parts.map((part) => {
|
|
425
425
|
const type = part.body?.type;
|
|
426
426
|
const inner = type === undefined || isBinaryPart(type) ? "z.unknown()" : registry.expressionFor(type);
|
|
427
|
-
// `multi` is `HttpPart<T>[]`
|
|
427
|
+
// `multi` is `HttpPart<T>[]` - the same part repeated, which arrives as an array.
|
|
428
428
|
const value = part.multi === true ? `z.array(${inner})` : inner;
|
|
429
429
|
return `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.optional()` : value},`;
|
|
430
430
|
});
|
|
@@ -432,13 +432,13 @@ function multipartSchemaOf(body, registry) {
|
|
|
432
432
|
/**
|
|
433
433
|
* Declared under the model's own name when it has one, because the document publishes a component
|
|
434
434
|
* for it. Emitted inline the schema is correct and the component still has no validator, which the
|
|
435
|
-
* differential reports
|
|
435
|
+
* differential reports - rightly - as a contract the runtime cannot check.
|
|
436
436
|
*/
|
|
437
437
|
const named = body.type;
|
|
438
438
|
const name = named !== undefined && named.kind === "Model" && named.name !== "" ? named.name : undefined;
|
|
439
439
|
return name === undefined ? shape : registry.declareNamed(name, () => shape);
|
|
440
440
|
}
|
|
441
|
-
/** `bytes`, or a model built on `File`
|
|
441
|
+
/** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
|
|
442
442
|
function isBinaryPart(type) {
|
|
443
443
|
if (type.kind === "Scalar") {
|
|
444
444
|
for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
|
|
@@ -458,18 +458,18 @@ function isBinaryPart(type) {
|
|
|
458
458
|
/**
|
|
459
459
|
* Whether a body served under these media types is RAW BINARY rather than a JSON value.
|
|
460
460
|
*
|
|
461
|
-
*
|
|
461
|
+
* **The distinction decides whether a validator may say anything about it at all.** A `bytes`
|
|
462
462
|
* body inside a JSON payload is base64 and the document publishes `type: "string"` with
|
|
463
463
|
* `contentEncoding`; a `bytes` body served as `application/octet-stream` or `image/png` is the bytes
|
|
464
|
-
* themselves, and `@typespec/openapi3` publishes a schema carrying only `contentMediaType`
|
|
464
|
+
* themselves, and `@typespec/openapi3` publishes a schema carrying only `contentMediaType` - which is
|
|
465
465
|
* an ANNOTATION under JSON Schema 2020-12, not an assertion. The document therefore states nothing
|
|
466
466
|
* about the value, and a validator that requires a string enforces a rule no caller reading the
|
|
467
467
|
* contract can see.
|
|
468
468
|
*
|
|
469
|
-
*
|
|
469
|
+
* **The same decision was already made for multipart and not for bodies**, which is how the two
|
|
470
470
|
* came to disagree: a binary PART is `z.unknown()` because openapi3 publishes `{}` for it, while a
|
|
471
471
|
* binary BODY stayed `z.string()`. Found by grading response-body kinds, a surface that had been
|
|
472
|
-
* counted and never compared
|
|
472
|
+
* counted and never compared - three positions in `encode/bytes`.
|
|
473
473
|
*/
|
|
474
474
|
export function isRawBinaryMediaType(contentTypes) {
|
|
475
475
|
if (contentTypes.length === 0)
|
|
@@ -483,13 +483,13 @@ function requestBodyOf(operation) {
|
|
|
483
483
|
}
|
|
484
484
|
/**
|
|
485
485
|
* Parameters are emitted as their **own** schemas rather than folded into the body, because the two
|
|
486
|
-
* are separate things in HTTP and in OpenAPI
|
|
486
|
+
* are separate things in HTTP and in OpenAPI - but they are equally part of the operation's input,
|
|
487
487
|
* and `?state=nonsense` must be a 400 rather than something the application has to defend against.
|
|
488
488
|
* Leaving them undescribed would document a constraint the runtime does not enforce, which is the
|
|
489
489
|
* same failure the vocabulary tuples exist to prevent one level down.
|
|
490
490
|
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
491
|
+
* **Split by WHERE they arrive, and only so.** A validator library validates one target at a time
|
|
492
|
+
* - `("param", ...)`, `("query", ...)` - which is how a caller learns *which* part of the request was
|
|
493
493
|
* wrong instead of being handed a merged object's complaint. A merged form existed beside these until
|
|
494
494
|
* 0.1.0 and was deleted: nothing read it, and it had drifted into a second set of rules. See
|
|
495
495
|
* `EmittedRoute.pathSchema`.
|
|
@@ -497,14 +497,14 @@ function requestBodyOf(operation) {
|
|
|
497
497
|
/**
|
|
498
498
|
* How a list is flattened into one query or header value, per OpenAPI's `style`.
|
|
499
499
|
*
|
|
500
|
-
*
|
|
500
|
+
* **Three of the four standard collection formats rejected every conformant request**, and only
|
|
501
501
|
* one of the three was visible to the differential. `?colors=blue,red,green` arrives as a single
|
|
502
502
|
* string; a validator expecting an array refuses it. For `pipes` and `ssv` the emitter also typed
|
|
503
|
-
* the parameter as a string, so document and validator disagreed and the oracle said so
|
|
503
|
+
* the parameter as a string, so document and validator disagreed and the oracle said so - but for
|
|
504
504
|
* `csv` both sides said `array` and **agreed perfectly** while the server 400'd every caller. No
|
|
505
505
|
* document-to-validator comparison can see that: the disagreement is with the WIRE.
|
|
506
506
|
*
|
|
507
|
-
* `explode: true` is the exception
|
|
507
|
+
* `explode: true` is the exception - the value repeats (`?colors=blue&colors=red`) and Hono hands
|
|
508
508
|
* back an array already, so there is nothing to undo.
|
|
509
509
|
*/
|
|
510
510
|
const COLLECTION_DELIMITERS = {
|
|
@@ -540,8 +540,8 @@ function collectionDelimiterOf(program, parameter) {
|
|
|
540
540
|
/**
|
|
541
541
|
* Decode a path, query or header value from the only thing HTTP can carry: text.
|
|
542
542
|
*
|
|
543
|
-
*
|
|
544
|
-
* means once decoded
|
|
543
|
+
* **A path or query parameter ALWAYS arrives as a string, and `type: integer` describes what it
|
|
544
|
+
* means once decoded - not what is on the wire.** `zValidator("param", ...)` hands `c.req.param()`
|
|
545
545
|
* straight to the schema, so `z.number().int()` met `"1"` and refused it. Measured against a server
|
|
546
546
|
* generated from the Swagger Petstore and run under `wrangler dev`:
|
|
547
547
|
*
|
|
@@ -552,17 +552,17 @@ function collectionDelimiterOf(program, parameter) {
|
|
|
552
552
|
* GET /store/order/1 -> 400 (integer)
|
|
553
553
|
* ```
|
|
554
554
|
*
|
|
555
|
-
*
|
|
556
|
-
* `test/reference/service.tsp`
|
|
555
|
+
* **Nothing saw it because nothing ever asked.** Every path and query parameter in
|
|
556
|
+
* `test/reference/service.tsp` - the only fixture that makes real requests - is a `string`. Across
|
|
557
557
|
* the corpus this emitter writes **29 numeric query and header parameters** and requests none of
|
|
558
558
|
* them. The validators were right about the document the whole time; the disagreement was with what
|
|
559
559
|
* arrives, which is the one thing no document comparison can see.
|
|
560
560
|
*
|
|
561
|
-
*
|
|
561
|
+
* **This DECODES, it does not coerce.** `z.coerce.number()` would have been one character of
|
|
562
562
|
* effort and wrong: `Number("")` is `0`, so `?limit=` would satisfy a required integer and an empty
|
|
563
563
|
* value the document forbids would sail through as zero. A value that is not a well-formed number is
|
|
564
564
|
* passed along UNCHANGED, so it fails against the schema the document published and reports the error
|
|
565
|
-
* the document justifies. Same rule as the collection split above
|
|
565
|
+
* the document justifies. Same rule as the collection split above - decode the wire form, then apply
|
|
566
566
|
* the document's schema to the result, never instead of it.
|
|
567
567
|
*
|
|
568
568
|
* Derived from the emitted expression rather than a restated list of scalar names: `SCALARS` in
|
|
@@ -577,7 +577,7 @@ function wireDecoded(declared) {
|
|
|
577
577
|
if (declared.startsWith("z.boolean()"))
|
|
578
578
|
return `z.preprocess(${BOOLEAN}, ${declared})`;
|
|
579
579
|
/**
|
|
580
|
-
* A list whose ELEMENTS are numeric or boolean
|
|
580
|
+
* A list whose ELEMENTS are numeric or boolean - `?ids=1,2,3` after the collection split, or a
|
|
581
581
|
* repeated `?id=1&id=2`. The elements are strings for exactly the same reason the scalar is, so
|
|
582
582
|
* the same decoder applies per element. A non-array value is left alone; the split that produces
|
|
583
583
|
* the array runs outside this and may not have happened.
|
|
@@ -593,9 +593,9 @@ function wireDecoded(declared) {
|
|
|
593
593
|
/**
|
|
594
594
|
* Decode a `content-type` header down to the media type the document names.
|
|
595
595
|
*
|
|
596
|
-
*
|
|
596
|
+
* **A media type is a type PLUS parameters, and the document states only the type.** `@header
|
|
597
597
|
* contentType: "multipart/form-data"` becomes `z.literal("multipart/form-data")`, which refuses
|
|
598
|
-
* `multipart/form-data; boundary
|
|
598
|
+
* `multipart/form-data; boundary=...` - and RFC 2046 section 5.1.1 makes that boundary MANDATORY, so the
|
|
599
599
|
* validator rejected **every syntactically valid multipart request**. Measured under `wrangler dev`:
|
|
600
600
|
*
|
|
601
601
|
* ```
|
|
@@ -607,24 +607,24 @@ function wireDecoded(declared) {
|
|
|
607
607
|
* `content-type`**, in 12 scenarios. Seventeen are `multipart/form-data` and fail 100% of the time;
|
|
608
608
|
* the remaining 61 refuse any caller sending the entirely legal `; charset=utf-8`.
|
|
609
609
|
*
|
|
610
|
-
*
|
|
610
|
+
* **Enforcing "no parameters" is enforcing something the document does not say**, which is the
|
|
611
611
|
* governing rule read in the direction that is easy to miss: the rule forbids inventing constraints
|
|
612
612
|
* just as much as it forbids dropping them. Stripping the parameters before comparing is the same
|
|
613
|
-
* move as decoding `"1"` to `1`
|
|
613
|
+
* move as decoding `"1"` to `1` - undo the transport form, then apply the document's schema to the
|
|
614
614
|
* result.
|
|
615
615
|
*
|
|
616
|
-
* Lowercased because RFC 9110
|
|
616
|
+
* Lowercased because RFC 9110 section 8.3.1 makes media type comparison case-insensitive - but ONLY when the
|
|
617
617
|
* declared literal is itself lowercase, so a document that published an oddly-cased type keeps
|
|
618
618
|
* matching exactly rather than silently never matching. Every literal openapi3 publishes across the
|
|
619
619
|
* corpus is lowercase; this costs one condition and removes the way that fact could stop being true.
|
|
620
620
|
*/
|
|
621
621
|
function mediaTypeDecoded(declared) {
|
|
622
622
|
/**
|
|
623
|
-
*
|
|
623
|
+
* **One accepted media type is a literal; several are an ENUM, and the second form is the one a
|
|
624
624
|
* real document produces.** The corpus writes 78 `content-type` validators and every one is a
|
|
625
625
|
* single literal, so a decoder that handled only `z.literal` looked complete against it. The
|
|
626
626
|
* Swagger Petstore's `addPet` accepts three media types and openapi3 publishes
|
|
627
|
-
* `z.enum(["application/json", "application/xml", "application/x-www-form-urlencoded"])`
|
|
627
|
+
* `z.enum(["application/json", "application/xml", "application/x-www-form-urlencoded"])` - which
|
|
628
628
|
* went on refusing `application/json; charset=utf-8` after the literal case was fixed. Measured:
|
|
629
629
|
* with the charset parameter **400**, without it **200**, same body.
|
|
630
630
|
*/
|
|
@@ -647,15 +647,15 @@ function parameterSchemasOf(program, operation, registry) {
|
|
|
647
647
|
continue;
|
|
648
648
|
}
|
|
649
649
|
/**
|
|
650
|
-
*
|
|
650
|
+
* **`parameter.name` is the WIRE name; `parameter.param.name` is the TypeSpec one.** Headers
|
|
651
651
|
* used to be keyed on the second, and it made every header validator reject every conformant
|
|
652
652
|
* request: `@header("webhook-id") id: string` produced `z.object({ id })`, while `zValidator`
|
|
653
|
-
* reads what actually arrived
|
|
653
|
+
* reads what actually arrived - a header called `webhook-id`. Measured: **400 against the
|
|
654
654
|
* emitted schema, 200 against the documented one**, on a webhook's signed headers.
|
|
655
655
|
*
|
|
656
656
|
* The wire name is also the right answer for path and query where the two differ
|
|
657
657
|
* (`@query("$select") select`), and it is what `propertyKey` already does for body properties
|
|
658
|
-
* via `resolveEncodedName`
|
|
658
|
+
* via `resolveEncodedName` - the document publishes one name per parameter, and it is this one.
|
|
659
659
|
* The TypeSpec property name is an authoring convenience that does not survive to the contract.
|
|
660
660
|
*/
|
|
661
661
|
const delimiter = collectionDelimiterOf(program, parameter);
|
|
@@ -663,7 +663,7 @@ function parameterSchemasOf(program, operation, registry) {
|
|
|
663
663
|
/**
|
|
664
664
|
* The wire form is decoded before it is validated, never instead of it. `z.preprocess` undoes
|
|
665
665
|
* exactly the flattening the document's `style` describes and then applies the schema the
|
|
666
|
-
* document publishes, so every constraint on the list
|
|
666
|
+
* document publishes, so every constraint on the list - `minItems`, the element type - still
|
|
667
667
|
* runs. Splitting in the handler instead would have left those unenforced.
|
|
668
668
|
*/
|
|
669
669
|
/**
|
|
@@ -681,7 +681,7 @@ function parameterSchemasOf(program, operation, registry) {
|
|
|
681
681
|
const entry = `\t${objectKey(parameter.name)}: ${expression},`;
|
|
682
682
|
(byTarget[parameter.type] ??= []).push(entry);
|
|
683
683
|
/**
|
|
684
|
-
*
|
|
684
|
+
* **On a route several operations share, `accept` SELECTS the operation - it is not a
|
|
685
685
|
* constraint on one of them.** Each member declares its own literal, so validating against any
|
|
686
686
|
* single member's would 400 every request asking for one of the others, and 400 is the wrong
|
|
687
687
|
* answer twice over: the request is well-formed, and the contract's answer to "I cannot serve
|
|
@@ -716,15 +716,15 @@ export function collectRoutes(program, registry, service) {
|
|
|
716
716
|
const statusCode = successStatusOf(operation);
|
|
717
717
|
if (statusCode === undefined)
|
|
718
718
|
continue;
|
|
719
|
-
// No body is a legitimate success
|
|
719
|
+
// No body is a legitimate success - see `EmittedRoute.responseSchema`. Skipping here is what
|
|
720
720
|
// dropped 282 operations across the conformance corpus without a word.
|
|
721
721
|
const responseType = successBodyOf(operation, statusCode);
|
|
722
722
|
const requestType = requestBodyOf(operation);
|
|
723
723
|
/**
|
|
724
|
-
*
|
|
724
|
+
* **A streamed operation's EVENT payloads are not reachable from its body.**
|
|
725
725
|
*
|
|
726
|
-
* `op receive(): SSEStream<UnnamedEvents>` has a response body of `string`
|
|
727
|
-
* stream
|
|
726
|
+
* `op receive(): SSEStream<UnnamedEvents>` has a response body of `string` - the event
|
|
727
|
+
* stream - so the walk above sees a scalar and stops, and `Unnamed.Info` gets no validator
|
|
728
728
|
* even though the document publishes it as a component. Declaring the union here reaches
|
|
729
729
|
* every payload through it, which is the same set openapi3 emits. The result is discarded:
|
|
730
730
|
* the route's response schema is still the stream, and this exists so the vocabulary a
|
|
@@ -734,7 +734,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
734
734
|
if (streamed !== undefined) {
|
|
735
735
|
withVisibility(program, Visibility.Read, () => registry.expressionFor(streamed));
|
|
736
736
|
}
|
|
737
|
-
// `@requestVisibility` when the operation states one, otherwise the verb's default
|
|
737
|
+
// `@requestVisibility` when the operation states one, otherwise the verb's default - the
|
|
738
738
|
// same resolution `@typespec/openapi3` performs before emitting a request schema.
|
|
739
739
|
const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
|
|
740
740
|
const statusCodes = successStatusesOf(operation);
|
|
@@ -753,11 +753,11 @@ export function collectRoutes(program, registry, service) {
|
|
|
753
753
|
operationId: resolveOperationId(program, operation.operation),
|
|
754
754
|
verb: operation.verb.toUpperCase(),
|
|
755
755
|
/**
|
|
756
|
-
* The path TEMPLATE, exactly as the document publishes it
|
|
756
|
+
* The path TEMPLATE, exactly as the document publishes it - `/widgets/{widget-id}`.
|
|
757
757
|
*
|
|
758
|
-
*
|
|
758
|
+
* **This used to be a router's path, converted here.** `toHonoPath` ran during
|
|
759
759
|
* collection and rewrote `{widget-id}` to `:widget-id`, so the shared intermediate
|
|
760
|
-
* representation carried one framework's spelling of a contract fact
|
|
760
|
+
* representation carried one framework's spelling of a contract fact - and a template
|
|
761
761
|
* that router could not mount refused the whole operation, validators included.
|
|
762
762
|
*
|
|
763
763
|
* A server emitter converts this when it renders, and refuses what it cannot mount with
|
|
@@ -773,15 +773,15 @@ export function collectRoutes(program, registry, service) {
|
|
|
773
773
|
/**
|
|
774
774
|
* `@summary` first, `@doc` as the fallback.
|
|
775
775
|
*
|
|
776
|
-
* OpenAPI distinguishes them
|
|
777
|
-
* explanation
|
|
776
|
+
* OpenAPI distinguishes them - `summary` is the short line, `description` the verbose
|
|
777
|
+
* explanation - and `@typespec/openapi3` maps them to exactly those. A spec whose operation
|
|
778
778
|
* strings are one-liners means `summary`; written as `@doc` they land in `description`
|
|
779
779
|
* instead and leave `summary` empty on every operation. The fallback keeps a spec that
|
|
780
780
|
* uses `@doc` working.
|
|
781
781
|
*/
|
|
782
782
|
summary: getSummary(program, operation.operation) ?? getDoc(program, operation.operation),
|
|
783
783
|
/**
|
|
784
|
-
*
|
|
784
|
+
* **Each position is walked at ITS OWN visibility, which is the whole point.** A model
|
|
785
785
|
* used as a request body and as a response is two shapes: `@statusCode` is applicable to
|
|
786
786
|
* one and not the other, and `@visibility` can add or remove properties outright. Walking
|
|
787
787
|
* both at one visibility gives whichever was seen first to both.
|
|
@@ -810,7 +810,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
810
810
|
: withVisibility(program, Visibility.Read, () =>
|
|
811
811
|
/**
|
|
812
812
|
* A raw binary body is `z.unknown()`, matching what a binary multipart part
|
|
813
|
-
* already emits and what the document actually asserts
|
|
813
|
+
* already emits and what the document actually asserts - see
|
|
814
814
|
* {@link isRawBinaryMediaType}.
|
|
815
815
|
*/
|
|
816
816
|
isBinaryPart(responseType) &&
|
|
@@ -820,7 +820,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
820
820
|
rawBodyProperty,
|
|
821
821
|
// Responses are read at Visibility.Read, exactly as the success body above is. Resolving
|
|
822
822
|
// them under the ambient request visibility declared six components under the wrong
|
|
823
|
-
// suffix
|
|
823
|
+
// suffix - measured: type/model/visibility gained six divergences.
|
|
824
824
|
errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
|
|
825
825
|
scopes: [
|
|
826
826
|
...new Set((getAuthenticationForOperation(program, operation.operation)?.options ?? []).flatMap((option) => option.schemes.flatMap((scheme) => scheme.type === "oauth2"
|
|
@@ -830,11 +830,11 @@ export function collectRoutes(program, registry, service) {
|
|
|
830
830
|
noAuth: noAuthFor(program, operation),
|
|
831
831
|
statusBy: discriminator,
|
|
832
832
|
/**
|
|
833
|
-
*
|
|
833
|
+
* **Each arm is validated against its OWN shape.**
|
|
834
834
|
*
|
|
835
835
|
* A consumer checks its value against the arm's schema, and with one schema
|
|
836
836
|
* for two statuses the acknowledged response was checked against the resolved arm's
|
|
837
|
-
* shape
|
|
837
|
+
* shape - a 502 on a perfectly good 202.
|
|
838
838
|
*/
|
|
839
839
|
alternateResponseSchema: discriminator === undefined
|
|
840
840
|
? undefined
|
|
@@ -847,7 +847,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
847
847
|
/**
|
|
848
848
|
* Emit only the external names a given file actually references.
|
|
849
849
|
*
|
|
850
|
-
* Both generated files can need them
|
|
850
|
+
* Both generated files can need them - `schemas.gen.ts` for the vocabulary tuples and `@refine`
|
|
851
851
|
* predicates, and `routes.gen.ts` when an inline params schema carries a refined scalar.
|
|
852
852
|
* Emitting the whole set into both would leave unused imports (which lint rejects); emitting it into
|
|
853
853
|
* neither is what left `routes.gen.ts` referencing `isParsableInstant` with no import at all.
|
|
@@ -867,21 +867,36 @@ function renderExternalImports(externals, ...bodies) {
|
|
|
867
867
|
* Every validator this service needs, in one module: the component schemas, and the per-operation
|
|
868
868
|
* request and response validators.
|
|
869
869
|
*
|
|
870
|
-
*
|
|
871
|
-
* a package advertising *TypeSpec HTTP
|
|
870
|
+
* **The per-operation half used to be emitted by the SERVER generator, into the server file.** So
|
|
871
|
+
* a package advertising *TypeSpec HTTP -> Zod* produced component schemas and nothing a caller could
|
|
872
872
|
* validate a request or a response against, and every oracle that graded those validators had to read
|
|
873
873
|
* a server to find them. See {@link nameRouteSchemas}.
|
|
874
874
|
*/
|
|
875
875
|
function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
|
|
876
|
-
const declarations = registry
|
|
877
|
-
|
|
878
|
-
|
|
876
|
+
const declarations = registry.declarations().map((d) => {
|
|
877
|
+
/**
|
|
878
|
+
* **A declaration on a CYCLE cannot take its type from `z.infer`, because that is the loop.**
|
|
879
|
+
* The ordinary form - `const x = ...; type X = z.infer<typeof x>` - is circular the moment `x`'s
|
|
880
|
+
* own initialiser depends on `X`, and TypeScript says so: `TS2456`, `TS2502`, `TS7022`. So a
|
|
881
|
+
* cyclic declaration carries the type written out, and the deferred one is annotated with it,
|
|
882
|
+
* which is the recipe Zod documents for exactly this. Everything else is unchanged, so a spec
|
|
883
|
+
* with no cycle emits byte-identical output to before.
|
|
884
|
+
*/
|
|
885
|
+
if (d.structural === undefined) {
|
|
886
|
+
return `\nexport const ${d.identifier} = ${d.source};\nexport type ${d.typeName} = z.infer<typeof ${d.identifier}>;\n`;
|
|
887
|
+
}
|
|
888
|
+
const declared = d.structural.startsWith("{")
|
|
889
|
+
? `export interface ${d.typeName} ${d.structural}`
|
|
890
|
+
: `export type ${d.typeName} = ${d.structural};`;
|
|
891
|
+
const annotation = d.annotated === true ? `: z.ZodType<${d.typeName}>` : "";
|
|
892
|
+
return `\n${declared}\nexport const ${d.identifier}${annotation} = ${d.source};\n`;
|
|
893
|
+
});
|
|
879
894
|
const parts = [GENERATED_BANNER, '\nimport { z } from "zod";\n'];
|
|
880
895
|
/**
|
|
881
896
|
* A TYPE import, so nothing of this package's survives into the emitted JavaScript.
|
|
882
897
|
*
|
|
883
898
|
* The annotation is what makes a misspelled or omitted `schema` a compile error rather than an arm
|
|
884
|
-
* that silently validates nothing
|
|
899
|
+
* that silently validates nothing - see the option's own docblock for the measurement. Emitted only
|
|
885
900
|
* when there are arms to annotate, because an unused import fails the lint a generated file has to
|
|
886
901
|
* pass like any other.
|
|
887
902
|
*/
|
|
@@ -895,16 +910,16 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
|
|
|
895
910
|
return parts.join("");
|
|
896
911
|
}
|
|
897
912
|
/**
|
|
898
|
-
* The IDENTIFIER a schema can be referred to by, which `z.infer<typeof
|
|
913
|
+
* The IDENTIFIER a schema can be referred to by, which `z.infer<typeof ...>` requires.
|
|
899
914
|
*
|
|
900
|
-
* A route's schema is often an expression rather than a declaration
|
|
901
|
-
* `z.object({
|
|
915
|
+
* A route's schema is often an expression rather than a declaration - `z.array(widgetSchema)`,
|
|
916
|
+
* `z.object({...})` for inline parameters - and `typeof z.array(widgetSchema)` is not valid
|
|
902
917
|
* TypeScript. Anything that is not already a bare identifier gets a name here; anything that is
|
|
903
918
|
* keeps it, so the generated file does not fill up with aliases that alias nothing.
|
|
904
919
|
*/
|
|
905
920
|
const isIdentifier = (expression) => /^[A-Za-z_$][\w$]*$/.test(expression);
|
|
906
921
|
const schemaConst = (operationId, suffix) => `${operationId}${suffix}`;
|
|
907
|
-
/** `[the name to refer to it by, the `const` line to emit]`
|
|
922
|
+
/** `[the name to refer to it by, the `const` line to emit]` - the line is absent when none is needed. */
|
|
908
923
|
function namedSchema(operationId, suffix, expression) {
|
|
909
924
|
if (expression === undefined)
|
|
910
925
|
return { name: undefined, declaration: undefined };
|
|
@@ -912,9 +927,9 @@ function namedSchema(operationId, suffix, expression) {
|
|
|
912
927
|
return { name: expression, declaration: undefined };
|
|
913
928
|
const name = schemaConst(operationId, suffix);
|
|
914
929
|
/**
|
|
915
|
-
*
|
|
916
|
-
* check.** These are the validators the server actually enforces on every request
|
|
917
|
-
* query and header shapes
|
|
930
|
+
* **Exported, because a schema nothing outside this file can name is a schema nothing can
|
|
931
|
+
* check.** These are the validators the server actually enforces on every request - the path,
|
|
932
|
+
* query and header shapes - and while they were module-local the only thing that could read them
|
|
918
933
|
* was the generated code itself. That is how a header validator keyed on the wrong name shipped:
|
|
919
934
|
* the conformance differential compares `components.schemas`, a parameter schema is not a
|
|
920
935
|
* component, and there was no third artefact to compare against even in principle.
|
|
@@ -927,10 +942,10 @@ function namedSchema(operationId, suffix, expression) {
|
|
|
927
942
|
/**
|
|
928
943
|
* Which `verb path` slots carry more than one operation.
|
|
929
944
|
*
|
|
930
|
-
*
|
|
945
|
+
* **A DOCUMENT fact, not a router one, which is why it lives here.** TypeSpec models content
|
|
931
946
|
* negotiation as several operations on one route; OpenAPI models it as a single path item whose
|
|
932
947
|
* response lists every media type. A route reached that way must not validate `accept` against any
|
|
933
|
-
* one member's literal
|
|
948
|
+
* one member's literal - `accept` SELECTS the operation, so validating it would answer `400` to a
|
|
934
949
|
* well-formed request whose real answer is `406`. That is why `negotiatedHeaderSchema` exists, and
|
|
935
950
|
* deciding which of the two applies is what this set is for.
|
|
936
951
|
*/
|
|
@@ -948,16 +963,16 @@ function sharedSlots(routes) {
|
|
|
948
963
|
/**
|
|
949
964
|
* Declare every per-operation validator, and hand back the names they were declared under.
|
|
950
965
|
*
|
|
951
|
-
*
|
|
952
|
-
* operation
|
|
953
|
-
* response arms
|
|
966
|
+
* **These used to be declared by the SERVER emitter, into the server file.** Six identifiers per
|
|
967
|
+
* operation - the path, query and header validators, the request body, the success response and the
|
|
968
|
+
* response arms - were built and emitted inside `renderApp`, which had three consequences and none of
|
|
954
969
|
* them were intended:
|
|
955
970
|
*
|
|
956
|
-
* 1. a package whose entire claim is *TypeSpec HTTP
|
|
971
|
+
* 1. a package whose entire claim is *TypeSpec HTTP -> Zod* emitted **no** request or response
|
|
957
972
|
* validator that a consumer could name. Component schemas, and nothing to check a call against;
|
|
958
973
|
* 2. every oracle that graded them had to read the SERVER file, so splitting this library out would
|
|
959
974
|
* have taken the parameter, response and response-body arms with it and left component agreement
|
|
960
|
-
* as the only thing still measured
|
|
975
|
+
* as the only thing still measured - three of the four largest arms, lost silently;
|
|
961
976
|
* 3. two emitters would have had to mint identical identifiers by coincidence, which is the coupling
|
|
962
977
|
* that made a library-plus-emitter split look impossible in the first place.
|
|
963
978
|
*
|
|
@@ -980,7 +995,7 @@ function nameRouteSchemas(routes) {
|
|
|
980
995
|
const query = declare("Query", route.querySchema);
|
|
981
996
|
const negotiating = shared.has(`${route.verb} ${route.path}`);
|
|
982
997
|
const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
|
|
983
|
-
// A raw body is read as text, never parsed, so it has no validator
|
|
998
|
+
// A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
|
|
984
999
|
const body = route.rawBodyProperty === undefined ? declare("Body", route.requestSchema) : undefined;
|
|
985
1000
|
const response = declare("Response", route.responseSchema);
|
|
986
1001
|
const alternateResponse = declare("AlternateResponse", route.alternateResponseSchema);
|
|
@@ -1000,10 +1015,10 @@ function nameRouteSchemas(routes) {
|
|
|
1000
1015
|
return { names, declarations };
|
|
1001
1016
|
}
|
|
1002
1017
|
/**
|
|
1003
|
-
* What an operation may answer with, as a literal
|
|
1018
|
+
* What an operation may answer with, as a literal - each declared status paired with the body the
|
|
1004
1019
|
* document gives it, in OpenAPI's own precedence order.
|
|
1005
1020
|
*
|
|
1006
|
-
*
|
|
1021
|
+
* **This docblock was orphaned in the source it came from**, sitting above an unrelated function
|
|
1007
1022
|
* while the one it describes had none. Reattached.
|
|
1008
1023
|
*/
|
|
1009
1024
|
function responseArmsOf(route, response, alternate) {
|
|
@@ -1020,11 +1035,11 @@ function responseArmsOf(route, response, alternate) {
|
|
|
1020
1035
|
/**
|
|
1021
1036
|
* The failure arms, which used to be dropped entirely.
|
|
1022
1037
|
*
|
|
1023
|
-
*
|
|
1038
|
+
* **The document declares what an error body looks like and the server never passed it on**, so
|
|
1024
1039
|
* `respond` could not check what it sends on failure even in principle.
|
|
1025
1040
|
*
|
|
1026
|
-
*
|
|
1027
|
-
* `arm.schema ?? errorSchema` was reached by every arm the emitter could not resolve a body for
|
|
1041
|
+
* **This used to fall back to the service-wide error schema, and the fallback was the defect.**
|
|
1042
|
+
* `arm.schema ?? errorSchema` was reached by every arm the emitter could not resolve a body for -
|
|
1028
1043
|
* which, while `"default"` was deliberately given none, meant every catch-all in every service
|
|
1029
1044
|
* answered with whichever shape `errorBodyOf` had settled on rather than the one its own response
|
|
1030
1045
|
* declares. Each arm resolves its own body now, so an arm with no schema is a response the document
|
|
@@ -1040,9 +1055,9 @@ const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
|
1040
1055
|
* The effective input of every operation, as TypeScript: its body, with the path, query and header
|
|
1041
1056
|
* parameters merged over it.
|
|
1042
1057
|
*
|
|
1043
|
-
*
|
|
1058
|
+
* **This used to drop any parameter literally named `companyId`.** One application carried its
|
|
1044
1059
|
* tenant identifier in a context object rather than in the payload, and the emitter was taught to
|
|
1045
|
-
* match
|
|
1060
|
+
* match - so for every consumer, a path parameter with that name vanished from the generated input
|
|
1046
1061
|
* type while the Zod validator went on requiring it. Two artefacts describing different shapes, from
|
|
1047
1062
|
* a rule no document states.
|
|
1048
1063
|
*
|
|
@@ -1060,28 +1075,48 @@ function collectRequestTypes(program, registry, service) {
|
|
|
1060
1075
|
/**
|
|
1061
1076
|
* Headers are part of the input too.
|
|
1062
1077
|
*
|
|
1063
|
-
* The webhook's three Standard-Webhooks headers are inside the MAC
|
|
1078
|
+
* The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
|
|
1064
1079
|
* beside the payload, they are arguments to the verification. Leaving them out of the wire
|
|
1065
1080
|
* type would describe an input the application cannot act on.
|
|
1066
1081
|
*/
|
|
1067
1082
|
const extraProperties = operation.parameters.parameters
|
|
1068
1083
|
.filter((parameter) => parameter.type === "path" || parameter.type === "query" || parameter.type === "header")
|
|
1069
|
-
|
|
1084
|
+
/**
|
|
1085
|
+
* Named as the WIRE names it, which is what the emitted validator keys on.
|
|
1086
|
+
* `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
|
|
1087
|
+
* `"x-thing"` here too: a consumer checking its handlers against these types otherwise
|
|
1088
|
+
* fails to typecheck against a server that is correct.
|
|
1089
|
+
*/
|
|
1090
|
+
.map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
|
|
1070
1091
|
if (body === undefined && extraProperties.length === 0)
|
|
1071
1092
|
continue;
|
|
1072
1093
|
/**
|
|
1073
|
-
* A `bytes` body reaches the use-case as a NAMED property holding the unparsed
|
|
1074
|
-
* a bare
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1094
|
+
* A `bytes` body reaches the use-case as a NAMED property holding the unparsed body, not as
|
|
1095
|
+
* a bare value, so the wire type is `{ rawBody: ... }`, matching what a server actually
|
|
1096
|
+
* builds. Referencing the scalar directly would type the whole input as that value and
|
|
1097
|
+
* quietly drop the headers beside it.
|
|
1077
1098
|
*/
|
|
1078
1099
|
const rawBody = body?.kind === "Scalar" && body.name === "bytes"
|
|
1079
1100
|
? (operation.parameters.body?.property?.name ?? "body")
|
|
1080
1101
|
: undefined;
|
|
1102
|
+
/**
|
|
1103
|
+
* **Raw binary is `ArrayBuffer`; base64 inside a JSON payload is `string`.** The same
|
|
1104
|
+
* `isRawBinaryMediaType` rule the response side and the multipart parts already follow,
|
|
1105
|
+
* applied here for the first time.
|
|
1106
|
+
*
|
|
1107
|
+
* This type said `string` for every `bytes` body whatever it was served as. A server reading
|
|
1108
|
+
* an `application/octet-stream` body hands the handler bytes, so a consumer checking its
|
|
1109
|
+
* handlers against these types could not typecheck against a correct server. It is the
|
|
1110
|
+
* request half of the corruption already fixed on the reader: decoding raw bytes as text
|
|
1111
|
+
* replaces every byte outside ASCII, and typing them as text is what invites it.
|
|
1112
|
+
*/
|
|
1113
|
+
const rawBodyType = isRawBinaryMediaType([...(operation.parameters.body?.contentTypes ?? [])])
|
|
1114
|
+
? "ArrayBuffer"
|
|
1115
|
+
: "string";
|
|
1081
1116
|
entries.push({
|
|
1082
1117
|
operationId: resolveOperationId(program, operation.operation),
|
|
1083
1118
|
bodyRef: rawBody !== undefined
|
|
1084
|
-
? `{\n\t${rawBody}:
|
|
1119
|
+
? `{\n\t${rawBody}: ${rawBodyType};\n}`
|
|
1085
1120
|
: body === undefined
|
|
1086
1121
|
? undefined
|
|
1087
1122
|
: registry.expressionFor(body),
|
|
@@ -1094,7 +1129,7 @@ function collectRequestTypes(program, registry, service) {
|
|
|
1094
1129
|
/**
|
|
1095
1130
|
* The success response every operation declares, so a producer can be held to producing it.
|
|
1096
1131
|
*
|
|
1097
|
-
*
|
|
1132
|
+
* **Pinned fields only - these are PERMISSIVE shapes.** The gateway names the fields it depends on
|
|
1098
1133
|
* and lets a richer view through untouched, so this is a floor and not an equality: the
|
|
1099
1134
|
* domain's result must CONTAIN what the document promises, and may contain more. Asserting equality
|
|
1100
1135
|
* would turn every field a producer legitimately adds into a build failure, which is the same mistake
|
|
@@ -1119,7 +1154,7 @@ function collectResponseTypes(program, registry, service) {
|
|
|
1119
1154
|
return entries;
|
|
1120
1155
|
}
|
|
1121
1156
|
/**
|
|
1122
|
-
* `requests.gen.ts`
|
|
1157
|
+
* `requests.gen.ts` - the shape both sides are checked against.
|
|
1123
1158
|
*
|
|
1124
1159
|
* Plain TypeScript with no imports at all, so the contract types stay framework-free and a layering
|
|
1125
1160
|
* rule banning a validation library from the rest of the codebase is untouched. `wire-contract.gen.ts`
|
|
@@ -1144,13 +1179,13 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
1144
1179
|
if (entry.extraProperties.length > 0) {
|
|
1145
1180
|
parts.push(`{\n${entry.extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
|
|
1146
1181
|
}
|
|
1147
|
-
return (`\n/** The effective input for \`${entry.operationId}\`
|
|
1182
|
+
return (`\n/** The effective input for \`${entry.operationId}\` - its body, with path and query parameters merged over it. */\n` +
|
|
1148
1183
|
`export type ${upperFirst(entry.operationId)}Input = Simplify<${parts.join(" & ")}>;\n`);
|
|
1149
1184
|
});
|
|
1150
1185
|
/**
|
|
1151
1186
|
* The same set again, keyed by operation id.
|
|
1152
1187
|
*
|
|
1153
|
-
* TypeScript cannot index a module's *type* exports by a computed name
|
|
1188
|
+
* TypeScript cannot index a module's *type* exports by a computed name - `typeof Contracts` carries
|
|
1154
1189
|
* only the value side, so `Contracts["AppointOfficerInput"]` does not resolve. Both sides'
|
|
1155
1190
|
* assertions are written as mapped types over every operation, and this is what makes that
|
|
1156
1191
|
* possible. Generated with the aliases above, so it cannot fall behind them.
|
|
@@ -1166,7 +1201,7 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
1166
1201
|
* Flattens an intersection into a single object type.
|
|
1167
1202
|
*
|
|
1168
1203
|
* Without it, \`Body & { filingId: string }\` and the equivalent flat object are structurally the
|
|
1169
|
-
* same but are NOT equal under the \`Equals<>\` conditional-type trick
|
|
1204
|
+
* same but are NOT equal under the \`Equals<>\` conditional-type trick - so the assertions that pair
|
|
1170
1205
|
* this file with the emitted Zod would fail on correct code.
|
|
1171
1206
|
*/
|
|
1172
1207
|
type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
@@ -1175,7 +1210,7 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
|
1175
1210
|
* The same shape, tolerant of \`readonly\`.
|
|
1176
1211
|
*
|
|
1177
1212
|
* A producer's result is asserted assignable to its published shape, and a codebase commonly declares
|
|
1178
|
-
* views with \`readonly\` properties and \`readonly T[]\` arrays
|
|
1213
|
+
* views with \`readonly\` properties and \`readonly T[]\` arrays - neither of which is assignable to a
|
|
1179
1214
|
* mutable one. Mutability is not a fact about a wire shape: it says nothing about what a caller
|
|
1180
1215
|
* receives. Making the TARGET readonly accepts both, which is the permissive direction; the reverse
|
|
1181
1216
|
* would reject every view a producer actually returns.
|
|
@@ -1186,7 +1221,7 @@ type Produced<T> = T extends readonly (infer Element)[]
|
|
|
1186
1221
|
? { readonly [K in keyof T]: Produced<T[K]> }
|
|
1187
1222
|
: T;
|
|
1188
1223
|
${declarations.join("")}${inputs.join("")}
|
|
1189
|
-
/** Every operation's effective input, keyed by operation id
|
|
1224
|
+
/** Every operation's effective input, keyed by operation id - what both sides assert against. */
|
|
1190
1225
|
export interface WireInputs {
|
|
1191
1226
|
${lookup}
|
|
1192
1227
|
}
|
|
@@ -1194,9 +1229,9 @@ ${lookup}
|
|
|
1194
1229
|
/**
|
|
1195
1230
|
* Every operation's success response, keyed by operation id.
|
|
1196
1231
|
*
|
|
1197
|
-
*
|
|
1232
|
+
* **A floor, not an equality.** These shapes are permissive: a caller pins the fields it depends
|
|
1198
1233
|
* on and lets a richer view through, so a producer's result must CONTAIN what the document promises
|
|
1199
|
-
* and may contain more. This is what makes a published response checkable at all
|
|
1234
|
+
* and may contain more. This is what makes a published response checkable at all - measured once, a
|
|
1200
1235
|
* route advertised a field it had never returned, and nothing anywhere could see it.
|
|
1201
1236
|
*/
|
|
1202
1237
|
export interface WireOutputs {
|
|
@@ -1207,23 +1242,23 @@ ${responseLookup}
|
|
|
1207
1242
|
/**
|
|
1208
1243
|
* The validator's half of the wire contract: the emitted Zod must infer **exactly** the shared type.
|
|
1209
1244
|
*
|
|
1210
|
-
* Two artefacts describe one contract
|
|
1211
|
-
* passes around
|
|
1245
|
+
* Two artefacts describe one contract - the runtime validator and the framework-free type a codebase
|
|
1246
|
+
* passes around - and a shared declaration compared against only one of them lets the other drift
|
|
1212
1247
|
* into it unnoticed. This asserts the validator *produces* the shared shape; a consumer that also
|
|
1213
1248
|
* asserts its own code *accepts* that shape has both directions pinned.
|
|
1214
1249
|
*
|
|
1215
|
-
*
|
|
1250
|
+
* This is also the only thing that catches the emitter disagreeing with **itself**. `zod.ts` and
|
|
1216
1251
|
* `types.ts` walk the same TypeSpec twice, once to Zod and once to TypeScript, and a construct one
|
|
1217
1252
|
* handles differently from the other produces two artefacts that describe different shapes while
|
|
1218
1253
|
* both look right in isolation. That happened during this emitter's first run: a vocabulary in a
|
|
1219
1254
|
* query-parameter position was inlined by one walk and referenced by the other.
|
|
1220
1255
|
*
|
|
1221
|
-
* `Equals` here, not assignability
|
|
1256
|
+
* `Equals` here, not assignability - `z.infer` is the output type, and the wire type is emitted with
|
|
1222
1257
|
* defaulted properties required for exactly that reason, so the two really are equal.
|
|
1223
1258
|
*/
|
|
1224
1259
|
function renderWireAssertions(schemas, types, entries, contractsPackage) {
|
|
1225
1260
|
// The assertions pair the emitted Zod against the request TYPES, which live in the contracts
|
|
1226
|
-
// package. With no package named there is nothing to import them from and nothing to pair
|
|
1261
|
+
// package. With no package named there is nothing to import them from and nothing to pair - the
|
|
1227
1262
|
// types are still written to `contracts-output-dir`, they simply have no shared specifier yet.
|
|
1228
1263
|
if (contractsPackage === undefined)
|
|
1229
1264
|
return undefined;
|
|
@@ -1269,7 +1304,7 @@ ${assertions.join("\n")}
|
|
|
1269
1304
|
`;
|
|
1270
1305
|
}
|
|
1271
1306
|
/**
|
|
1272
|
-
* `vocabularies.gen.ts`
|
|
1307
|
+
* `vocabularies.gen.ts` - every vocabulary the surface reaches, as runtime tuples.
|
|
1273
1308
|
*
|
|
1274
1309
|
* Keyed by enum name rather than emitted as individually-named tuples, because the constant names a
|
|
1275
1310
|
* consumer wants are English plurals and some of them are irregular. An emitter that pluralises is an
|
|
@@ -1282,9 +1317,9 @@ function renderVocabularies(vocabularies) {
|
|
|
1282
1317
|
.map(([name, values]) => `\t${name}: [${values.map((v) => JSON.stringify(v)).join(", ")}],`);
|
|
1283
1318
|
return `${GENERATED_BANNER}
|
|
1284
1319
|
/**
|
|
1285
|
-
* The spec's vocabularies
|
|
1320
|
+
* The spec's vocabularies - every enum the surface reaches, and what each one contains.
|
|
1286
1321
|
*
|
|
1287
|
-
* \`as const\` so each is a readonly tuple of literals
|
|
1322
|
+
* \`as const\` so each is a readonly tuple of literals - the type every consumer derives with
|
|
1288
1323
|
* \`(typeof X)[number]\` keeps working unchanged.
|
|
1289
1324
|
*/
|
|
1290
1325
|
export const SPEC_VOCABULARIES = {
|
|
@@ -1297,7 +1332,7 @@ export const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
|
|
|
1297
1332
|
/**
|
|
1298
1333
|
* Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
|
|
1299
1334
|
*
|
|
1300
|
-
*
|
|
1335
|
+
* **The whole orchestration lives here, once.** The nesting below is load-bearing - a walk that
|
|
1301
1336
|
* runs outside `collectExternalImports` throws, vocabularies must be collected from what the service
|
|
1302
1337
|
* DECLARES rather than from what the Zod walk happens to touch, and the error body has to be
|
|
1303
1338
|
* registered inside the walk or its validator never reaches `schemas.gen.ts`. An emitter built on this
|
|
@@ -1309,9 +1344,9 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
1309
1344
|
const options = context.options;
|
|
1310
1345
|
const defaultRuntimeModule = wrapper.defaultRuntimeModule ?? DEFAULT_RUNTIME_MODULE;
|
|
1311
1346
|
/**
|
|
1312
|
-
* Each service already projected to the version it currently serves
|
|
1347
|
+
* Each service already projected to the version it currently serves - see `versioning.ts`.
|
|
1313
1348
|
*
|
|
1314
|
-
*
|
|
1349
|
+
* **This used to be a bare `getAllHttpServices`, which is the UNION of every version.** A spec
|
|
1315
1350
|
* that removes an operation in v2 had it mounted anyway, and a spec that removed a property had it
|
|
1316
1351
|
* still required. Both while the document said otherwise.
|
|
1317
1352
|
*/
|
|
@@ -1319,14 +1354,14 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
1319
1354
|
const snapshots = serviceSnapshots(context.program, versioning);
|
|
1320
1355
|
/**
|
|
1321
1356
|
* Resolved once, here, because the walk below is synchronous and this is not. `undefined` when the
|
|
1322
|
-
* optional stream libraries are not installed, which is the normal case
|
|
1357
|
+
* optional stream libraries are not installed, which is the normal case - see `streams.ts`.
|
|
1323
1358
|
*/
|
|
1324
1359
|
const streamModule = await resolveStreamModule();
|
|
1325
1360
|
const emitted = [];
|
|
1326
1361
|
/**
|
|
1327
|
-
*
|
|
1362
|
+
* **One emission per `@service`, with its own registry and its own import collector.**
|
|
1328
1363
|
*
|
|
1329
|
-
* Sharing either across services merges one surface's schemas and imports into the other's file
|
|
1364
|
+
* Sharing either across services merges one surface's schemas and imports into the other's file -
|
|
1330
1365
|
* an unused import if you are lucky and a missing one if you are not, and a public document
|
|
1331
1366
|
* carrying internal models if you are not paying attention. It is invisible while there is one
|
|
1332
1367
|
* service, which is exactly why it is built this way before the second one lands rather than after.
|
|
@@ -1353,7 +1388,7 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
1353
1388
|
* A closed key SET is a vocabulary too, and it cannot be an enum.
|
|
1354
1389
|
*
|
|
1355
1390
|
* TypeSpec has no key-constrained `Record`, so a model naming its permitted properties is
|
|
1356
|
-
* how the document states one
|
|
1391
|
+
* how the document states one - and a generated type cannot be iterated, which leaves a
|
|
1357
1392
|
* consumer needing the set at run time with a hand-maintained copy. Naming the model in
|
|
1358
1393
|
* `key-vocabularies` writes the keys beside the types. A name matching nothing is
|
|
1359
1394
|
* reported rather than quietly emitting no tuple.
|
|
@@ -1374,7 +1409,7 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
1374
1409
|
* walk follows success bodies and parameters, so without this a runtime would have no
|
|
1375
1410
|
* generated schema to check the documents it sends against.
|
|
1376
1411
|
*
|
|
1377
|
-
*
|
|
1412
|
+
* **Its expression is registered and then deliberately discarded.** It used to be
|
|
1378
1413
|
* returned and used as a fallback for any response arm whose own body could not be
|
|
1379
1414
|
* resolved, which meant every catch-all answered with whichever shape `errorBodyOf` had
|
|
1380
1415
|
* settled on rather than the one its own response declares. Each arm resolves its own
|
|
@@ -1389,9 +1424,9 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
1389
1424
|
};
|
|
1390
1425
|
})));
|
|
1391
1426
|
/**
|
|
1392
|
-
*
|
|
1427
|
+
* **Named AFTER the walk and BEFORE the file is rendered.** Naming mints identifiers that
|
|
1393
1428
|
* `schemas.gen.ts` must declare, so it has to happen while the registry is complete and before
|
|
1394
|
-
* anything is written
|
|
1429
|
+
* anything is written - and it has to happen HERE rather than in a server emitter, or the
|
|
1395
1430
|
* validators it produces never reach this package's own output.
|
|
1396
1431
|
*/
|
|
1397
1432
|
const { names, declarations } = nameRouteSchemas(routes);
|