typespec-http-zod 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +269 -0
- package/dist/src/api.d.ts +304 -0
- package/dist/src/api.js +1425 -0
- package/dist/src/constraints.d.ts +38 -0
- package/dist/src/constraints.js +68 -0
- package/dist/src/emitter.d.ts +13 -0
- package/dist/src/emitter.js +15 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/lib.d.ts +181 -0
- package/dist/src/lib.js +172 -0
- package/dist/src/registry.d.ts +69 -0
- package/dist/src/registry.js +421 -0
- package/dist/src/runtime.d.ts +84 -0
- package/dist/src/runtime.js +25 -0
- package/dist/src/streams.d.ts +40 -0
- package/dist/src/streams.js +40 -0
- package/dist/src/tsp-index.d.ts +12 -0
- package/dist/src/tsp-index.js +12 -0
- package/dist/src/types.d.ts +10 -0
- package/dist/src/types.js +254 -0
- package/dist/src/versioning.d.ts +58 -0
- package/dist/src/versioning.js +57 -0
- package/dist/src/zod.d.ts +124 -0
- package/dist/src/zod.js +1015 -0
- package/lib/main.tsp +4 -0
- package/package.json +94 -0
package/dist/src/api.js
ADDED
|
@@ -0,0 +1,1425 @@
|
|
|
1
|
+
import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, resolvePath, } from "@typespec/compiler";
|
|
2
|
+
import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
|
|
3
|
+
import { resolveOperationId } from "@typespec/openapi";
|
|
4
|
+
import { reportDiagnostic } from "./lib.js";
|
|
5
|
+
import { SchemaRegistry, TypeRegistry } from "./registry.js";
|
|
6
|
+
import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
|
|
7
|
+
import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
|
|
8
|
+
import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
|
|
9
|
+
/**
|
|
10
|
+
* `typespec-http-zod` — Zod validators and framework-free contract types from a TypeSpec HTTP
|
|
11
|
+
* service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
|
|
12
|
+
*
|
|
13
|
+
* **The governing rule: if the document cannot say it, the validator must not enforce it.** A
|
|
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 — four were
|
|
16
|
+
* deleted, each of which let a spec state something openapi3 could not publish — and asserts the
|
|
17
|
+
* class is empty rather than trusting it. Where openapi3 decides something, its rule is read from its
|
|
18
|
+
* source and copied, refusals included: the same spec has to be representable by both emitters or by
|
|
19
|
+
* neither, or a differential between them means nothing.
|
|
20
|
+
*
|
|
21
|
+
* **Why not `@typespec/http-server-js`.** The first-party JS server emitter is `0.58.0-alpha.*`,
|
|
22
|
+
* self-describes as "highly experimental", and emits a Node.js `http` router. It also answers a
|
|
23
|
+
* different question: this package emits no server at all. Validators are useful to Express, Fastify,
|
|
24
|
+
* Elysia, a Workers fetch handler or a typed client, and a server generator is a thin consumer of
|
|
25
|
+
* them — `typespec-hono` is one.
|
|
26
|
+
*
|
|
27
|
+
* **Built on the stable 1.x surface only** — `$onEmit` plus `@typespec/http` — rather than on
|
|
28
|
+
* `@typespec/emitter-framework` (pre-1.0) or `@typespec/asset-emitter`, which announces its own
|
|
29
|
+
* replacement in its package description. Fewer moving parts under something that has to keep working.
|
|
30
|
+
*/
|
|
31
|
+
export { $lib } from "./lib.js";
|
|
32
|
+
/**
|
|
33
|
+
* The option contract, published so an emitter built on this library can **derive** its own rather
|
|
34
|
+
* than restate it.
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ **Two schemas that have to agree are two lists that will not.** A server generator adds its own
|
|
37
|
+
* keys and forwards the rest; if it restates this one, an option added here is silently rejected as
|
|
38
|
+
* unknown by the wrapper, or accepted and never passed on. Neither failure is visible in output.
|
|
39
|
+
*/
|
|
40
|
+
export { EmitterOptionsSchema } from "./lib.js";
|
|
41
|
+
/**
|
|
42
|
+
* How a property name is spelled as an object key — quoted only when it has to be.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ **Published because a consumer emitting code beside these validators has to spell keys the
|
|
45
|
+
* SAME way, and getting it wrong produces a file that does not parse.** An unquoted
|
|
46
|
+
* `x-ms-test-header` is exactly that, and it happened: a corpus scenario declares one and the
|
|
47
|
+
* emitted module stopped being loadable. This is not a style choice a second emitter may make
|
|
48
|
+
* independently.
|
|
49
|
+
*/
|
|
50
|
+
export { objectKey } from "./zod.js";
|
|
51
|
+
/** Named once. The emitted banner and the internal-invariant messages both quote it. */
|
|
52
|
+
const PACKAGE_NAME = "typespec-http-zod";
|
|
53
|
+
/**
|
|
54
|
+
* The header every emitted file carries.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ **This once named one repository's build script** — `pnpm --filter … generate`, a command that
|
|
57
|
+
* exists in exactly one checkout. A generated file is the most-read artefact an emitter produces, and
|
|
58
|
+
* a banner sending the reader to a script they do not have is worse than no instruction at all.
|
|
59
|
+
*
|
|
60
|
+
* The package name stays, because that IS the answer to "what wrote this". The instruction becomes
|
|
61
|
+
* the one thing true for every consumer: recompile the source, do not edit the output.
|
|
62
|
+
*/
|
|
63
|
+
const GENERATED_BANNER = `// GENERATED by ${PACKAGE_NAME} from the TypeSpec service definition. DO NOT EDIT.
|
|
64
|
+
// Recompile the spec that produced it; edits here are overwritten on the next run.
|
|
65
|
+
`;
|
|
66
|
+
/**
|
|
67
|
+
* The success status a row registers.
|
|
68
|
+
*
|
|
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 — an
|
|
71
|
+
* operation carrying only those is skipped rather than guessed at.
|
|
72
|
+
*/
|
|
73
|
+
export function successStatusOf(operation) {
|
|
74
|
+
return successStatusesOf(operation)[0];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* **Every** success status the operation declares, ascending.
|
|
78
|
+
*
|
|
79
|
+
* An operation that answers `202` when the work was merely accepted and `200` when it completed
|
|
80
|
+
* inline declares both, and something has to choose. A route with two declared statuses and no way to
|
|
81
|
+
* choose would always answer the first, publishing a status that can never occur; the arm carrying
|
|
82
|
+
* asserts the two stay in step.
|
|
83
|
+
*/
|
|
84
|
+
export function successStatusesOf(operation) {
|
|
85
|
+
const codes = [];
|
|
86
|
+
for (const response of operation.responses) {
|
|
87
|
+
const code = response.statusCodes;
|
|
88
|
+
if (typeof code === "number" && code >= 200 && code < 300)
|
|
89
|
+
codes.push(code);
|
|
90
|
+
}
|
|
91
|
+
return [...new Set(codes)].toSorted((a, b) => a - b);
|
|
92
|
+
}
|
|
93
|
+
/** The five buckets OpenAPI can express a status RANGE as, in the order it names them. */
|
|
94
|
+
const STATUS_RANGE_GROUPS = [1, 2, 3, 4, 5];
|
|
95
|
+
/**
|
|
96
|
+
* What the document will key this response under — **openapi3's rule, copied, not re-derived.**
|
|
97
|
+
*
|
|
98
|
+
* ⚠️ **`HttpStatusCodesEntry` is `HttpStatusCodeRange | number | "*"`, and the third case was
|
|
99
|
+
* silently dropped.** A response declared `@minValue(400) @maxValue(499) @statusCode _: int32` is a
|
|
100
|
+
* `4XX` entry in the document and was emitted NOWHERE here: not refused, not warned about, just
|
|
101
|
+
* absent from the arms — so a service could publish a whole class of failures and hand `respond`
|
|
102
|
+
* nothing to check them against. Two of the two operations in `response/status-code-range` lost an
|
|
103
|
+
* arm each this way, and the emitter's own `mixed-error-bodies` refusal was firing over the same
|
|
104
|
+
* scenario for an unrelated and by then obsolete reason, which is how it stayed hidden.
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ **A partial range is REFUSED, because `@typespec/openapi3` refuses it.** `494-499` does not
|
|
107
|
+
* cover the `4XX` bucket, and bucketing it anyway would have the server claim that a `400` carries
|
|
108
|
+
* `ErrorInRange` — a rule no document states, since openapi3 declines to publish a document at all.
|
|
109
|
+
* Agreeing with the reference implementation is the whole point of copying its line: the same spec
|
|
110
|
+
* is either representable by both or by neither.
|
|
111
|
+
*
|
|
112
|
+
* `@defaultResponse` is not tested separately the way openapi3 tests it; `@typespec/http` resolves
|
|
113
|
+
* that decorator to `"*"` before we see it, so the one check covers both spellings.
|
|
114
|
+
*/
|
|
115
|
+
function statusKeysOf(program, response) {
|
|
116
|
+
const codes = response.statusCodes;
|
|
117
|
+
if (codes === "*")
|
|
118
|
+
return ["default"];
|
|
119
|
+
if (typeof codes === "number")
|
|
120
|
+
return [codes];
|
|
121
|
+
const keys = [];
|
|
122
|
+
let { start, end } = codes;
|
|
123
|
+
const refuse = () => {
|
|
124
|
+
reportDiagnostic(program, {
|
|
125
|
+
code: "unsupported-status-code-range",
|
|
126
|
+
target: response.type,
|
|
127
|
+
format: { start: String(codes.start), end: String(codes.end) },
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
if (start < 100) {
|
|
131
|
+
refuse();
|
|
132
|
+
start = 100;
|
|
133
|
+
keys.push("default");
|
|
134
|
+
}
|
|
135
|
+
else if (end > 599) {
|
|
136
|
+
refuse();
|
|
137
|
+
keys.push("default");
|
|
138
|
+
end = 599;
|
|
139
|
+
}
|
|
140
|
+
for (const group of STATUS_RANGE_GROUPS) {
|
|
141
|
+
if (start > end)
|
|
142
|
+
break;
|
|
143
|
+
const groupStart = group * 100;
|
|
144
|
+
const groupEnd = groupStart + 99;
|
|
145
|
+
if (start >= groupStart && start <= groupEnd) {
|
|
146
|
+
keys.push(`${group}XX`);
|
|
147
|
+
if (start !== groupStart || end < groupEnd)
|
|
148
|
+
refuse();
|
|
149
|
+
start = groupStart + 100;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return keys;
|
|
153
|
+
}
|
|
154
|
+
/** Whether a document status key describes a SUCCESS, so the success arms already carry it. */
|
|
155
|
+
function isSuccessKey(status) {
|
|
156
|
+
return status === "2XX" || (typeof status === "number" && status >= 200 && status < 300);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Every FAILURE arm the operation declares, each with the schema for ITS OWN body.
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ **None of these reached the generated output at all, and a census is the proof.** Measured on one
|
|
162
|
+
* service, the document declared `200`x9, `201`x3, `202`x1, `401`x1 and `default`x2, and only the
|
|
163
|
+
* first three were emitted. So the schema for a failure never arrived, and no application could have
|
|
164
|
+
* checked what it sends when something goes wrong even if it wanted to.
|
|
165
|
+
*
|
|
166
|
+
* ⚠️ **Then every arm that did arrive carried the SERVICE-WIDE error schema.** The spike declares
|
|
167
|
+
* `@error model WebhookUnauthorized { ...ProblemDetails; @statusCode 401 }` and openapi3 publishes
|
|
168
|
+
* the 401 as `$ref: WebhookUnauthorized` while we answered with `problemDetailsSchema`. The shapes
|
|
169
|
+
* were identical there, so nothing was mis-validated — but the component the document names for that
|
|
170
|
+
* response had no declaration at all.
|
|
171
|
+
*
|
|
172
|
+
* ⚠️ **`"default"` used to be the one arm with no body of its own**, filled in downstream from the
|
|
173
|
+
* service-wide schema. That was backwards: a catch-all response declares a body like any other
|
|
174
|
+
* response, and `response/status-code-range` gives two operations whose `default` is a DIFFERENT
|
|
175
|
+
* model from every other failure they declare. Reading each response's own body answers all three at
|
|
176
|
+
* once, and is why the emitter no longer needs to believe a service has one error shape.
|
|
177
|
+
*
|
|
178
|
+
* Arms come out in the order OpenAPI resolves them — exact codes, then ranges, then the catch-all —
|
|
179
|
+
* so a consumer scanning for the first match applies the precedence the specification states.
|
|
180
|
+
*/
|
|
181
|
+
function errorArmsOf(program, operation, registry) {
|
|
182
|
+
const arms = new Map();
|
|
183
|
+
for (const response of operation.responses) {
|
|
184
|
+
for (const status of statusKeysOf(program, response)) {
|
|
185
|
+
if (isSuccessKey(status) || arms.has(status))
|
|
186
|
+
continue;
|
|
187
|
+
arms.set(status, bodySchemaOf(response, registry));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return [...arms]
|
|
191
|
+
.map(([status, schema]) => ({ status, schema }))
|
|
192
|
+
.toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Where an arm sits in OpenAPI's own precedence order.
|
|
196
|
+
*
|
|
197
|
+
* ⚠️ **The specification decides this, not us.** The Responses Object says a range MAY be given as
|
|
198
|
+
* an uppercase `X` wildcard, and that "if a response is defined using an explicit code, the explicit
|
|
199
|
+
* code definition takes precedence over the range definition for that code". `default` is by
|
|
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 — the alternative is every application
|
|
202
|
+
* re-deriving the rule, and getting it wrong quietly when a range and an exact code both apply.
|
|
203
|
+
*/
|
|
204
|
+
function statusPrecedenceOf(status) {
|
|
205
|
+
if (status === "default")
|
|
206
|
+
return 2;
|
|
207
|
+
return typeof status === "number" ? 0 : 1;
|
|
208
|
+
}
|
|
209
|
+
/** A response's own body, as an emitted Zod expression — `undefined` where it declares none. */
|
|
210
|
+
function bodySchemaOf(response, registry) {
|
|
211
|
+
for (const content of response.responses) {
|
|
212
|
+
if (content.body?.bodyKind !== "single")
|
|
213
|
+
continue;
|
|
214
|
+
const type = bodyTypeOf(content.body.type);
|
|
215
|
+
if (type !== undefined)
|
|
216
|
+
return registry.expressionFor(type);
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Whether the operation declares `@useAuth(NoAuth)` — no caller is established for it.
|
|
222
|
+
*
|
|
223
|
+
* ⚠️ **`@useAuth(NoAuth)` reaches OpenAPI as `security: []`, which is why this is derivable at all.**
|
|
224
|
+
* Whether an operation needs a caller is a contract fact the document publishes. What KIND of caller
|
|
225
|
+
* — account-scoped, resource-scoped — is not: no OpenAPI keyword expresses it, and a server emitter
|
|
226
|
+
* that guesses is enforcing something no document states.
|
|
227
|
+
*/
|
|
228
|
+
function noAuthFor(program, operation) {
|
|
229
|
+
return (getAuthenticationForOperation(program, operation.operation)?.options.some((option) => option.schemes.some((scheme) => scheme.type === "noAuth")) === true);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The body every `@error` response in the service declares, when they all declare the same one.
|
|
233
|
+
*
|
|
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
|
+
* CM-shaped special case. RFC 9457 happens to be what `cm-api` puts in that slot.
|
|
238
|
+
*
|
|
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
|
+
* catch-all bodies, or a specific status whose body neither was nor spread the catch-all one. That
|
|
242
|
+
* was a real constraint while every failure arm carried this single schema; now each declared
|
|
243
|
+
* response resolves its own, and a service answering `404` with `NotFoundError` and everything else
|
|
244
|
+
* with `DefaultError` is an ordinary contract that the generated server serves correctly.
|
|
245
|
+
*
|
|
246
|
+
* So the refusal is gone, and what remains is a convenience: the module-level `GENERATED_ERROR_SCHEMA`
|
|
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 — and a consumer importing it gets a missing export, named at the
|
|
249
|
+
* import, which is the honest report of "there is no such thing here".
|
|
250
|
+
*/
|
|
251
|
+
export function errorBodyOf(program, service) {
|
|
252
|
+
const canonical = new Set();
|
|
253
|
+
const specific = new Set();
|
|
254
|
+
for (const operation of service.operations) {
|
|
255
|
+
for (const response of operation.responses) {
|
|
256
|
+
/**
|
|
257
|
+
* ⚠️ `@error` sits on the RESPONSE type, which is not always the body type.
|
|
258
|
+
*
|
|
259
|
+
* Two declaration styles are both idiomatic and both occur. One returns the error model bare
|
|
260
|
+
* (`op x(): Foo | ProblemDetails`), so response type and body type are the same; the other wraps
|
|
261
|
+
* it (`@error model ErrorResponse { @statusCode …; @body body: Problem }`), so the `@error` is
|
|
262
|
+
* on the wrapper and the body is a plain model. Testing the body missed the second style
|
|
263
|
+
* entirely — measured on a surface declaring an error on all
|
|
264
|
+
* eleven operations and this found none of them.
|
|
265
|
+
*/
|
|
266
|
+
if (!isErrorModel(program, response.type))
|
|
267
|
+
continue;
|
|
268
|
+
for (const content of response.responses) {
|
|
269
|
+
const body = content.body;
|
|
270
|
+
if (body?.bodyKind !== "single")
|
|
271
|
+
continue;
|
|
272
|
+
/**
|
|
273
|
+
* ⚠️ **An error response can declare no body either.** `tsp-openapi3` renders a bare
|
|
274
|
+
* `default` response as `DefaultResponse<Body = void>`, so the Swagger Petstore reaches
|
|
275
|
+
* here with the `void` intrinsic — which was then registered as the service's error schema
|
|
276
|
+
* and refused by the Zod walk. There is nothing there to validate.
|
|
277
|
+
*/
|
|
278
|
+
const type = bodyTypeOf(body.type);
|
|
279
|
+
if (type === undefined)
|
|
280
|
+
continue;
|
|
281
|
+
// `"*"` is what `@error` resolves to when the model declares no status — the operation's
|
|
282
|
+
// general "any failure" contract, and the shape the runtime constructs.
|
|
283
|
+
(response.statusCodes === "*" ? canonical : specific).add(type);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Two catch-all bodies means there is no "the" service-wide error body, so there is nothing to
|
|
289
|
+
* publish — which is a reason to emit no constant, not a defect to refuse. Each operation's own
|
|
290
|
+
* `default` arm carries its own schema, and that is what the server validates against.
|
|
291
|
+
*/
|
|
292
|
+
if (canonical.size > 1)
|
|
293
|
+
return undefined;
|
|
294
|
+
const [body] = canonical;
|
|
295
|
+
if (body !== undefined)
|
|
296
|
+
return body;
|
|
297
|
+
/**
|
|
298
|
+
* A service can decline to declare a catch-all and still have one error shape — ten specific
|
|
299
|
+
* statuses carrying one problem model between them. That is a service-wide
|
|
300
|
+
* body by any honest reading. Two different ones is not, and picking the first would be naming a
|
|
301
|
+
* contract fact by iteration order.
|
|
302
|
+
*/
|
|
303
|
+
return specific.size === 1 ? [...specific][0] : undefined;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* A response's body type, or `undefined` where it declares no body.
|
|
307
|
+
*
|
|
308
|
+
* ⚠️ **`void` and `never` ARE "no body", wherever they appear.** Answering that in one place is the
|
|
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 — the road that
|
|
311
|
+
* actually broke the Swagger Petstore — `tsp-openapi3` writing a `default` error response as
|
|
312
|
+
* `DefaultResponse<Body = void>`. Returned as a type, any of them is walked and refused with
|
|
313
|
+
* `Cannot emit Zod for Intrinsic: 'void' has no runtime representation'`.
|
|
314
|
+
*/
|
|
315
|
+
function bodyTypeOf(type) {
|
|
316
|
+
return isVoidType(type) || isNeverType(type) ? undefined : type;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The declared body type of the operation's success response, if it has one.
|
|
320
|
+
*
|
|
321
|
+
* ⚠️ **`void` and `never` ARE "no body", and reading them as types was a defect.** A response with
|
|
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` — which is what `tsp-openapi3` writes when it converts a `204`,
|
|
324
|
+
* and what `op remove(): void` means. The second used to be returned as a type, walked, and refused
|
|
325
|
+
* with `Cannot emit Zod for Intrinsic: 'void' has no runtime representation'`. That took out the
|
|
326
|
+
* Swagger Petstore, the most widely implemented reference API there is, on the round-trip suite's
|
|
327
|
+
* first run.
|
|
328
|
+
*
|
|
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 — `collectRoutes`, the contract types, the dual-status
|
|
331
|
+
* discriminator and the alternate-arm schema all want the same answer to the same question.
|
|
332
|
+
*/
|
|
333
|
+
function successBodyOf(operation, status) {
|
|
334
|
+
for (const response of operation.responses) {
|
|
335
|
+
if (response.statusCodes !== status)
|
|
336
|
+
continue;
|
|
337
|
+
for (const content of response.responses) {
|
|
338
|
+
if (content.body?.bodyKind !== "single")
|
|
339
|
+
continue;
|
|
340
|
+
return bodyTypeOf(content.body.type);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The media types this operation's success response can be served as.
|
|
347
|
+
*
|
|
348
|
+
* ⚠️ **The fact that decides whether two operations are a collision or a CHOICE.** TypeSpec models
|
|
349
|
+
* content negotiation as several operations on one route, each accepting a different media type;
|
|
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 — which matches in registration order — left all but the
|
|
352
|
+
* first as dead code that looks mounted.
|
|
353
|
+
*
|
|
354
|
+
* Empty when the response carries no body, which is not the same as a body served as anything.
|
|
355
|
+
*/
|
|
356
|
+
function responseContentTypesOf(operation, status) {
|
|
357
|
+
const types = new Set();
|
|
358
|
+
for (const response of operation.responses) {
|
|
359
|
+
if (response.statusCodes !== status)
|
|
360
|
+
continue;
|
|
361
|
+
for (const content of response.responses) {
|
|
362
|
+
for (const contentType of content.body?.contentTypes ?? [])
|
|
363
|
+
types.add(contentType);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return [...types];
|
|
367
|
+
}
|
|
368
|
+
/** Whether a type is an array — TypeSpec models one as a `Model` carrying an integer indexer. */
|
|
369
|
+
function isArrayType(type) {
|
|
370
|
+
return type.kind === "Model" && type.indexer?.key.name === "integer";
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* How to choose between an operation's two success statuses, read from the declared arms.
|
|
374
|
+
*
|
|
375
|
+
* ⚠️ **A discriminator, not a trial-parse.** "Try the 200 schema, and if it fails use the 202" is the
|
|
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 — and a consumer reads that one property.
|
|
379
|
+
*
|
|
380
|
+
* This replaces hand-written per-operation closures, which existed only because a spec declaring the
|
|
381
|
+
* SAME body model for both statuses could not say which was which.
|
|
382
|
+
*/
|
|
383
|
+
function statusDiscriminatorOf(operation, statuses) {
|
|
384
|
+
if (statuses.length < 2)
|
|
385
|
+
return undefined;
|
|
386
|
+
for (const status of statuses) {
|
|
387
|
+
const body = successBodyOf(operation, status);
|
|
388
|
+
if (body?.kind !== "Model")
|
|
389
|
+
continue;
|
|
390
|
+
for (const [name, property] of body.properties) {
|
|
391
|
+
// The OTHER arm carries the same property as optional; a required literal is the mark.
|
|
392
|
+
if (property.optional)
|
|
393
|
+
continue;
|
|
394
|
+
if (property.type.kind === "Boolean" || property.type.kind === "String") {
|
|
395
|
+
return { property: name, value: property.type.value, status };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
/** The emitted schema for a specific success status, used for the second arm of a dual-status op. */
|
|
402
|
+
function alternateSchemaFor(operation, status, registry) {
|
|
403
|
+
const body = successBodyOf(operation, status);
|
|
404
|
+
return body === undefined ? undefined : registry.expressionFor(body);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* The Zod for a **multipart** request body, built from its parts.
|
|
408
|
+
*
|
|
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
|
+
* components the document declares that nothing here could check. Not a refusal, not a diagnostic:
|
|
412
|
+
* silently unvalidated, which is the failure mode this emitter exists to remove.
|
|
413
|
+
*
|
|
414
|
+
* `HttpPart<T>` is unwrapped to `T`, which is what the document publishes — `HttpPart<string>` is
|
|
415
|
+
* `{type: "string"}` and `HttpPart<Address>` is a `$ref`.
|
|
416
|
+
*
|
|
417
|
+
* ⚠️ **A binary part is `z.unknown()`, deliberately.** `SCALARS` maps `bytes` to `z.string()`, which
|
|
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 — no type at all. Emitting `z.string()` would enforce a rule the
|
|
420
|
+
* document does not state AND reject what a server actually receives, since `c.req.parseBody()`
|
|
421
|
+
* hands back a `File`. The type of a value is positional, exactly as its visibility is.
|
|
422
|
+
*/
|
|
423
|
+
function multipartSchemaOf(body, registry) {
|
|
424
|
+
const entries = body.parts.map((part) => {
|
|
425
|
+
const type = part.body?.type;
|
|
426
|
+
const inner = type === undefined || isBinaryPart(type) ? "z.unknown()" : registry.expressionFor(type);
|
|
427
|
+
// `multi` is `HttpPart<T>[]` — the same part repeated, which arrives as an array.
|
|
428
|
+
const value = part.multi === true ? `z.array(${inner})` : inner;
|
|
429
|
+
return `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.optional()` : value},`;
|
|
430
|
+
});
|
|
431
|
+
const shape = entries.length === 0 ? "z.object({})" : `z.object({\n${entries.join("\n")}\n})`;
|
|
432
|
+
/**
|
|
433
|
+
* Declared under the model's own name when it has one, because the document publishes a component
|
|
434
|
+
* for it. Emitted inline the schema is correct and the component still has no validator, which the
|
|
435
|
+
* differential reports — rightly — as a contract the runtime cannot check.
|
|
436
|
+
*/
|
|
437
|
+
const named = body.type;
|
|
438
|
+
const name = named !== undefined && named.kind === "Model" && named.name !== "" ? named.name : undefined;
|
|
439
|
+
return name === undefined ? shape : registry.declareNamed(name, () => shape);
|
|
440
|
+
}
|
|
441
|
+
/** `bytes`, or a model built on `File` — the parts openapi3 publishes as an empty schema. */
|
|
442
|
+
function isBinaryPart(type) {
|
|
443
|
+
if (type.kind === "Scalar") {
|
|
444
|
+
for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
|
|
445
|
+
if (scalar.name === "bytes")
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
if (type.kind !== "Model")
|
|
451
|
+
return false;
|
|
452
|
+
for (let model = type; model !== undefined; model = model.baseModel) {
|
|
453
|
+
if (model.name === "File")
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Whether a body served under these media types is RAW BINARY rather than a JSON value.
|
|
460
|
+
*
|
|
461
|
+
* ⚠️ **The distinction decides whether a validator may say anything about it at all.** A `bytes`
|
|
462
|
+
* body inside a JSON payload is base64 and the document publishes `type: "string"` with
|
|
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` — which is
|
|
465
|
+
* an ANNOTATION under JSON Schema 2020-12, not an assertion. The document therefore states nothing
|
|
466
|
+
* about the value, and a validator that requires a string enforces a rule no caller reading the
|
|
467
|
+
* contract can see.
|
|
468
|
+
*
|
|
469
|
+
* ⚠️ **The same decision was already made for multipart and not for bodies**, which is how the two
|
|
470
|
+
* came to disagree: a binary PART is `z.unknown()` because openapi3 publishes `{}` for it, while a
|
|
471
|
+
* binary BODY stayed `z.string()`. Found by grading response-body kinds, a surface that had been
|
|
472
|
+
* counted and never compared — three positions in `encode/bytes`.
|
|
473
|
+
*/
|
|
474
|
+
export function isRawBinaryMediaType(contentTypes) {
|
|
475
|
+
if (contentTypes.length === 0)
|
|
476
|
+
return false;
|
|
477
|
+
return contentTypes.every((type) => !/^application\/(json|.*\+json)$/.test(type));
|
|
478
|
+
}
|
|
479
|
+
/** The declared request body type, if the operation takes one. */
|
|
480
|
+
function requestBodyOf(operation) {
|
|
481
|
+
const body = operation.parameters.body;
|
|
482
|
+
return body?.bodyKind === "single" ? body.type : undefined;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
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 — but they are equally part of the operation's input,
|
|
487
|
+
* and `?state=nonsense` must be a 400 rather than something the application has to defend against.
|
|
488
|
+
* Leaving them undescribed would document a constraint the runtime does not enforce, which is the
|
|
489
|
+
* same failure the vocabulary tuples exist to prevent one level down.
|
|
490
|
+
*
|
|
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
|
+
* wrong instead of being handed a merged object's complaint. A merged form existed beside these until
|
|
494
|
+
* 0.1.0 and was deleted: nothing read it, and it had drifted into a second set of rules. See
|
|
495
|
+
* `EmittedRoute.pathSchema`.
|
|
496
|
+
*/
|
|
497
|
+
/**
|
|
498
|
+
* How a list is flattened into one query or header value, per OpenAPI's `style`.
|
|
499
|
+
*
|
|
500
|
+
* ⚠️ **Three of the four standard collection formats rejected every conformant request**, and only
|
|
501
|
+
* one of the three was visible to the differential. `?colors=blue,red,green` arrives as a single
|
|
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 — but for
|
|
504
|
+
* `csv` both sides said `array` and **agreed perfectly** while the server 400'd every caller. No
|
|
505
|
+
* document-to-validator comparison can see that: the disagreement is with the WIRE.
|
|
506
|
+
*
|
|
507
|
+
* `explode: true` is the exception — the value repeats (`?colors=blue&colors=red`) and Hono hands
|
|
508
|
+
* back an array already, so there is nothing to undo.
|
|
509
|
+
*/
|
|
510
|
+
const COLLECTION_DELIMITERS = {
|
|
511
|
+
"ArrayEncoding.pipeDelimited": "|",
|
|
512
|
+
"ArrayEncoding.spaceDelimited": " ",
|
|
513
|
+
"ArrayEncoding.commaDelimited": ",",
|
|
514
|
+
"ArrayEncoding.newlineDelimited": "\n",
|
|
515
|
+
};
|
|
516
|
+
/** Whether the declared type is a list, which is what makes a delimiter meaningful. */
|
|
517
|
+
function isCollectionParameter(property) {
|
|
518
|
+
return isArrayType(property.type);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* The delimiter a parameter's values were joined with, or `undefined` when they were not joined.
|
|
522
|
+
*
|
|
523
|
+
* Defaults matter as much as the encodings: a query parameter with no `@encode` and no explode is
|
|
524
|
+
* `form` style, which OpenAPI joins with a comma, and a header list is `simple` style, also comma.
|
|
525
|
+
* Those are the two that looked correct because the document and the validator agreed.
|
|
526
|
+
*/
|
|
527
|
+
function collectionDelimiterOf(program, parameter) {
|
|
528
|
+
if (!isCollectionParameter(parameter.param))
|
|
529
|
+
return undefined;
|
|
530
|
+
if (parameter.type !== "query" && parameter.type !== "header")
|
|
531
|
+
return undefined;
|
|
532
|
+
if (parameter.explode === true)
|
|
533
|
+
return undefined;
|
|
534
|
+
const encoding = getEncode(program, parameter.param)?.encoding;
|
|
535
|
+
if (encoding !== undefined && encoding in COLLECTION_DELIMITERS) {
|
|
536
|
+
return COLLECTION_DELIMITERS[encoding];
|
|
537
|
+
}
|
|
538
|
+
return ",";
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Decode a path, query or header value from the only thing HTTP can carry: text.
|
|
542
|
+
*
|
|
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
|
+
* straight to the schema, so `z.number().int()` met `"1"` and refused it. Measured against a server
|
|
546
|
+
* generated from the Swagger Petstore and run under `wrangler dev`:
|
|
547
|
+
*
|
|
548
|
+
* ```
|
|
549
|
+
* GET /pet/findByStatus?status=available -> 200 (string)
|
|
550
|
+
* GET /user/zach -> 200 (string)
|
|
551
|
+
* GET /pet/1 -> 400 (integer) <- every conformant caller
|
|
552
|
+
* GET /store/order/1 -> 400 (integer)
|
|
553
|
+
* ```
|
|
554
|
+
*
|
|
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
|
+
* the corpus this emitter writes **29 numeric query and header parameters** and requests none of
|
|
558
|
+
* them. The validators were right about the document the whole time; the disagreement was with what
|
|
559
|
+
* arrives, which is the one thing no document comparison can see.
|
|
560
|
+
*
|
|
561
|
+
* ⚠️ **This DECODES, it does not coerce.** `z.coerce.number()` would have been one character of
|
|
562
|
+
* effort and wrong: `Number("")` is `0`, so `?limit=` would satisfy a required integer and an empty
|
|
563
|
+
* value the document forbids would sail through as zero. A value that is not a well-formed number is
|
|
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 — decode the wire form, then apply
|
|
566
|
+
* the document's schema to the result, never instead of it.
|
|
567
|
+
*
|
|
568
|
+
* Derived from the emitted expression rather than a restated list of scalar names: `SCALARS` in
|
|
569
|
+
* `zod.ts` decides what becomes `z.number()`, and a second copy of that list here would stop agreeing
|
|
570
|
+
* with it silently.
|
|
571
|
+
*/
|
|
572
|
+
function wireDecoded(declared) {
|
|
573
|
+
const NUMBER = '(raw) => (typeof raw === "string" && raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : raw)';
|
|
574
|
+
const BOOLEAN = '(raw) => (raw === "true" ? true : raw === "false" ? false : raw)';
|
|
575
|
+
if (declared.startsWith("z.number()"))
|
|
576
|
+
return `z.preprocess(${NUMBER}, ${declared})`;
|
|
577
|
+
if (declared.startsWith("z.boolean()"))
|
|
578
|
+
return `z.preprocess(${BOOLEAN}, ${declared})`;
|
|
579
|
+
/**
|
|
580
|
+
* A list whose ELEMENTS are numeric or boolean — `?ids=1,2,3` after the collection split, or a
|
|
581
|
+
* repeated `?id=1&id=2`. The elements are strings for exactly the same reason the scalar is, so
|
|
582
|
+
* the same decoder applies per element. A non-array value is left alone; the split that produces
|
|
583
|
+
* the array runs outside this and may not have happened.
|
|
584
|
+
*/
|
|
585
|
+
const element = /^z\.array\((z\.(?:number|boolean)\(\).*)\)$/s.exec(declared);
|
|
586
|
+
if (element !== null) {
|
|
587
|
+
const inner = element[1] ?? "";
|
|
588
|
+
const decoder = inner.startsWith("z.number()") ? NUMBER : BOOLEAN;
|
|
589
|
+
return `z.preprocess((raw) => (Array.isArray(raw) ? raw.map(${decoder}) : raw), ${declared})`;
|
|
590
|
+
}
|
|
591
|
+
return declared;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Decode a `content-type` header down to the media type the document names.
|
|
595
|
+
*
|
|
596
|
+
* ⚠️ **A media type is a type PLUS parameters, and the document states only the type.** `@header
|
|
597
|
+
* contentType: "multipart/form-data"` becomes `z.literal("multipart/form-data")`, which refuses
|
|
598
|
+
* `multipart/form-data; boundary=…` — and RFC 2046 §5.1.1 makes that boundary MANDATORY, so the
|
|
599
|
+
* validator rejected **every syntactically valid multipart request**. Measured under `wrangler dev`:
|
|
600
|
+
*
|
|
601
|
+
* ```
|
|
602
|
+
* expected "multipart/form-data"
|
|
603
|
+
* received "multipart/form-data; boundary=------------------------IQFk2KVVVwrsWuOSm212u0"
|
|
604
|
+
* ```
|
|
605
|
+
*
|
|
606
|
+
* Across the corpus this emitter writes **78 `z.literal` header validators, every one of them on
|
|
607
|
+
* `content-type`**, in 12 scenarios. Seventeen are `multipart/form-data` and fail 100% of the time;
|
|
608
|
+
* the remaining 61 refuse any caller sending the entirely legal `; charset=utf-8`.
|
|
609
|
+
*
|
|
610
|
+
* ⚠️ **Enforcing "no parameters" is enforcing something the document does not say**, which is the
|
|
611
|
+
* governing rule read in the direction that is easy to miss: the rule forbids inventing constraints
|
|
612
|
+
* just as much as it forbids dropping them. Stripping the parameters before comparing is the same
|
|
613
|
+
* move as decoding `"1"` to `1` — undo the transport form, then apply the document's schema to the
|
|
614
|
+
* result.
|
|
615
|
+
*
|
|
616
|
+
* Lowercased because RFC 9110 §8.3.1 makes media type comparison case-insensitive — but ONLY when the
|
|
617
|
+
* declared literal is itself lowercase, so a document that published an oddly-cased type keeps
|
|
618
|
+
* matching exactly rather than silently never matching. Every literal openapi3 publishes across the
|
|
619
|
+
* corpus is lowercase; this costs one condition and removes the way that fact could stop being true.
|
|
620
|
+
*/
|
|
621
|
+
function mediaTypeDecoded(declared) {
|
|
622
|
+
/**
|
|
623
|
+
* ⚠️ **One accepted media type is a literal; several are an ENUM, and the second form is the one a
|
|
624
|
+
* real document produces.** The corpus writes 78 `content-type` validators and every one is a
|
|
625
|
+
* single literal, so a decoder that handled only `z.literal` looked complete against it. The
|
|
626
|
+
* Swagger Petstore's `addPet` accepts three media types and openapi3 publishes
|
|
627
|
+
* `z.enum(["application/json", "application/xml", "application/x-www-form-urlencoded"])` — which
|
|
628
|
+
* went on refusing `application/json; charset=utf-8` after the literal case was fixed. Measured:
|
|
629
|
+
* with the charset parameter **400**, without it **200**, same body.
|
|
630
|
+
*/
|
|
631
|
+
const values = /^z\.literal\("([^"]+)"\)/.exec(declared)?.slice(1, 2) ??
|
|
632
|
+
/^z\.enum\(\[([^\]]+)\]\)/
|
|
633
|
+
.exec(declared)?.[1]
|
|
634
|
+
?.split(",")
|
|
635
|
+
.map((entry) => entry.trim().replace(/^"|"$/g, ""));
|
|
636
|
+
if (values === undefined || values.length === 0)
|
|
637
|
+
return declared;
|
|
638
|
+
// Lowercased only when every declared value already is, so an oddly-cased one keeps matching.
|
|
639
|
+
const normalise = values.every((value) => value === value.toLowerCase()) ? ".toLowerCase()" : "";
|
|
640
|
+
return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${declared})`;
|
|
641
|
+
}
|
|
642
|
+
function parameterSchemasOf(program, operation, registry) {
|
|
643
|
+
const byTarget = {};
|
|
644
|
+
let accept;
|
|
645
|
+
for (const parameter of operation.parameters.parameters) {
|
|
646
|
+
if (parameter.type !== "path" && parameter.type !== "query" && parameter.type !== "header") {
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* ⚠️ **`parameter.name` is the WIRE name; `parameter.param.name` is the TypeSpec one.** Headers
|
|
651
|
+
* used to be keyed on the second, and it made every header validator reject every conformant
|
|
652
|
+
* request: `@header("webhook-id") id: string` produced `z.object({ id })`, while `zValidator`
|
|
653
|
+
* reads what actually arrived — a header called `webhook-id`. Measured: **400 against the
|
|
654
|
+
* emitted schema, 200 against the documented one**, on a webhook's signed headers.
|
|
655
|
+
*
|
|
656
|
+
* The wire name is also the right answer for path and query where the two differ
|
|
657
|
+
* (`@query("$select") select`), and it is what `propertyKey` already does for body properties
|
|
658
|
+
* via `resolveEncodedName` — the document publishes one name per parameter, and it is this one.
|
|
659
|
+
* The TypeSpec property name is an authoring convenience that does not survive to the contract.
|
|
660
|
+
*/
|
|
661
|
+
const delimiter = collectionDelimiterOf(program, parameter);
|
|
662
|
+
const declared = registry.expressionForProperty(parameter.param);
|
|
663
|
+
/**
|
|
664
|
+
* The wire form is decoded before it is validated, never instead of it. `z.preprocess` undoes
|
|
665
|
+
* exactly the flattening the document's `style` describes and then applies the schema the
|
|
666
|
+
* document publishes, so every constraint on the list — `minItems`, the element type — still
|
|
667
|
+
* runs. Splitting in the handler instead would have left those unenforced.
|
|
668
|
+
*/
|
|
669
|
+
/**
|
|
670
|
+
* Decoded BEFORE the split is wrapped around it, so the two nest in wire order: the outer
|
|
671
|
+
* preprocess turns `"1,2,3"` into `["1","2","3"]`, the inner one turns those into numbers, and
|
|
672
|
+
* the document's schema then validates the result. Wrapping the other way round would hand the
|
|
673
|
+
* element decoder an unsplit string.
|
|
674
|
+
*/
|
|
675
|
+
const decoded = parameter.type === "header" && parameter.name.toLowerCase() === "content-type"
|
|
676
|
+
? mediaTypeDecoded(declared)
|
|
677
|
+
: wireDecoded(declared);
|
|
678
|
+
const expression = delimiter === undefined
|
|
679
|
+
? decoded
|
|
680
|
+
: `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${JSON.stringify(delimiter)}) : raw), ${decoded})`;
|
|
681
|
+
const entry = `\t${objectKey(parameter.name)}: ${expression},`;
|
|
682
|
+
(byTarget[parameter.type] ??= []).push(entry);
|
|
683
|
+
/**
|
|
684
|
+
* ⚠️ **On a route several operations share, `accept` SELECTS the operation — it is not a
|
|
685
|
+
* constraint on one of them.** Each member declares its own literal, so validating against any
|
|
686
|
+
* single member's would 400 every request asking for one of the others, and 400 is the wrong
|
|
687
|
+
* answer twice over: the request is well-formed, and the contract's answer to "I cannot serve
|
|
688
|
+
* what you asked for" is 406. `selectContentType` decides it instead, so the negotiated route
|
|
689
|
+
* validates every OTHER header and leaves this one to the negotiation.
|
|
690
|
+
*/
|
|
691
|
+
if (parameter.type === "header" && parameter.name.toLowerCase() === "accept") {
|
|
692
|
+
const literal = parameter.param.type;
|
|
693
|
+
if (literal.kind === "String")
|
|
694
|
+
accept = { name: parameter.name, value: literal.value };
|
|
695
|
+
}
|
|
696
|
+
else if (parameter.type === "header") {
|
|
697
|
+
(byTarget["header-without-accept"] ??= []).push(entry);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
const shape = (target) => {
|
|
701
|
+
const entries = byTarget[target];
|
|
702
|
+
return entries === undefined ? undefined : `z.object({\n${entries.join("\n")}\n})`;
|
|
703
|
+
};
|
|
704
|
+
return {
|
|
705
|
+
path: shape("path"),
|
|
706
|
+
query: shape("query"),
|
|
707
|
+
header: shape("header"),
|
|
708
|
+
negotiatedHeader: shape("header-without-accept"),
|
|
709
|
+
accept,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
export function collectRoutes(program, registry, service) {
|
|
713
|
+
const routes = [];
|
|
714
|
+
{
|
|
715
|
+
for (const operation of service.operations) {
|
|
716
|
+
const statusCode = successStatusOf(operation);
|
|
717
|
+
if (statusCode === undefined)
|
|
718
|
+
continue;
|
|
719
|
+
// No body is a legitimate success — see `EmittedRoute.responseSchema`. Skipping here is what
|
|
720
|
+
// dropped 282 operations across the conformance corpus without a word.
|
|
721
|
+
const responseType = successBodyOf(operation, statusCode);
|
|
722
|
+
const requestType = requestBodyOf(operation);
|
|
723
|
+
/**
|
|
724
|
+
* ⚠️ **A streamed operation's EVENT payloads are not reachable from its body.**
|
|
725
|
+
*
|
|
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
|
+
* even though the document publishes it as a component. Declaring the union here reaches
|
|
729
|
+
* every payload through it, which is the same set openapi3 emits. The result is discarded:
|
|
730
|
+
* the route's response schema is still the stream, and this exists so the vocabulary a
|
|
731
|
+
* consumer needs to check each `data:` against is actually generated.
|
|
732
|
+
*/
|
|
733
|
+
const streamed = streamedTypeOf(program, operation.operation.returnType);
|
|
734
|
+
if (streamed !== undefined) {
|
|
735
|
+
withVisibility(program, Visibility.Read, () => registry.expressionFor(streamed));
|
|
736
|
+
}
|
|
737
|
+
// `@requestVisibility` when the operation states one, otherwise the verb's default — the
|
|
738
|
+
// same resolution `@typespec/openapi3` performs before emitting a request schema.
|
|
739
|
+
const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
|
|
740
|
+
const statusCodes = successStatusesOf(operation);
|
|
741
|
+
const discriminator = statusDiscriminatorOf(operation, statusCodes);
|
|
742
|
+
/**
|
|
743
|
+
* A `bytes` body means the BYTES are the contract.
|
|
744
|
+
*
|
|
745
|
+
* A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
|
|
746
|
+
* verify a different string and every delivery would fail authentication.
|
|
747
|
+
*/
|
|
748
|
+
const bodyParameter = operation.parameters.body;
|
|
749
|
+
const rawBodyProperty = requestType?.kind === "Scalar" && requestType.name === "bytes"
|
|
750
|
+
? (bodyParameter?.property?.name ?? "body")
|
|
751
|
+
: undefined;
|
|
752
|
+
routes.push({
|
|
753
|
+
operationId: resolveOperationId(program, operation.operation),
|
|
754
|
+
verb: operation.verb.toUpperCase(),
|
|
755
|
+
/**
|
|
756
|
+
* The path TEMPLATE, exactly as the document publishes it — `/widgets/{widget-id}`.
|
|
757
|
+
*
|
|
758
|
+
* ⚠️ **This used to be a router's path, converted here.** `toHonoPath` ran during
|
|
759
|
+
* collection and rewrote `{widget-id}` to `:widget-id`, so the shared intermediate
|
|
760
|
+
* representation carried one framework's spelling of a contract fact — and a template
|
|
761
|
+
* that router could not mount refused the whole operation, validators included.
|
|
762
|
+
*
|
|
763
|
+
* A server emitter converts this when it renders, and refuses what it cannot mount with
|
|
764
|
+
* a diagnostic of its own. The validators for such an operation are still correct and
|
|
765
|
+
* still emitted: what a request body must look like does not depend on whether some
|
|
766
|
+
* particular router can express the path.
|
|
767
|
+
*/
|
|
768
|
+
path: operation.path,
|
|
769
|
+
statusCode,
|
|
770
|
+
statusCodes,
|
|
771
|
+
responseContentTypes: responseContentTypesOf(operation, statusCode),
|
|
772
|
+
requestContentTypes: [...(operation.parameters.body?.contentTypes ?? [])],
|
|
773
|
+
/**
|
|
774
|
+
* `@summary` first, `@doc` as the fallback.
|
|
775
|
+
*
|
|
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
|
+
* strings are one-liners means `summary`; written as `@doc` they land in `description`
|
|
779
|
+
* instead and leave `summary` empty on every operation. The fallback keeps a spec that
|
|
780
|
+
* uses `@doc` working.
|
|
781
|
+
*/
|
|
782
|
+
summary: getSummary(program, operation.operation) ?? getDoc(program, operation.operation),
|
|
783
|
+
/**
|
|
784
|
+
* ⚠️ **Each position is walked at ITS OWN visibility, which is the whole point.** A model
|
|
785
|
+
* used as a request body and as a response is two shapes: `@statusCode` is applicable to
|
|
786
|
+
* one and not the other, and `@visibility` can add or remove properties outright. Walking
|
|
787
|
+
* both at one visibility gives whichever was seen first to both.
|
|
788
|
+
*/
|
|
789
|
+
requestSchema: withVisibility(program, requestVisibility, () => {
|
|
790
|
+
// Multipart first: its body has no single type to walk, and skipping it left every
|
|
791
|
+
// such route mounted with nothing checking the request at all.
|
|
792
|
+
const parameterBody = operation.parameters.body;
|
|
793
|
+
if (parameterBody?.bodyKind === "multipart") {
|
|
794
|
+
return multipartSchemaOf(parameterBody, registry);
|
|
795
|
+
}
|
|
796
|
+
return requestType === undefined ? undefined : registry.expressionFor(requestType);
|
|
797
|
+
}),
|
|
798
|
+
...(() => {
|
|
799
|
+
const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry));
|
|
800
|
+
return {
|
|
801
|
+
pathSchema: split.path,
|
|
802
|
+
querySchema: split.query,
|
|
803
|
+
headerSchema: split.header,
|
|
804
|
+
negotiatedHeaderSchema: split.negotiatedHeader,
|
|
805
|
+
accept: split.accept,
|
|
806
|
+
};
|
|
807
|
+
})(),
|
|
808
|
+
responseSchema: responseType === undefined
|
|
809
|
+
? undefined
|
|
810
|
+
: withVisibility(program, Visibility.Read, () =>
|
|
811
|
+
/**
|
|
812
|
+
* A raw binary body is `z.unknown()`, matching what a binary multipart part
|
|
813
|
+
* already emits and what the document actually asserts — see
|
|
814
|
+
* {@link isRawBinaryMediaType}.
|
|
815
|
+
*/
|
|
816
|
+
isBinaryPart(responseType) &&
|
|
817
|
+
isRawBinaryMediaType(responseContentTypesOf(operation, statusCode))
|
|
818
|
+
? "z.unknown()"
|
|
819
|
+
: registry.expressionFor(responseType)),
|
|
820
|
+
rawBodyProperty,
|
|
821
|
+
// Responses are read at Visibility.Read, exactly as the success body above is. Resolving
|
|
822
|
+
// them under the ambient request visibility declared six components under the wrong
|
|
823
|
+
// suffix — measured: type/model/visibility gained six divergences.
|
|
824
|
+
errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
|
|
825
|
+
scopes: [
|
|
826
|
+
...new Set((getAuthenticationForOperation(program, operation.operation)?.options ?? []).flatMap((option) => option.schemes.flatMap((scheme) => scheme.type === "oauth2"
|
|
827
|
+
? scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value))
|
|
828
|
+
: []))),
|
|
829
|
+
],
|
|
830
|
+
noAuth: noAuthFor(program, operation),
|
|
831
|
+
statusBy: discriminator,
|
|
832
|
+
/**
|
|
833
|
+
* ⚠️ **Each arm is validated against its OWN shape.**
|
|
834
|
+
*
|
|
835
|
+
* A consumer checks its value against the arm's schema, and with one schema
|
|
836
|
+
* for two statuses the acknowledged response was checked against the resolved arm's
|
|
837
|
+
* shape — a 502 on a perfectly good 202.
|
|
838
|
+
*/
|
|
839
|
+
alternateResponseSchema: discriminator === undefined
|
|
840
|
+
? undefined
|
|
841
|
+
: alternateSchemaFor(operation, discriminator.status, registry),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return routes;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Emit only the external names a given file actually references.
|
|
849
|
+
*
|
|
850
|
+
* Both generated files can need them — `schemas.gen.ts` for the vocabulary tuples and `@refine`
|
|
851
|
+
* predicates, and `routes.gen.ts` when an inline params schema carries a refined scalar.
|
|
852
|
+
* Emitting the whole set into both would leave unused imports (which lint rejects); emitting it into
|
|
853
|
+
* neither is what left `routes.gen.ts` referencing `isParsableInstant` with no import at all.
|
|
854
|
+
*/
|
|
855
|
+
function renderExternalImports(externals, ...bodies) {
|
|
856
|
+
const haystack = bodies.join("\n");
|
|
857
|
+
const lines = [];
|
|
858
|
+
for (const [module, names] of externals) {
|
|
859
|
+
const used = [...names].filter((name) => new RegExp(`\\b${name}\\b`).test(haystack));
|
|
860
|
+
if (used.length > 0) {
|
|
861
|
+
lines.push(`import { ${used.toSorted().join(", ")} } from ${JSON.stringify(module)};\n`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return lines.join("");
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Every validator this service needs, in one module: the component schemas, and the per-operation
|
|
868
|
+
* request and response validators.
|
|
869
|
+
*
|
|
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
|
+
* validate a request or a response against, and every oracle that graded those validators had to read
|
|
873
|
+
* a server to find them. See {@link nameRouteSchemas}.
|
|
874
|
+
*/
|
|
875
|
+
function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
|
|
876
|
+
const declarations = registry
|
|
877
|
+
.declarations()
|
|
878
|
+
.map((d) => `\nexport const ${d.identifier} = ${d.source};\nexport type ${d.typeName} = z.infer<typeof ${d.identifier}>;\n`);
|
|
879
|
+
const parts = [GENERATED_BANNER, '\nimport { z } from "zod";\n'];
|
|
880
|
+
/**
|
|
881
|
+
* A TYPE import, so nothing of this package's survives into the emitted JavaScript.
|
|
882
|
+
*
|
|
883
|
+
* The annotation is what makes a misspelled or omitted `schema` a compile error rather than an arm
|
|
884
|
+
* that silently validates nothing — see the option's own docblock for the measurement. Emitted only
|
|
885
|
+
* when there are arms to annotate, because an unused import fails the lint a generated file has to
|
|
886
|
+
* pass like any other.
|
|
887
|
+
*/
|
|
888
|
+
if (routeDeclarations.length > 0) {
|
|
889
|
+
parts.push(`import type { ResponseArm } from ${JSON.stringify(runtimeModule)};\n`);
|
|
890
|
+
}
|
|
891
|
+
parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
|
|
892
|
+
parts.push(declarations.join(""));
|
|
893
|
+
if (routeDeclarations.length > 0)
|
|
894
|
+
parts.push(`\n${routeDeclarations.join("\n")}\n`);
|
|
895
|
+
return parts.join("");
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* The IDENTIFIER a schema can be referred to by, which `z.infer<typeof …>` requires.
|
|
899
|
+
*
|
|
900
|
+
* A route's schema is often an expression rather than a declaration — `z.array(widgetSchema)`,
|
|
901
|
+
* `z.object({…})` for inline parameters — and `typeof z.array(widgetSchema)` is not valid
|
|
902
|
+
* TypeScript. Anything that is not already a bare identifier gets a name here; anything that is
|
|
903
|
+
* keeps it, so the generated file does not fill up with aliases that alias nothing.
|
|
904
|
+
*/
|
|
905
|
+
const isIdentifier = (expression) => /^[A-Za-z_$][\w$]*$/.test(expression);
|
|
906
|
+
const schemaConst = (operationId, suffix) => `${operationId}${suffix}`;
|
|
907
|
+
/** `[the name to refer to it by, the `const` line to emit]` — the line is absent when none is needed. */
|
|
908
|
+
function namedSchema(operationId, suffix, expression) {
|
|
909
|
+
if (expression === undefined)
|
|
910
|
+
return { name: undefined, declaration: undefined };
|
|
911
|
+
if (isIdentifier(expression))
|
|
912
|
+
return { name: expression, declaration: undefined };
|
|
913
|
+
const name = schemaConst(operationId, suffix);
|
|
914
|
+
/**
|
|
915
|
+
* ⚠️ **Exported, because a schema nothing outside this file can name is a schema nothing can
|
|
916
|
+
* check.** These are the validators the server actually enforces on every request — the path,
|
|
917
|
+
* query and header shapes — and while they were module-local the only thing that could read them
|
|
918
|
+
* was the generated code itself. That is how a header validator keyed on the wrong name shipped:
|
|
919
|
+
* the conformance differential compares `components.schemas`, a parameter schema is not a
|
|
920
|
+
* component, and there was no third artefact to compare against even in principle.
|
|
921
|
+
*
|
|
922
|
+
* Exporting also gives an application the request shape by name, which is what the per-operation
|
|
923
|
+
* type aliases will be built from.
|
|
924
|
+
*/
|
|
925
|
+
return { name, declaration: `export const ${name} = ${expression};` };
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Which `verb path` slots carry more than one operation.
|
|
929
|
+
*
|
|
930
|
+
* ⚠️ **A DOCUMENT fact, not a router one, which is why it lives here.** TypeSpec models content
|
|
931
|
+
* negotiation as several operations on one route; OpenAPI models it as a single path item whose
|
|
932
|
+
* response lists every media type. A route reached that way must not validate `accept` against any
|
|
933
|
+
* one member's literal — `accept` SELECTS the operation, so validating it would answer `400` to a
|
|
934
|
+
* well-formed request whose real answer is `406`. That is why `negotiatedHeaderSchema` exists, and
|
|
935
|
+
* deciding which of the two applies is what this set is for.
|
|
936
|
+
*/
|
|
937
|
+
function sharedSlots(routes) {
|
|
938
|
+
const shared = new Set();
|
|
939
|
+
const seen = new Set();
|
|
940
|
+
for (const route of routes) {
|
|
941
|
+
const slot = `${route.verb} ${route.path}`;
|
|
942
|
+
if (seen.has(slot))
|
|
943
|
+
shared.add(slot);
|
|
944
|
+
seen.add(slot);
|
|
945
|
+
}
|
|
946
|
+
return shared;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Declare every per-operation validator, and hand back the names they were declared under.
|
|
950
|
+
*
|
|
951
|
+
* ⚠️ **These used to be declared by the SERVER emitter, into the server file.** Six identifiers per
|
|
952
|
+
* operation — the path, query and header validators, the request body, the success response and the
|
|
953
|
+
* response arms — were built and emitted inside `renderApp`, which had three consequences and none of
|
|
954
|
+
* them were intended:
|
|
955
|
+
*
|
|
956
|
+
* 1. a package whose entire claim is *TypeSpec HTTP → Zod* emitted **no** request or response
|
|
957
|
+
* validator that a consumer could name. Component schemas, and nothing to check a call against;
|
|
958
|
+
* 2. every oracle that graded them had to read the SERVER file, so splitting this library out would
|
|
959
|
+
* have taken the parameter, response and response-body arms with it and left component agreement
|
|
960
|
+
* as the only thing still measured — three of the four largest arms, lost silently;
|
|
961
|
+
* 3. two emitters would have had to mint identical identifiers by coincidence, which is the coupling
|
|
962
|
+
* that made a library-plus-emitter split look impossible in the first place.
|
|
963
|
+
*
|
|
964
|
+
* Declaring them here dissolves all three: the library mints the names AND emits them, and a server
|
|
965
|
+
* emitter imports what it is told rather than agreeing about it.
|
|
966
|
+
*/
|
|
967
|
+
function nameRouteSchemas(routes) {
|
|
968
|
+
const shared = sharedSlots(routes);
|
|
969
|
+
const declarations = [];
|
|
970
|
+
const names = new Map();
|
|
971
|
+
for (const route of routes) {
|
|
972
|
+
const declare = (suffix, expression) => {
|
|
973
|
+
const resolved = namedSchema(route.operationId, suffix, expression);
|
|
974
|
+
if (resolved.declaration !== undefined)
|
|
975
|
+
declarations.push(resolved.declaration);
|
|
976
|
+
return resolved.name;
|
|
977
|
+
};
|
|
978
|
+
// Order follows the request: what identifies the resource, then what filters it, then its body.
|
|
979
|
+
const path = declare("Path", route.pathSchema);
|
|
980
|
+
const query = declare("Query", route.querySchema);
|
|
981
|
+
const negotiating = shared.has(`${route.verb} ${route.path}`);
|
|
982
|
+
const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
|
|
983
|
+
// A raw body is read as text, never parsed, so it has no validator — the bytes are the contract.
|
|
984
|
+
const body = route.rawBodyProperty === undefined ? declare("Body", route.requestSchema) : undefined;
|
|
985
|
+
const response = declare("Response", route.responseSchema);
|
|
986
|
+
const alternateResponse = declare("AlternateResponse", route.alternateResponseSchema);
|
|
987
|
+
const responses = schemaConst(route.operationId, "Responses");
|
|
988
|
+
declarations.push(`export const ${responses} = ${responseArmsOf(route, response, alternateResponse)} satisfies readonly ResponseArm[];`);
|
|
989
|
+
names.set(route.operationId, {
|
|
990
|
+
operationId: route.operationId,
|
|
991
|
+
path,
|
|
992
|
+
query,
|
|
993
|
+
header,
|
|
994
|
+
body,
|
|
995
|
+
response,
|
|
996
|
+
alternateResponse,
|
|
997
|
+
responses,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
return { names, declarations };
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* What an operation may answer with, as a literal — each declared status paired with the body the
|
|
1004
|
+
* document gives it, in OpenAPI's own precedence order.
|
|
1005
|
+
*
|
|
1006
|
+
* ⚠️ **This docblock was orphaned in the source it came from**, sitting above an unrelated function
|
|
1007
|
+
* while the one it describes had none. Reattached.
|
|
1008
|
+
*/
|
|
1009
|
+
function responseArmsOf(route, response, alternate) {
|
|
1010
|
+
const arms = [];
|
|
1011
|
+
const primary = response ?? "undefined";
|
|
1012
|
+
if (route.statusBy === undefined) {
|
|
1013
|
+
arms.push(`{ status: ${route.statusCode}, schema: ${primary} }`);
|
|
1014
|
+
}
|
|
1015
|
+
else {
|
|
1016
|
+
const alternateSchema = alternate ?? "undefined";
|
|
1017
|
+
const { property, value, status } = route.statusBy;
|
|
1018
|
+
arms.push(`{ status: ${status}, schema: ${alternateSchema}, when: { property: ${JSON.stringify(property)}, value: ${JSON.stringify(value)} } }`, `{ status: ${route.statusCode}, schema: ${primary} }`);
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* The failure arms, which used to be dropped entirely.
|
|
1022
|
+
*
|
|
1023
|
+
* ⚠️ **The document declares what an error body looks like and the server never passed it on**, so
|
|
1024
|
+
* `respond` could not check what it sends on failure even in principle.
|
|
1025
|
+
*
|
|
1026
|
+
* ⚠️ **This used to fall back to the service-wide error schema, and the fallback was the defect.**
|
|
1027
|
+
* `arm.schema ?? errorSchema` was reached by every arm the emitter could not resolve a body for —
|
|
1028
|
+
* which, while `"default"` was deliberately given none, meant every catch-all in every service
|
|
1029
|
+
* answered with whichever shape `errorBodyOf` had settled on rather than the one its own response
|
|
1030
|
+
* declares. Each arm resolves its own body now, so an arm with no schema is a response the document
|
|
1031
|
+
* says carries no body, and saying `undefined` is the truthful answer rather than a guess.
|
|
1032
|
+
*/
|
|
1033
|
+
for (const arm of route.errorArms) {
|
|
1034
|
+
arms.push(`{ status: ${JSON.stringify(arm.status)}, schema: ${arm.schema ?? "undefined"} }`);
|
|
1035
|
+
}
|
|
1036
|
+
return `[${arms.join(", ")}]`;
|
|
1037
|
+
}
|
|
1038
|
+
const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
1039
|
+
/**
|
|
1040
|
+
* The effective input of every operation, as TypeScript: its body, with the path, query and header
|
|
1041
|
+
* parameters merged over it.
|
|
1042
|
+
*
|
|
1043
|
+
* ⚠️ **This used to drop any parameter literally named `companyId`.** One application carried its
|
|
1044
|
+
* tenant identifier in a context object rather than in the payload, and the emitter was taught to
|
|
1045
|
+
* match — so for every consumer, a path parameter with that name vanished from the generated input
|
|
1046
|
+
* type while the Zod validator went on requiring it. Two artefacts describing different shapes, from
|
|
1047
|
+
* a rule no document states.
|
|
1048
|
+
*
|
|
1049
|
+
* It is the same defect class this emitter exists to delete, and it hid better than most: the
|
|
1050
|
+
* emitted assertions pair the validator against the contract types for MODELS, so a disagreement
|
|
1051
|
+
* about a merged input type is not among the things they compare.
|
|
1052
|
+
*/
|
|
1053
|
+
function collectRequestTypes(program, registry, service) {
|
|
1054
|
+
const entries = [];
|
|
1055
|
+
{
|
|
1056
|
+
for (const operation of service.operations) {
|
|
1057
|
+
if (successStatusOf(operation) === undefined)
|
|
1058
|
+
continue;
|
|
1059
|
+
const body = requestBodyOf(operation);
|
|
1060
|
+
/**
|
|
1061
|
+
* Headers are part of the input too.
|
|
1062
|
+
*
|
|
1063
|
+
* The webhook's three Standard-Webhooks headers are inside the MAC — they are not metadata
|
|
1064
|
+
* beside the payload, they are arguments to the verification. Leaving them out of the wire
|
|
1065
|
+
* type would describe an input the application cannot act on.
|
|
1066
|
+
*/
|
|
1067
|
+
const extraProperties = operation.parameters.parameters
|
|
1068
|
+
.filter((parameter) => parameter.type === "path" || parameter.type === "query" || parameter.type === "header")
|
|
1069
|
+
.map((parameter) => registry.expressionForProperty(parameter.param));
|
|
1070
|
+
if (body === undefined && extraProperties.length === 0)
|
|
1071
|
+
continue;
|
|
1072
|
+
/**
|
|
1073
|
+
* A `bytes` body reaches the use-case as a NAMED property holding the unparsed text, not as
|
|
1074
|
+
* a bare string — so the wire type is `{ rawBody: string }`, matching what a server
|
|
1075
|
+
* actually builds. Referencing the scalar directly would type the whole input as `string`
|
|
1076
|
+
* and quietly drop the headers beside it.
|
|
1077
|
+
*/
|
|
1078
|
+
const rawBody = body?.kind === "Scalar" && body.name === "bytes"
|
|
1079
|
+
? (operation.parameters.body?.property?.name ?? "body")
|
|
1080
|
+
: undefined;
|
|
1081
|
+
entries.push({
|
|
1082
|
+
operationId: resolveOperationId(program, operation.operation),
|
|
1083
|
+
bodyRef: rawBody !== undefined
|
|
1084
|
+
? `{\n\t${rawBody}: string;\n}`
|
|
1085
|
+
: body === undefined
|
|
1086
|
+
? undefined
|
|
1087
|
+
: registry.expressionFor(body),
|
|
1088
|
+
extraProperties,
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return entries;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* The success response every operation declares, so a producer can be held to producing it.
|
|
1096
|
+
*
|
|
1097
|
+
* ⚠️ **Pinned fields only — these are PERMISSIVE shapes.** The gateway names the fields it depends on
|
|
1098
|
+
* and lets a richer view through untouched, so this is a floor and not an equality: the
|
|
1099
|
+
* domain's result must CONTAIN what the document promises, and may contain more. Asserting equality
|
|
1100
|
+
* would turn every field a producer legitimately adds into a build failure, which is the same mistake
|
|
1101
|
+
* as making a response schema strict.
|
|
1102
|
+
*/
|
|
1103
|
+
function collectResponseTypes(program, registry, service) {
|
|
1104
|
+
const entries = [];
|
|
1105
|
+
{
|
|
1106
|
+
for (const operation of service.operations) {
|
|
1107
|
+
const status = successStatusOf(operation);
|
|
1108
|
+
if (status === undefined)
|
|
1109
|
+
continue;
|
|
1110
|
+
const body = successBodyOf(operation, status);
|
|
1111
|
+
if (body === undefined)
|
|
1112
|
+
continue;
|
|
1113
|
+
entries.push({
|
|
1114
|
+
operationId: resolveOperationId(program, operation.operation),
|
|
1115
|
+
ref: registry.expressionFor(body),
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return entries;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* `requests.gen.ts` — the shape both sides are checked against.
|
|
1123
|
+
*
|
|
1124
|
+
* Plain TypeScript with no imports at all, so the contract types stay framework-free and a layering
|
|
1125
|
+
* rule banning a validation library from the rest of the codebase is untouched. `wire-contract.gen.ts`
|
|
1126
|
+
* asserts the emitted Zod infers exactly this; a consumer asserts its own code produces it. Neither
|
|
1127
|
+
* imports the other, and a disagreement that would otherwise answer 400 on every request becomes a
|
|
1128
|
+
* typecheck failure.
|
|
1129
|
+
*/
|
|
1130
|
+
function renderRequestTypes(entries, responses, registry) {
|
|
1131
|
+
const declarations = registry.declarations().map((declaration) => {
|
|
1132
|
+
// A vocabulary alias stays module-local: the vocabularies artefact already exports these names
|
|
1133
|
+
// as runtime tuples, and exporting both collides on every one.
|
|
1134
|
+
if (declaration.isVocabulary)
|
|
1135
|
+
return `\ntype ${declaration.name} = ${declaration.source};\n`;
|
|
1136
|
+
return declaration.isObject
|
|
1137
|
+
? `\nexport interface ${declaration.name} ${declaration.source}\n`
|
|
1138
|
+
: `\nexport type ${declaration.name} = ${declaration.source};\n`;
|
|
1139
|
+
});
|
|
1140
|
+
const inputs = entries.map((entry) => {
|
|
1141
|
+
const parts = [];
|
|
1142
|
+
if (entry.bodyRef !== undefined)
|
|
1143
|
+
parts.push(entry.bodyRef);
|
|
1144
|
+
if (entry.extraProperties.length > 0) {
|
|
1145
|
+
parts.push(`{\n${entry.extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
|
|
1146
|
+
}
|
|
1147
|
+
return (`\n/** The effective input for \`${entry.operationId}\` — its body, with path and query parameters merged over it. */\n` +
|
|
1148
|
+
`export type ${upperFirst(entry.operationId)}Input = Simplify<${parts.join(" & ")}>;\n`);
|
|
1149
|
+
});
|
|
1150
|
+
/**
|
|
1151
|
+
* The same set again, keyed by operation id.
|
|
1152
|
+
*
|
|
1153
|
+
* TypeScript cannot index a module's *type* exports by a computed name — `typeof Contracts` carries
|
|
1154
|
+
* only the value side, so `Contracts["AppointOfficerInput"]` does not resolve. Both sides'
|
|
1155
|
+
* assertions are written as mapped types over every operation, and this is what makes that
|
|
1156
|
+
* possible. Generated with the aliases above, so it cannot fall behind them.
|
|
1157
|
+
*/
|
|
1158
|
+
const responseLookup = responses
|
|
1159
|
+
.map((entry) => `\t${entry.operationId}: Produced<${entry.ref}>;`)
|
|
1160
|
+
.join("\n");
|
|
1161
|
+
const lookup = entries
|
|
1162
|
+
.map((entry) => `\t${entry.operationId}: ${upperFirst(entry.operationId)}Input;`)
|
|
1163
|
+
.join("\n");
|
|
1164
|
+
return `${GENERATED_BANNER}
|
|
1165
|
+
/**
|
|
1166
|
+
* Flattens an intersection into a single object type.
|
|
1167
|
+
*
|
|
1168
|
+
* 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 — so the assertions that pair
|
|
1170
|
+
* this file with the emitted Zod would fail on correct code.
|
|
1171
|
+
*/
|
|
1172
|
+
type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* The same shape, tolerant of \`readonly\`.
|
|
1176
|
+
*
|
|
1177
|
+
* 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 — neither of which is assignable to a
|
|
1179
|
+
* mutable one. Mutability is not a fact about a wire shape: it says nothing about what a caller
|
|
1180
|
+
* receives. Making the TARGET readonly accepts both, which is the permissive direction; the reverse
|
|
1181
|
+
* would reject every view a producer actually returns.
|
|
1182
|
+
*/
|
|
1183
|
+
type Produced<T> = T extends readonly (infer Element)[]
|
|
1184
|
+
? readonly Produced<Element>[]
|
|
1185
|
+
: T extends object
|
|
1186
|
+
? { readonly [K in keyof T]: Produced<T[K]> }
|
|
1187
|
+
: T;
|
|
1188
|
+
${declarations.join("")}${inputs.join("")}
|
|
1189
|
+
/** Every operation's effective input, keyed by operation id — what both sides assert against. */
|
|
1190
|
+
export interface WireInputs {
|
|
1191
|
+
${lookup}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Every operation's success response, keyed by operation id.
|
|
1196
|
+
*
|
|
1197
|
+
* ⚠️ **A floor, not an equality.** These shapes are permissive: a caller pins the fields it depends
|
|
1198
|
+
* 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 — measured once, a
|
|
1200
|
+
* route advertised a field it had never returned, and nothing anywhere could see it.
|
|
1201
|
+
*/
|
|
1202
|
+
export interface WireOutputs {
|
|
1203
|
+
${responseLookup}
|
|
1204
|
+
}
|
|
1205
|
+
`;
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* The validator's half of the wire contract: the emitted Zod must infer **exactly** the shared type.
|
|
1209
|
+
*
|
|
1210
|
+
* Two artefacts describe one contract — the runtime validator and the framework-free type a codebase
|
|
1211
|
+
* passes around — and a shared declaration compared against only one of them lets the other drift
|
|
1212
|
+
* into it unnoticed. This asserts the validator *produces* the shared shape; a consumer that also
|
|
1213
|
+
* asserts its own code *accepts* that shape has both directions pinned.
|
|
1214
|
+
*
|
|
1215
|
+
* ⚠️ This is also the only thing that catches the emitter disagreeing with **itself**. `zod.ts` and
|
|
1216
|
+
* `types.ts` walk the same TypeSpec twice, once to Zod and once to TypeScript, and a construct one
|
|
1217
|
+
* handles differently from the other produces two artefacts that describe different shapes while
|
|
1218
|
+
* both look right in isolation. That happened during this emitter's first run: a vocabulary in a
|
|
1219
|
+
* query-parameter position was inlined by one walk and referenced by the other.
|
|
1220
|
+
*
|
|
1221
|
+
* `Equals` here, not assignability — `z.infer` is the output type, and the wire type is emitted with
|
|
1222
|
+
* defaulted properties required for exactly that reason, so the two really are equal.
|
|
1223
|
+
*/
|
|
1224
|
+
function renderWireAssertions(schemas, types, entries, contractsPackage) {
|
|
1225
|
+
// 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 — the
|
|
1227
|
+
// types are still written to `contracts-output-dir`, they simply have no shared specifier yet.
|
|
1228
|
+
if (contractsPackage === undefined)
|
|
1229
|
+
return undefined;
|
|
1230
|
+
const requestModels = new Set(entries.map((entry) => entry.bodyRef).filter((ref) => ref !== undefined));
|
|
1231
|
+
const paired = [];
|
|
1232
|
+
for (const declaration of types.declarations()) {
|
|
1233
|
+
if (!requestModels.has(declaration.name))
|
|
1234
|
+
continue;
|
|
1235
|
+
const schema = schemas
|
|
1236
|
+
.declarations()
|
|
1237
|
+
.find((candidate) => candidate.typeName === declaration.name);
|
|
1238
|
+
if (schema === undefined)
|
|
1239
|
+
continue;
|
|
1240
|
+
paired.push({ name: declaration.name, identifier: schema.identifier });
|
|
1241
|
+
}
|
|
1242
|
+
if (paired.length === 0)
|
|
1243
|
+
return undefined;
|
|
1244
|
+
const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<z.infer<typeof ${pair.identifier}>, Contracts.${pair.name}>>;`);
|
|
1245
|
+
return `${GENERATED_BANNER}
|
|
1246
|
+
import type { z } from "zod";
|
|
1247
|
+
import type * as Contracts from "${contractsPackage}";
|
|
1248
|
+
import type {
|
|
1249
|
+
${paired.map((pair) => `\t${pair.identifier},`).join("\n")}
|
|
1250
|
+
} from "./schemas.gen.js";
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Exact structural equality. The naive \`A extends B ? B extends A\` form reports two object types
|
|
1254
|
+
* equal when one has an optional property the other lacks, which is the whole class of drift this
|
|
1255
|
+
* exists to catch; the identity-function comparison does not.
|
|
1256
|
+
*/
|
|
1257
|
+
type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
|
1258
|
+
? true
|
|
1259
|
+
: ["the emitted validator and the shared wire type describe different shapes"];
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* \u26a0\ufe0f **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
|
|
1263
|
+
* type when the two disagree, and a type alias to an odd type compiles fine \u2014 66 assertions that
|
|
1264
|
+
* assert nothing. Only a constraint violation is an error.
|
|
1265
|
+
*/
|
|
1266
|
+
type MustHold<T extends true> = T;
|
|
1267
|
+
|
|
1268
|
+
${assertions.join("\n")}
|
|
1269
|
+
`;
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* `vocabularies.gen.ts` — every vocabulary the surface reaches, as runtime tuples.
|
|
1273
|
+
*
|
|
1274
|
+
* Keyed by enum name rather than emitted as individually-named tuples, because the constant names a
|
|
1275
|
+
* consumer wants are English plurals and some of them are irregular. An emitter that pluralises is an
|
|
1276
|
+
* emitter that will one day invent `CAPACITYS` and silently create a second constant beside the real
|
|
1277
|
+
* one. So the emitter states the fact and the consumer owns the naming.
|
|
1278
|
+
*/
|
|
1279
|
+
function renderVocabularies(vocabularies) {
|
|
1280
|
+
const entries = [...vocabularies.entries()]
|
|
1281
|
+
.toSorted(([a], [b]) => a.localeCompare(b))
|
|
1282
|
+
.map(([name, values]) => `\t${name}: [${values.map((v) => JSON.stringify(v)).join(", ")}],`);
|
|
1283
|
+
return `${GENERATED_BANNER}
|
|
1284
|
+
/**
|
|
1285
|
+
* The spec's vocabularies — every enum the surface reaches, and what each one contains.
|
|
1286
|
+
*
|
|
1287
|
+
* \`as const\` so each is a readonly tuple of literals — the type every consumer derives with
|
|
1288
|
+
* \`(typeof X)[number]\` keeps working unchanged.
|
|
1289
|
+
*/
|
|
1290
|
+
export const SPEC_VOCABULARIES = {
|
|
1291
|
+
${entries.join("\n")}
|
|
1292
|
+
} as const;
|
|
1293
|
+
`;
|
|
1294
|
+
}
|
|
1295
|
+
/** What the generated files import their runtime contract from when nothing says otherwise. */
|
|
1296
|
+
export const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
|
|
1297
|
+
/**
|
|
1298
|
+
* Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
|
|
1299
|
+
*
|
|
1300
|
+
* ⚠️ **The whole orchestration lives here, once.** The nesting below is load-bearing — a walk that
|
|
1301
|
+
* runs outside `collectExternalImports` throws, vocabularies must be collected from what the service
|
|
1302
|
+
* DECLARES rather than from what the Zod walk happens to touch, and the error body has to be
|
|
1303
|
+
* registered inside the walk or its validator never reaches `schemas.gen.ts`. An emitter built on this
|
|
1304
|
+
* library calls this rather than restating it: two copies of a sequence that must agree are two
|
|
1305
|
+
* copies that will not, and the symptom would be a subtly different set of declarations in a file
|
|
1306
|
+
* whose name is the contract.
|
|
1307
|
+
*/
|
|
1308
|
+
export async function emitHttpZod(context, wrapper = {}) {
|
|
1309
|
+
const options = context.options;
|
|
1310
|
+
const defaultRuntimeModule = wrapper.defaultRuntimeModule ?? DEFAULT_RUNTIME_MODULE;
|
|
1311
|
+
/**
|
|
1312
|
+
* Each service already projected to the version it currently serves — see `versioning.ts`.
|
|
1313
|
+
*
|
|
1314
|
+
* ⚠️ **This used to be a bare `getAllHttpServices`, which is the UNION of every version.** A spec
|
|
1315
|
+
* that removes an operation in v2 had it mounted anyway, and a spec that removed a property had it
|
|
1316
|
+
* still required. Both while the document said otherwise.
|
|
1317
|
+
*/
|
|
1318
|
+
const versioning = await resolveVersioningModule();
|
|
1319
|
+
const snapshots = serviceSnapshots(context.program, versioning);
|
|
1320
|
+
/**
|
|
1321
|
+
* 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 — see `streams.ts`.
|
|
1323
|
+
*/
|
|
1324
|
+
const streamModule = await resolveStreamModule();
|
|
1325
|
+
const emitted = [];
|
|
1326
|
+
/**
|
|
1327
|
+
* ⚠️ **One emission per `@service`, with its own registry and its own import collector.**
|
|
1328
|
+
*
|
|
1329
|
+
* Sharing either across services merges one surface's schemas and imports into the other's file —
|
|
1330
|
+
* an unused import if you are lucky and a missing one if you are not, and a public document
|
|
1331
|
+
* carrying internal models if you are not paying attention. It is invisible while there is one
|
|
1332
|
+
* service, which is exactly why it is built this way before the second one lands rather than after.
|
|
1333
|
+
*/
|
|
1334
|
+
for (const snapshot of snapshots) {
|
|
1335
|
+
const service = snapshot.service;
|
|
1336
|
+
const serviceName = service.namespace.name;
|
|
1337
|
+
const perService = options.services?.[serviceName];
|
|
1338
|
+
const outputDir = perService?.["output-dir"] ?? context.emitterOutputDir;
|
|
1339
|
+
const resolved = {
|
|
1340
|
+
contractsOutputDir: perService?.["contracts-output-dir"] ?? options["contracts-output-dir"],
|
|
1341
|
+
contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
|
|
1342
|
+
sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
|
|
1343
|
+
keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
|
|
1344
|
+
runtimeModule: perService?.["runtime-module"] ?? options["runtime-module"] ?? defaultRuntimeModule,
|
|
1345
|
+
};
|
|
1346
|
+
const registry = new SchemaRegistry(context.program);
|
|
1347
|
+
const { result: { routes }, imports: externals, vocabularies, } = collectExternalImports(() => withContractsPackage(resolved.contractsPackage, () => withSealedObjects(resolved.sealObjectSchemas, () => {
|
|
1348
|
+
// Declarations first: a vocabulary is a fact about the contract, so it is collected from
|
|
1349
|
+
// what the service DECLARES rather than from what the Zod walk happens to touch. An enum
|
|
1350
|
+
// reachable only through an error body was silently absent otherwise.
|
|
1351
|
+
noteDeclaredVocabularies(service.namespace.enums.values());
|
|
1352
|
+
/**
|
|
1353
|
+
* A closed key SET is a vocabulary too, and it cannot be an enum.
|
|
1354
|
+
*
|
|
1355
|
+
* TypeSpec has no key-constrained `Record`, so a model naming its permitted properties is
|
|
1356
|
+
* how the document states one — and a generated type cannot be iterated, which leaves a
|
|
1357
|
+
* consumer needing the set at run time with a hand-maintained copy. Naming the model in
|
|
1358
|
+
* `key-vocabularies` writes the keys beside the types. A name matching nothing is
|
|
1359
|
+
* reported rather than quietly emitting no tuple.
|
|
1360
|
+
*/
|
|
1361
|
+
const emittedKeyVocabularies = noteKeyVocabularies(context.program, service.namespace.models.values(), resolved.keyVocabularies);
|
|
1362
|
+
for (const name of resolved.keyVocabularies) {
|
|
1363
|
+
if (emittedKeyVocabularies.includes(name))
|
|
1364
|
+
continue;
|
|
1365
|
+
reportDiagnostic(context.program, {
|
|
1366
|
+
code: "unknown-key-vocabulary",
|
|
1367
|
+
target: service.namespace,
|
|
1368
|
+
format: { name, service: service.namespace.name },
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* The error body is registered INSIDE this walk, so its validator lands in
|
|
1373
|
+
* `schemas.gen.ts` alongside every other declaration. Nothing else reaches it: the Zod
|
|
1374
|
+
* walk follows success bodies and parameters, so without this a runtime would have no
|
|
1375
|
+
* generated schema to check the documents it sends against.
|
|
1376
|
+
*
|
|
1377
|
+
* ⚠️ **Its expression is registered and then deliberately discarded.** It used to be
|
|
1378
|
+
* returned and used as a fallback for any response arm whose own body could not be
|
|
1379
|
+
* resolved, which meant every catch-all answered with whichever shape `errorBodyOf` had
|
|
1380
|
+
* settled on rather than the one its own response declares. Each arm resolves its own
|
|
1381
|
+
* body now; registering this one only ensures it is declared.
|
|
1382
|
+
*/
|
|
1383
|
+
const errorBody = errorBodyOf(context.program, service);
|
|
1384
|
+
if (errorBody !== undefined) {
|
|
1385
|
+
withVisibility(context.program, Visibility.Read, () => registry.expressionFor(errorBody));
|
|
1386
|
+
}
|
|
1387
|
+
return {
|
|
1388
|
+
routes: withStreamModule(streamModule, () => collectRoutes(context.program, registry, service)),
|
|
1389
|
+
};
|
|
1390
|
+
})));
|
|
1391
|
+
/**
|
|
1392
|
+
* ⚠️ **Named AFTER the walk and BEFORE the file is rendered.** Naming mints identifiers that
|
|
1393
|
+
* `schemas.gen.ts` must declare, so it has to happen while the registry is complete and before
|
|
1394
|
+
* anything is written — and it has to happen HERE rather than in a server emitter, or the
|
|
1395
|
+
* validators it produces never reach this package's own output.
|
|
1396
|
+
*/
|
|
1397
|
+
const { names, declarations } = nameRouteSchemas(routes);
|
|
1398
|
+
await emitFile(context.program, {
|
|
1399
|
+
path: resolvePath(outputDir, "schemas.gen.ts"),
|
|
1400
|
+
content: renderSchemas(registry, externals, declarations, resolved.runtimeModule),
|
|
1401
|
+
});
|
|
1402
|
+
if (resolved.contractsOutputDir !== undefined) {
|
|
1403
|
+
const types = new TypeRegistry(context.program);
|
|
1404
|
+
const entries = collectRequestTypes(context.program, types, service);
|
|
1405
|
+
const responses = collectResponseTypes(context.program, types, service);
|
|
1406
|
+
await emitFile(context.program, {
|
|
1407
|
+
path: resolvePath(resolved.contractsOutputDir, "requests.gen.ts"),
|
|
1408
|
+
content: renderRequestTypes(entries, responses, types),
|
|
1409
|
+
});
|
|
1410
|
+
await emitFile(context.program, {
|
|
1411
|
+
path: resolvePath(resolved.contractsOutputDir, "vocabularies.gen.ts"),
|
|
1412
|
+
content: renderVocabularies(vocabularies),
|
|
1413
|
+
});
|
|
1414
|
+
const assertions = renderWireAssertions(registry, types, entries, resolved.contractsPackage);
|
|
1415
|
+
if (assertions !== undefined) {
|
|
1416
|
+
await emitFile(context.program, {
|
|
1417
|
+
path: resolvePath(outputDir, "wire-contract.gen.ts"),
|
|
1418
|
+
content: assertions,
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
emitted.push({ service, routes, schemaNames: names, outputDir, options: resolved });
|
|
1423
|
+
}
|
|
1424
|
+
return emitted;
|
|
1425
|
+
}
|