typespec-http-zod 0.26.0 → 0.28.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 +6 -6
- package/dist/src/api.d.ts +63 -9
- package/dist/src/api.js +710 -295
- package/dist/src/constraints.d.ts +46 -0
- package/dist/src/constraints.js +237 -10
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/lib.d.ts +32 -3
- package/dist/src/lib.js +53 -0
- package/dist/src/linter.d.ts +15 -0
- package/dist/src/linter.js +23 -0
- package/dist/src/registry.d.ts +1 -1
- package/dist/src/registry.js +161 -41
- package/dist/src/results.d.ts +113 -0
- package/dist/src/results.js +134 -0
- package/dist/src/rules/redos-prone-pattern.rule.d.ts +27 -0
- package/dist/src/rules/redos-prone-pattern.rule.js +47 -0
- package/dist/src/statuses.d.ts +24 -0
- package/dist/src/statuses.js +27 -0
- package/dist/src/types.js +37 -24
- package/dist/src/uri-template.d.ts +1 -1
- package/dist/src/uri-template.js +4 -1
- package/dist/src/zod.d.ts +25 -18
- package/dist/src/zod.js +69 -14
- package/package.json +19 -16
package/dist/src/api.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolveEncodedName, resolvePath, } from "@typespec/compiler";
|
|
1
|
+
import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolveEncodedName, resolvePath, walkPropertiesInherited, } from "@typespec/compiler";
|
|
2
2
|
import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
|
|
3
3
|
import { getOperationId, resolveOperationId } from "@typespec/openapi";
|
|
4
4
|
import { reportDiagnostic } from "./lib.js";
|
|
5
5
|
import { SchemaRegistry, TypeRegistry } from "./registry.js";
|
|
6
|
-
import {
|
|
6
|
+
import { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
|
|
7
|
+
import { propertyTypeToTs } from "./types.js";
|
|
7
8
|
import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
|
|
8
9
|
import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
|
|
9
10
|
import { EXPLODED_QUERY_SOURCE, explodedQueryCall, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, } from "./uri-template.js";
|
|
10
|
-
|
|
11
|
+
export { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
|
|
12
|
+
export { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
|
|
13
|
+
import { collectExternalImports, isPayloadProperty, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
|
|
11
14
|
/**
|
|
12
15
|
* `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
|
|
13
16
|
* service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
|
|
@@ -154,10 +157,10 @@ let operationIds;
|
|
|
154
157
|
/**
|
|
155
158
|
* Establish a fresh id resolver for one service's emission.
|
|
156
159
|
*
|
|
157
|
-
* **Scoped per SERVICE rather than per walk, because
|
|
158
|
-
* `collectRoutes`
|
|
159
|
-
*
|
|
160
|
-
*
|
|
160
|
+
* **Scoped per SERVICE rather than per walk, because every walk has to name an operation the same
|
|
161
|
+
* way.** `collectRoutes` and `collectRequestTypes` both visit every operation, but each is a separate
|
|
162
|
+
* walk; deduplicating independently would let a suffix depend on the order a walk happened to meet
|
|
163
|
+
* two operations in, and the artefacts would name one operation two ways.
|
|
161
164
|
*
|
|
162
165
|
* A reset rather than a wrapper, because the emission loop is `for (const snapshot of snapshots)`
|
|
163
166
|
* and awaits each service to completion before the next begins. Nothing interleaves, so there is no
|
|
@@ -275,7 +278,7 @@ function statusKeysOf(program, response) {
|
|
|
275
278
|
return keys;
|
|
276
279
|
}
|
|
277
280
|
/**
|
|
278
|
-
* Every response the operation declares, one per status key, in
|
|
281
|
+
* Every response the operation declares, one per status key, in the order they were RESOLVED.
|
|
279
282
|
*
|
|
280
283
|
* **Each response's own body, headers and media types, and nothing borrowed from another.** The
|
|
281
284
|
* failure arms used to be collected here separately from the success, and before that every failure
|
|
@@ -288,10 +291,23 @@ function statusKeysOf(program, response) {
|
|
|
288
291
|
* Where two responses resolve to the same status the first body wins, which is what the document
|
|
289
292
|
* does, and media types and headers are the union across them.
|
|
290
293
|
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
294
|
+
* Resolution order, not precedence order, because a renderer that declares components has to
|
|
295
|
+
* declare them in the order they were always declared; {@link inPrecedenceOrder} sorts.
|
|
296
|
+
*
|
|
297
|
+
* **Resolved once per operation, and every walk reads that record.** Resolving reports what the
|
|
298
|
+
* document cannot publish, such as a status range OpenAPI cannot key; the route walk and the contract
|
|
299
|
+
* walk each resolved the same operation, so with contracts emitted every such error was raised twice.
|
|
293
300
|
*/
|
|
294
|
-
function
|
|
301
|
+
function resolvedResponsesOf(program, operation) {
|
|
302
|
+
const resolved = resolvedResponses.get(operation);
|
|
303
|
+
if (resolved !== undefined)
|
|
304
|
+
return resolved;
|
|
305
|
+
const fresh = resolveResponses(program, operation);
|
|
306
|
+
resolvedResponses.set(operation, fresh);
|
|
307
|
+
return fresh;
|
|
308
|
+
}
|
|
309
|
+
const resolvedResponses = new WeakMap();
|
|
310
|
+
function resolveResponses(program, operation) {
|
|
295
311
|
const byStatus = new Map();
|
|
296
312
|
for (const response of operation.responses) {
|
|
297
313
|
for (const status of statusKeysOf(program, response)) {
|
|
@@ -302,22 +318,28 @@ function responsesOf(program, operation, registry) {
|
|
|
302
318
|
contentTypes: new Set(),
|
|
303
319
|
headers: [],
|
|
304
320
|
streamed: false,
|
|
321
|
+
file: false,
|
|
305
322
|
};
|
|
306
323
|
for (const content of response.responses) {
|
|
307
324
|
if (entry.body === undefined && content.body?.bodyKind === "single") {
|
|
308
325
|
entry.body = bodyTypeOf(content.body.type);
|
|
309
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* **An `Http.File` response's body is its contents, served as bytes.** The same reading
|
|
329
|
+
* as a file request body: a media type and nothing to serialise. Read only for
|
|
330
|
+
* `"single"`, every download in `type/file` declared `image/png` and was served empty.
|
|
331
|
+
*/
|
|
332
|
+
if (entry.body === undefined && content.body?.bodyKind === "file") {
|
|
333
|
+
entry.body = content.body.contents.type;
|
|
334
|
+
entry.file = true;
|
|
335
|
+
}
|
|
310
336
|
for (const contentType of content.body?.contentTypes ?? []) {
|
|
311
337
|
entry.contentTypes.add(contentType);
|
|
312
338
|
}
|
|
313
339
|
for (const [name, property] of Object.entries(content.headers ?? {})) {
|
|
314
340
|
if (entry.headers.some((existing) => existing.name === name))
|
|
315
341
|
continue;
|
|
316
|
-
entry.headers.push({
|
|
317
|
-
name,
|
|
318
|
-
type: typeToTs(program, property.type),
|
|
319
|
-
optional: property.optional,
|
|
320
|
-
});
|
|
342
|
+
entry.headers.push({ name, property });
|
|
321
343
|
}
|
|
322
344
|
}
|
|
323
345
|
if (streamedTypeOf(program, response.type) !== undefined)
|
|
@@ -325,27 +347,73 @@ function responsesOf(program, operation, registry) {
|
|
|
325
347
|
byStatus.set(key, entry);
|
|
326
348
|
}
|
|
327
349
|
}
|
|
328
|
-
return [...byStatus.values()]
|
|
329
|
-
.map((entry) => {
|
|
350
|
+
return [...byStatus.values()].map((entry) => {
|
|
330
351
|
const contentTypes = [...entry.contentTypes];
|
|
331
|
-
const binary = entry.body !== undefined && isBinaryPart(entry.body) && isRawBinaryMediaType(contentTypes);
|
|
332
352
|
return {
|
|
333
353
|
status: entry.status,
|
|
334
|
-
|
|
335
|
-
? undefined
|
|
336
|
-
: binary
|
|
337
|
-
? "z.unknown()"
|
|
338
|
-
: registry.expressionFor(entry.body),
|
|
354
|
+
body: entry.body,
|
|
339
355
|
contentTypes,
|
|
340
356
|
headers: entry.headers,
|
|
341
|
-
binary
|
|
357
|
+
binary: entry.file ||
|
|
358
|
+
(entry.body !== undefined &&
|
|
359
|
+
isBinaryPart(entry.body) &&
|
|
360
|
+
isRawBinaryMediaType(contentTypes)),
|
|
342
361
|
streamed: entry.streamed,
|
|
343
|
-
textual: entry.body !== undefined && isStringType(entry.body),
|
|
362
|
+
textual: !entry.file && entry.body !== undefined && isStringType(entry.body),
|
|
344
363
|
};
|
|
345
|
-
})
|
|
346
|
-
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/** Responses in OpenAPI's precedence order: exact codes, then ranges, then `default`. */
|
|
367
|
+
function inPrecedenceOrder(responses) {
|
|
368
|
+
return responses.toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
|
|
347
369
|
statusOrderOf(a.status) - statusOrderOf(b.status));
|
|
348
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* Every response the operation declares, rendered as Zod, in OpenAPI's precedence order.
|
|
373
|
+
*
|
|
374
|
+
* Every header is rendered before any body, and bodies in precedence order, which is the order this
|
|
375
|
+
* has always declared components in: the emitted files do not move because the resolution moved.
|
|
376
|
+
*
|
|
377
|
+
* Call inside `withVisibility(program, Visibility.Read, ...)`: a response is read at `Read`, and
|
|
378
|
+
* resolving it under a request visibility declares components under the wrong suffix.
|
|
379
|
+
*/
|
|
380
|
+
function responsesOf(program, operation, registry) {
|
|
381
|
+
const withHeaders = resolvedResponsesOf(program, operation).map((response) => ({
|
|
382
|
+
response,
|
|
383
|
+
headers: response.headers.map(({ name, property }) => ({
|
|
384
|
+
name,
|
|
385
|
+
/**
|
|
386
|
+
* **Encode-aware, as the schema beside it is.** `@encode(seconds, int32)` makes a `duration`
|
|
387
|
+
* an integer on the wire, and the header schema checks an integer; reading the declared type
|
|
388
|
+
* alone published `string`, so a handler satisfying its own result type was refused by its
|
|
389
|
+
* own header check and the arm could not be returned without a cast.
|
|
390
|
+
*/
|
|
391
|
+
type: propertyTypeToTs(program, property),
|
|
392
|
+
/**
|
|
393
|
+
* **The Zod expression, which used to be discarded here.** Every REQUEST parameter goes
|
|
394
|
+
* through `expressionForProperty` a few hundred lines below; a response header went
|
|
395
|
+
* through `typeToTs` and nothing else, so the emitted arm carried a name and an
|
|
396
|
+
* optionality and no way to check a value. A response body that breaks its schema throws
|
|
397
|
+
* `ResponseContractError`; a header that breaks its own was served.
|
|
398
|
+
*/
|
|
399
|
+
schema: registry.expressionForProperty(property),
|
|
400
|
+
optional: property.optional,
|
|
401
|
+
})),
|
|
402
|
+
}));
|
|
403
|
+
return inPrecedenceOrder(withHeaders.map(({ response, headers }) => ({ status: response.status, response, headers }))).map(({ response, headers }) => ({
|
|
404
|
+
status: response.status,
|
|
405
|
+
schema: response.body === undefined
|
|
406
|
+
? undefined
|
|
407
|
+
: response.binary
|
|
408
|
+
? "z.unknown()"
|
|
409
|
+
: registry.expressionFor(response.body),
|
|
410
|
+
contentTypes: response.contentTypes,
|
|
411
|
+
headers,
|
|
412
|
+
binary: response.binary,
|
|
413
|
+
streamed: response.streamed,
|
|
414
|
+
textual: response.textual,
|
|
415
|
+
}));
|
|
416
|
+
}
|
|
349
417
|
/**
|
|
350
418
|
* Where an arm sits in OpenAPI's own precedence order.
|
|
351
419
|
*
|
|
@@ -531,33 +599,6 @@ export function errorBodyOf(program, service) {
|
|
|
531
599
|
function bodyTypeOf(type) {
|
|
532
600
|
return isVoidType(type) || isNeverType(type) ? undefined : type;
|
|
533
601
|
}
|
|
534
|
-
/**
|
|
535
|
-
* The declared body type of the operation's success response, if it has one.
|
|
536
|
-
*
|
|
537
|
-
* **`void` and `never` ARE "no body", and reading them as types was a defect.** A response with
|
|
538
|
-
* no content reaches this in two shapes: as no body at all, which `undefined` already covered, and
|
|
539
|
-
* as a body whose type is `void` - which is what `tsp-openapi3` writes when it converts a `204`,
|
|
540
|
-
* and what `op remove(): void` means. The second used to be returned as a type, walked, and refused
|
|
541
|
-
* with `Cannot emit Zod for Intrinsic: 'void' has no runtime representation'`. That took out the
|
|
542
|
-
* Swagger Petstore, the most widely implemented reference API there is, on the round-trip suite's
|
|
543
|
-
* first run.
|
|
544
|
-
*
|
|
545
|
-
* One fact arriving by two roads. Answering it here rather than at each of the four call sites is
|
|
546
|
-
* what keeps them from drifting - `collectRoutes`, the contract types, the dual-status
|
|
547
|
-
* discriminator and the alternate-arm schema all want the same answer to the same question.
|
|
548
|
-
*/
|
|
549
|
-
function successBodyOf(operation, status) {
|
|
550
|
-
for (const response of operation.responses) {
|
|
551
|
-
if (response.statusCodes !== status)
|
|
552
|
-
continue;
|
|
553
|
-
for (const content of response.responses) {
|
|
554
|
-
if (content.body?.bodyKind !== "single")
|
|
555
|
-
continue;
|
|
556
|
-
return bodyTypeOf(content.body.type);
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
return undefined;
|
|
560
|
-
}
|
|
561
602
|
/**
|
|
562
603
|
* The media types this operation's success response can be served as.
|
|
563
604
|
*
|
|
@@ -793,7 +834,18 @@ function isBinaryPart(type) {
|
|
|
793
834
|
export function isRawBinaryMediaType(contentTypes) {
|
|
794
835
|
if (contentTypes.length === 0)
|
|
795
836
|
return false;
|
|
796
|
-
return contentTypes.every((type) =>
|
|
837
|
+
return contentTypes.every((type) => !isJsonMediaType(type));
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* A media type a server can parse a request body from into a value its schema can check: JSON and
|
|
841
|
+
* the `+json` suffix RFC 6839 defines, a URL-encoded form, and multipart. Anything else - XML, a
|
|
842
|
+
* vendor type - has no reading this emitter can derive from the document.
|
|
843
|
+
*/
|
|
844
|
+
function isParseableMediaType(type) {
|
|
845
|
+
const essence = mediaTypeEssenceOf(type);
|
|
846
|
+
return (isJsonMediaType(essence) ||
|
|
847
|
+
essence === "application/x-www-form-urlencoded" ||
|
|
848
|
+
essence.startsWith("multipart/"));
|
|
797
849
|
}
|
|
798
850
|
/** The declared request body type, if the operation takes one. */
|
|
799
851
|
function requestBodyOf(operation) {
|
|
@@ -968,9 +1020,52 @@ function mediaTypeDecoded(declared) {
|
|
|
968
1020
|
.map((entry) => entry.trim().replace(/^"|"$/g, ""));
|
|
969
1021
|
if (values === undefined || values.length === 0)
|
|
970
1022
|
return declared;
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1023
|
+
/**
|
|
1024
|
+
* **A declared media RANGE admits the types inside it, and a literal admits only itself.**
|
|
1025
|
+
*
|
|
1026
|
+
* \`@header contentType: "text/*"\` published \`z.literal("text/*")\`, so the only request that
|
|
1027
|
+
* satisfied it was one sending the range itself as its \`Content-Type\`, which no client does.
|
|
1028
|
+
* Measured before this existed: \`text/plain\` and \`text/html\` both answered 400 on a route whose
|
|
1029
|
+
* document declares the whole \`text\` group.
|
|
1030
|
+
*
|
|
1031
|
+
* A template literal is the Zod counterpart of the TypeScript type the contract already uses for
|
|
1032
|
+
* a range, so the validator and the handler's own type say the same thing.
|
|
1033
|
+
*/
|
|
1034
|
+
const admitted = values.map((value) => {
|
|
1035
|
+
/**
|
|
1036
|
+
* **The full wildcard admits every media type, so there is nothing left to match on.**
|
|
1037
|
+
*
|
|
1038
|
+
* Spelled as a template literal it becomes the prefix `*` + `/`, which matches only a
|
|
1039
|
+
* Content-Type whose text begins with the range's own first two characters. Measured on the
|
|
1040
|
+
* emitted schema: `application/json`, `text/plain` and `image/png` were all REFUSED and the
|
|
1041
|
+
* literal `*` + `/*` was the one value accepted, which is the defect the range branch exists
|
|
1042
|
+
* to remove rather than to respell. A consumer declares this on a file upload that takes
|
|
1043
|
+
* whatever the caller sends.
|
|
1044
|
+
*/
|
|
1045
|
+
if (value === "*/*")
|
|
1046
|
+
return "z.string()";
|
|
1047
|
+
return isMediaRange(value)
|
|
1048
|
+
? `z.templateLiteral([${JSON.stringify(mediaTypeEssenceOf(value).slice(0, -1))}, z.string()])`
|
|
1049
|
+
: `z.literal(${JSON.stringify(value)})`;
|
|
1050
|
+
});
|
|
1051
|
+
const schema = admitted.length === 1 ? (admitted[0] ?? declared) : `z.union([${admitted.join(", ")}])`;
|
|
1052
|
+
const ranged = values.some(isMediaRange) ? schema : declared;
|
|
1053
|
+
/**
|
|
1054
|
+
* **The request's media type is reduced to its essence, and so is every declared one.** A value
|
|
1055
|
+
* declared as its own essence - `application/json` - is the literal the essence is compared with.
|
|
1056
|
+
* One declared with a parameter or in capitals - `application/json; charset=utf-8` - is restored
|
|
1057
|
+
* from the essence, so the request is admitted and the handler is given the type as the document
|
|
1058
|
+
* declares it, which is what its input type says. Compared as written, such a value refused every
|
|
1059
|
+
* request: no request's essence carries a parameter.
|
|
1060
|
+
*/
|
|
1061
|
+
const restored = values.filter((value) => !isMediaRange(value) && mediaTypeEssenceOf(value) !== value);
|
|
1062
|
+
const essence = `(raw.split(";")[0] ?? "").trim().toLowerCase()`;
|
|
1063
|
+
const decoded = restored.length === 0
|
|
1064
|
+
? essence
|
|
1065
|
+
: `((essence) => ${restored
|
|
1066
|
+
.map((value) => `essence === ${JSON.stringify(mediaTypeEssenceOf(value))} ? ${JSON.stringify(value)} : `)
|
|
1067
|
+
.join("")}essence)(${essence})`;
|
|
1068
|
+
return `z.preprocess((raw) => (typeof raw === "string" ? ${decoded} : raw), ${ranged})`;
|
|
974
1069
|
}
|
|
975
1070
|
function parameterSchemasOf(program, operation, registry, segments,
|
|
976
1071
|
/** The names of a query string written into the route itself, which no parameter may gather. */
|
|
@@ -983,7 +1078,22 @@ literalQueryNames) {
|
|
|
983
1078
|
.filter((parameter) => parameter.type === "query")
|
|
984
1079
|
.map((parameter) => parameter.name);
|
|
985
1080
|
for (const parameter of operation.parameters.parameters) {
|
|
986
|
-
|
|
1081
|
+
/**
|
|
1082
|
+
* **`cookie` is here because `@typespec/http` puts it here**, and excluding it was silent.
|
|
1083
|
+
* `HttpOperationParameter` discriminates on `"header" | "cookie" | "query" | "path"`, and this
|
|
1084
|
+
* filter named three of the four, so a `@cookie` reached no validator, no handler input and no
|
|
1085
|
+
* diagnostic while `@typespec/openapi3` published it as `in: cookie, required: true`. A
|
|
1086
|
+
* requirement the document states and the server cannot see is the defect class this package
|
|
1087
|
+
* exists to remove, and a cookie is where a session and a CSRF token normally live.
|
|
1088
|
+
*
|
|
1089
|
+
* The exclusion was by omission rather than by a `never` check, which is why TypeScript never
|
|
1090
|
+
* objected; every other place that reads `parameter.type` handles an encoding question a cookie
|
|
1091
|
+
* does not have, since `CookieOptions` carries only a name.
|
|
1092
|
+
*/
|
|
1093
|
+
if (parameter.type !== "path" &&
|
|
1094
|
+
parameter.type !== "query" &&
|
|
1095
|
+
parameter.type !== "header" &&
|
|
1096
|
+
parameter.type !== "cookie") {
|
|
987
1097
|
continue;
|
|
988
1098
|
}
|
|
989
1099
|
/**
|
|
@@ -1096,12 +1206,20 @@ literalQueryNames) {
|
|
|
1096
1206
|
(byTarget["header-without-accept"] ??= []).push(entry);
|
|
1097
1207
|
}
|
|
1098
1208
|
}
|
|
1209
|
+
const fileContentType = fileContentTypeOf(operation);
|
|
1210
|
+
if (fileContentType !== undefined) {
|
|
1211
|
+
// The same decoding a declared `Content-Type` header gets: parameters dropped, lowercased.
|
|
1212
|
+
const entry = `\t${objectKey("Content-Type")}: ${mediaTypeDecoded(registry.expressionFor(fileContentType.type))},`;
|
|
1213
|
+
(byTarget["header"] ??= []).push(entry);
|
|
1214
|
+
(byTarget["header-without-accept"] ??= []).push(entry);
|
|
1215
|
+
}
|
|
1099
1216
|
const shape = (target) => {
|
|
1100
1217
|
const entries = byTarget[target];
|
|
1101
1218
|
return entries === undefined ? undefined : `z.object({\n${entries.join("\n")}\n})`;
|
|
1102
1219
|
};
|
|
1103
1220
|
const query = shape("query");
|
|
1104
1221
|
return {
|
|
1222
|
+
cookie: shape("cookie"),
|
|
1105
1223
|
path: shape("path"),
|
|
1106
1224
|
query: query === undefined
|
|
1107
1225
|
? undefined
|
|
@@ -1132,6 +1250,178 @@ function modelPropertiesOf(model) {
|
|
|
1132
1250
|
}
|
|
1133
1251
|
return chain.flatMap((current) => [...current.properties.values()]);
|
|
1134
1252
|
}
|
|
1253
|
+
/**
|
|
1254
|
+
* **The media type an `Http.File` body declares, as the input it is.** A file's `contentType` is the
|
|
1255
|
+
* request's `Content-Type`: `Http.File<"image/png" | "image/jpeg">` accepts either and the handler has
|
|
1256
|
+
* to know which arrived, exactly as it would from a declared `@header contentType`. `@typespec/http`
|
|
1257
|
+
* keeps it on the file model rather than among the operation's parameters, so without this a
|
|
1258
|
+
* handler for a two-type upload had the bytes and no way to tell what they were.
|
|
1259
|
+
*
|
|
1260
|
+
* `undefined` where the body is not a file, or where the operation already declares the header.
|
|
1261
|
+
*/
|
|
1262
|
+
function fileContentTypeOf(operation) {
|
|
1263
|
+
const body = operation.parameters.body;
|
|
1264
|
+
if (body?.bodyKind !== "file")
|
|
1265
|
+
return undefined;
|
|
1266
|
+
const declared = operation.parameters.parameters.some((parameter) => parameter.type === "header" && parameter.name.toLowerCase() === "content-type");
|
|
1267
|
+
return declared ? undefined : body.contentTypeProperty;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Where a request body reaches a handler's input: spread into it, named under a property, or handed
|
|
1271
|
+
* over unread.
|
|
1272
|
+
*
|
|
1273
|
+
* **One decision, read by every artefact that states an operation's input.** The server emitters
|
|
1274
|
+
* render a handler's input from {@link EmittedRoute.bodyProperty}; the contract types in
|
|
1275
|
+
* `requests.gen.ts` re-derived the input for themselves and always merged, so for every body this
|
|
1276
|
+
* names - an indexer, an optional body, a scalar, enum or union - `WireInputs` described an input no
|
|
1277
|
+
* handler receives.
|
|
1278
|
+
*/
|
|
1279
|
+
/**
|
|
1280
|
+
* The keys a merged body would contribute to the handler's input: each property of the body SENT,
|
|
1281
|
+
* at the operation's request visibility, under its wire name, or each named part of a multipart
|
|
1282
|
+
* body. A read-only property is not sent, so it contributes nothing.
|
|
1283
|
+
*/
|
|
1284
|
+
function mergedBodyKeysOf(program, operation) {
|
|
1285
|
+
const body = operation.parameters.body;
|
|
1286
|
+
if (body?.bodyKind === "multipart") {
|
|
1287
|
+
return body.parts.flatMap((part) => (part.name === undefined ? [] : [part.name]));
|
|
1288
|
+
}
|
|
1289
|
+
const type = requestBodyOf(operation);
|
|
1290
|
+
if (type?.kind !== "Model")
|
|
1291
|
+
return [];
|
|
1292
|
+
return withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => [...walkPropertiesInherited(type)]
|
|
1293
|
+
.filter((property) => isPayloadProperty(program, property))
|
|
1294
|
+
.map((property) => resolveEncodedName(program, property, "application/json")));
|
|
1295
|
+
}
|
|
1296
|
+
function requestBodyPlacementOf(program, operation) {
|
|
1297
|
+
const requestType = requestBodyOf(operation);
|
|
1298
|
+
const bodyParameter = operation.parameters.body;
|
|
1299
|
+
const contentTypes = [...(bodyParameter?.contentTypes ?? [])];
|
|
1300
|
+
/**
|
|
1301
|
+
* A `bytes` body means the BYTES are the contract.
|
|
1302
|
+
*
|
|
1303
|
+
* A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
|
|
1304
|
+
* verify a different string and every delivery would fail authentication.
|
|
1305
|
+
*/
|
|
1306
|
+
const bytes = requestType?.kind === "Scalar" && requestType.name === "bytes";
|
|
1307
|
+
/**
|
|
1308
|
+
* **A body declared only under media types nothing can parse into it is handed over raw.** A
|
|
1309
|
+
* model as `application/xml` has no serialisation this emitter can derive, and the server has no
|
|
1310
|
+
* reader for it; it used to get a header check and then call the handler with the body never
|
|
1311
|
+
* read, while the warning beside it said such requests were refused. Handed over as it arrived,
|
|
1312
|
+
* the handler parses it - exactly what the response side already asks of a handler serving a model
|
|
1313
|
+
* as XML. It is the text under a textual media type and the bytes under any other
|
|
1314
|
+
* (`rawBodyBinary`). A string body is not this: it IS text, and its schema still checks it
|
|
1315
|
+
* (`requestTextual`).
|
|
1316
|
+
*/
|
|
1317
|
+
const unparseable = bodyParameter?.bodyKind === "single" &&
|
|
1318
|
+
requestType !== undefined &&
|
|
1319
|
+
!bytes &&
|
|
1320
|
+
!isStringType(requestType) &&
|
|
1321
|
+
contentTypes.length > 0 &&
|
|
1322
|
+
contentTypes.every((type) => !isParseableMediaType(type));
|
|
1323
|
+
/**
|
|
1324
|
+
* **An `Http.File` body is raw too: the file's contents ARE the body.** `@typespec/openapi3`
|
|
1325
|
+
* publishes it as `content: { "image/png": { schema: { contentMediaType: "image/png" } } }` - a
|
|
1326
|
+
* media type and no type, nothing to parse and nothing to validate - which is exactly a `bytes`
|
|
1327
|
+
* body under that media type. `requestBodyOf` reads only `bodyKind === "single"`, so a file body
|
|
1328
|
+
* reached no placement at all: the handler was passed `{}` and the upload was dropped, from a
|
|
1329
|
+
* compile that reported success, on all four uploads in `type/file`.
|
|
1330
|
+
*/
|
|
1331
|
+
const rawBodyProperty = bytes || bodyParameter?.bodyKind === "file" || unparseable
|
|
1332
|
+
? (bodyParameter?.property?.name ?? "body")
|
|
1333
|
+
: undefined;
|
|
1334
|
+
/**
|
|
1335
|
+
* A body with an indexer is named rather than spread - see `bodyProperty`. Read from the
|
|
1336
|
+
* resolved model's own indexer rather than from the emitted schema text, which would be a
|
|
1337
|
+
* decision made by pattern-matching generated output.
|
|
1338
|
+
*/
|
|
1339
|
+
/**
|
|
1340
|
+
* **An OPTIONAL body is named for the same reason an indexed one is**: the merge cannot
|
|
1341
|
+
* express "absent". See {@link EmittedRoute.optionalBody}.
|
|
1342
|
+
*/
|
|
1343
|
+
const optionalBody = bodyParameter?.property?.optional === true;
|
|
1344
|
+
/**
|
|
1345
|
+
* **An optional MULTIPART body reaches the same rule by a second route, because
|
|
1346
|
+
* `requestType` cannot see it.** `requestBodyOf` resolves a type only for
|
|
1347
|
+
* `bodyKind === "single"`, so `requestType` is `undefined` for every multipart operation
|
|
1348
|
+
* and the `optionalBody` disjunct above was unreachable however the document declared it.
|
|
1349
|
+
*
|
|
1350
|
+
* The emitted server for such an operation did not compile. Measured, and the mechanism is
|
|
1351
|
+
* worth stating because it is not a union: spreading a value typed `Parts | undefined`
|
|
1352
|
+
* makes every part OPTIONAL, so `{ file?: ...; pages?: ... }` was passed where
|
|
1353
|
+
* `{ file: ...; pages: ... }` was required. `TS2345`, inside `app.gen.ts` itself.
|
|
1354
|
+
*
|
|
1355
|
+
* That is the failure {@link EmittedRoute.optionalBody} predicts in prose - a merge has no
|
|
1356
|
+
* way to say "these properties are here only sometimes" without making every one of them
|
|
1357
|
+
* optional, which is a weaker and different claim about the body that IS sent. So this is
|
|
1358
|
+
* the stated rule reaching the one body kind it had never been applied to, not a new rule.
|
|
1359
|
+
*
|
|
1360
|
+
* **The REQUIRED arm is deliberately excluded.** The indexed reason does not apply to a
|
|
1361
|
+
* parts model, so a required multipart body merges as safely as a required single one, and
|
|
1362
|
+
* `typespec-hono/test/filepart/` compiles a handler that reads `input.file` directly.
|
|
1363
|
+
* Widening this to "multipart is always named" would rewrite that directory for no defect.
|
|
1364
|
+
*
|
|
1365
|
+
* This is the third hole the `"single"` gate has produced, after `multipartSchemaOf` and
|
|
1366
|
+
* `multipartTsOf`, both recorded above.
|
|
1367
|
+
*/
|
|
1368
|
+
const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
|
|
1369
|
+
/**
|
|
1370
|
+
* **A body that is not a MODEL has no properties to merge, and merging it anyway lost it.**
|
|
1371
|
+
*
|
|
1372
|
+
* `@body body: string` emitted a handler input of `Record<string, never>` and a call site of
|
|
1373
|
+
* `{}`: the server validated the body and then never handed it to the handler, silently, from
|
|
1374
|
+
* a compile that reported success. `@body body: SomeEnum` was the same rule failing loudly,
|
|
1375
|
+
* `TS2698: Spread types may only be created from object types`, which is what
|
|
1376
|
+
* `payload/media-type`, `type/enum/*`, `type/scalar` and `versioning/returnTypeChangedFrom`
|
|
1377
|
+
* had been pinned on in the conformance corpus.
|
|
1378
|
+
*
|
|
1379
|
+
* A scalar, an enum, a union or a literal body is therefore NAMED, which is the treatment an
|
|
1380
|
+
* indexed or optional body already gets and for the same reason: the merge cannot express it.
|
|
1381
|
+
*
|
|
1382
|
+
* **Except a `bytes` body**, which {@link EmittedRoute.rawBodyProperty} already names, and
|
|
1383
|
+
* naming it twice would put the same value under two keys.
|
|
1384
|
+
*/
|
|
1385
|
+
const unmergeableBody = requestType !== undefined && requestType.kind !== "Model" && rawBodyProperty === undefined;
|
|
1386
|
+
/**
|
|
1387
|
+
* **A body sharing a key with a parameter is named, because merged one value replaces the
|
|
1388
|
+
* other.** `PUT /renames/{name}` with a body `{ name }` handed the handler one `name`, the body's,
|
|
1389
|
+
* and the path's was lost with nothing reported - in `parameters/spread`'s composite request, and
|
|
1390
|
+
* in any spec whose body repeats a path or query parameter. The document states the two
|
|
1391
|
+
* separately; named, the handler is given both. A read-only property is not in the body sent, so
|
|
1392
|
+
* `@path id` beside a model whose `id` is read-only collides with nothing and still merges.
|
|
1393
|
+
*/
|
|
1394
|
+
const parameterNames = new Set(operation.parameters.parameters.map((parameter) => parameter.name));
|
|
1395
|
+
const collides = rawBodyProperty === undefined &&
|
|
1396
|
+
mergedBodyKeysOf(program, operation).some((key) => parameterNames.has(key));
|
|
1397
|
+
const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
|
|
1398
|
+
optionalMultipartBody ||
|
|
1399
|
+
unmergeableBody ||
|
|
1400
|
+
collides
|
|
1401
|
+
? (bodyParameter?.property?.name ?? "body")
|
|
1402
|
+
: undefined;
|
|
1403
|
+
/**
|
|
1404
|
+
* A file is handed over as a stream of bytes whatever its media type. `@typespec/http` lets an
|
|
1405
|
+
* emitter represent a textual file "as strings or streams of textual characters", and one
|
|
1406
|
+
* representation for every file is what lets a file declared `application/json` be told from a
|
|
1407
|
+
* JSON body - which the media type alone cannot do.
|
|
1408
|
+
*
|
|
1409
|
+
* **A body nothing parses is bytes unless every type it is declared under is text.** Decoded as
|
|
1410
|
+
* text, `application/octet-stream` lost every byte that is not valid UTF-8; see
|
|
1411
|
+
* {@link isTextualMediaType}.
|
|
1412
|
+
*/
|
|
1413
|
+
const rawBodyBinary = rawBodyProperty !== undefined &&
|
|
1414
|
+
(unparseable
|
|
1415
|
+
? !contentTypes.every(isTextualMediaType)
|
|
1416
|
+
: bodyParameter?.bodyKind === "file" || isRawBinaryMediaType(contentTypes));
|
|
1417
|
+
return {
|
|
1418
|
+
rawBodyProperty,
|
|
1419
|
+
rawBodyBinary,
|
|
1420
|
+
rawBodyUnvalidated: unparseable,
|
|
1421
|
+
bodyProperty,
|
|
1422
|
+
optionalBody,
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1135
1425
|
export function collectRoutes(program, registry, service) {
|
|
1136
1426
|
const routes = [];
|
|
1137
1427
|
{
|
|
@@ -1161,73 +1451,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1161
1451
|
// `@requestVisibility` when the operation states one, otherwise the verb's default - the
|
|
1162
1452
|
// same resolution `@typespec/openapi3` performs before emitting a request schema.
|
|
1163
1453
|
const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
|
|
1164
|
-
|
|
1165
|
-
* A `bytes` body means the BYTES are the contract.
|
|
1166
|
-
*
|
|
1167
|
-
* A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
|
|
1168
|
-
* verify a different string and every delivery would fail authentication.
|
|
1169
|
-
*/
|
|
1170
|
-
const bodyParameter = operation.parameters.body;
|
|
1171
|
-
const rawBodyProperty = requestType?.kind === "Scalar" && requestType.name === "bytes"
|
|
1172
|
-
? (bodyParameter?.property?.name ?? "body")
|
|
1173
|
-
: undefined;
|
|
1174
|
-
/**
|
|
1175
|
-
* A body with an indexer is named rather than spread - see `bodyProperty`. Read from the
|
|
1176
|
-
* resolved model's own indexer rather than from the emitted schema text, which would be a
|
|
1177
|
-
* decision made by pattern-matching generated output.
|
|
1178
|
-
*/
|
|
1179
|
-
/**
|
|
1180
|
-
* **An OPTIONAL body is named for the same reason an indexed one is**: the merge cannot
|
|
1181
|
-
* express "absent". See {@link EmittedRoute.optionalBody}.
|
|
1182
|
-
*/
|
|
1183
|
-
const optionalBody = bodyParameter?.property?.optional === true;
|
|
1184
|
-
/**
|
|
1185
|
-
* **An optional MULTIPART body reaches the same rule by a second route, because
|
|
1186
|
-
* `requestType` cannot see it.** `requestBodyOf` resolves a type only for
|
|
1187
|
-
* `bodyKind === "single"`, so `requestType` is `undefined` for every multipart operation
|
|
1188
|
-
* and the `optionalBody` disjunct above was unreachable however the document declared it.
|
|
1189
|
-
*
|
|
1190
|
-
* The emitted server for such an operation did not compile. Measured, and the mechanism is
|
|
1191
|
-
* worth stating because it is not a union: spreading a value typed `Parts | undefined`
|
|
1192
|
-
* makes every part OPTIONAL, so `{ file?: ...; pages?: ... }` was passed where
|
|
1193
|
-
* `{ file: ...; pages: ... }` was required. `TS2345`, inside `app.gen.ts` itself.
|
|
1194
|
-
*
|
|
1195
|
-
* That is the failure {@link EmittedRoute.optionalBody} predicts in prose - a merge has no
|
|
1196
|
-
* way to say "these properties are here only sometimes" without making every one of them
|
|
1197
|
-
* optional, which is a weaker and different claim about the body that IS sent. So this is
|
|
1198
|
-
* the stated rule reaching the one body kind it had never been applied to, not a new rule.
|
|
1199
|
-
*
|
|
1200
|
-
* **The REQUIRED arm is deliberately excluded.** The indexed reason does not apply to a
|
|
1201
|
-
* parts model, so a required multipart body merges as safely as a required single one, and
|
|
1202
|
-
* `typespec-hono/test/filepart/` compiles a handler that reads `input.file` directly.
|
|
1203
|
-
* Widening this to "multipart is always named" would rewrite that directory for no defect.
|
|
1204
|
-
*
|
|
1205
|
-
* This is the third hole the `"single"` gate has produced, after `multipartSchemaOf` and
|
|
1206
|
-
* `multipartTsOf`, both recorded above.
|
|
1207
|
-
*/
|
|
1208
|
-
const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
|
|
1209
|
-
/**
|
|
1210
|
-
* **A body that is not a MODEL has no properties to merge, and merging it anyway lost it.**
|
|
1211
|
-
*
|
|
1212
|
-
* `@body body: string` emitted a handler input of `Record<string, never>` and a call site of
|
|
1213
|
-
* `{}`: the server validated the body and then never handed it to the handler, silently, from
|
|
1214
|
-
* a compile that reported success. `@body body: SomeEnum` was the same rule failing loudly,
|
|
1215
|
-
* `TS2698: Spread types may only be created from object types`, which is what
|
|
1216
|
-
* `payload/media-type`, `type/enum/*`, `type/scalar` and `versioning/returnTypeChangedFrom`
|
|
1217
|
-
* had been pinned on in the conformance corpus.
|
|
1218
|
-
*
|
|
1219
|
-
* A scalar, an enum, a union or a literal body is therefore NAMED, which is the treatment an
|
|
1220
|
-
* indexed or optional body already gets and for the same reason: the merge cannot express it.
|
|
1221
|
-
*
|
|
1222
|
-
* **Except a `bytes` body**, which {@link EmittedRoute.rawBodyProperty} already names, and
|
|
1223
|
-
* naming it twice would put the same value under two keys.
|
|
1224
|
-
*/
|
|
1225
|
-
const unmergeableBody = requestType !== undefined && requestType.kind !== "Model" && rawBodyProperty === undefined;
|
|
1226
|
-
const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
|
|
1227
|
-
optionalMultipartBody ||
|
|
1228
|
-
unmergeableBody
|
|
1229
|
-
? (bodyParameter?.property?.name ?? "body")
|
|
1230
|
-
: undefined;
|
|
1454
|
+
const { rawBodyProperty, rawBodyBinary, rawBodyUnvalidated, bodyProperty, optionalBody } = requestBodyPlacementOf(program, operation);
|
|
1231
1455
|
const authentication = authenticationFor(program, operation);
|
|
1232
1456
|
const template = routeTemplateOf(operation.uriTemplate, operation.parameters.parameters
|
|
1233
1457
|
.filter((parameter) => parameter.type === "path")
|
|
@@ -1299,6 +1523,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1299
1523
|
...(() => {
|
|
1300
1524
|
const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
|
|
1301
1525
|
return {
|
|
1526
|
+
cookieSchema: split.cookie,
|
|
1302
1527
|
pathSchema: split.path,
|
|
1303
1528
|
querySchema: split.query,
|
|
1304
1529
|
queryFieldsSchema: split.queryFields,
|
|
@@ -1315,6 +1540,8 @@ export function collectRoutes(program, registry, service) {
|
|
|
1315
1540
|
*/
|
|
1316
1541
|
responses: withVisibility(program, Visibility.Read, () => responsesOf(program, operation, registry)),
|
|
1317
1542
|
rawBodyProperty,
|
|
1543
|
+
rawBodyBinary,
|
|
1544
|
+
rawBodyUnvalidated,
|
|
1318
1545
|
bodyProperty,
|
|
1319
1546
|
optionalBody,
|
|
1320
1547
|
scopes: authentication.scopes,
|
|
@@ -1452,13 +1679,30 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
|
|
|
1452
1679
|
* parse - the walk captures the reference while it is still in the temporal dead zone, so the
|
|
1453
1680
|
* failure surfaces at request time rather than at import.
|
|
1454
1681
|
*
|
|
1455
|
-
* **
|
|
1456
|
-
*
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1459
|
-
*
|
|
1682
|
+
* **CYCLE MEMBERSHIP decides this, not deferral, and that took a zod upgrade to learn.**
|
|
1683
|
+
*
|
|
1684
|
+
* It was keyed on `annotated` - set exactly when the declaration is deferred, which is exactly
|
|
1685
|
+
* when `z.lazy()` is written - on the measurement that a model whose back edge sits on a GETTER
|
|
1686
|
+
* compiled and parsed fine. That was true on zod 4.5.2 and is false on 4.6.5, which made the
|
|
1687
|
+
* recursive parse state weak. The failure moved from the deferred declaration to the one that
|
|
1688
|
+
* REFERENCES it, which was still being compiled:
|
|
1689
|
+
*
|
|
1690
|
+
* ```
|
|
1691
|
+
* const branchSchema = z.lazy(() => z.union([nodeSchema, z.string()]));
|
|
1692
|
+
* const nodeSchema = z.compile(z.strictObject({ ..., branch: branchSchema.exactOptional() }));
|
|
1693
|
+
* nodeSchema.safeParse({ label: "a", attributes: {} });
|
|
1694
|
+
* zod 4.5.2: true
|
|
1695
|
+
* zod 4.6.5: TypeError: Cannot read properties of undefined (reading '_zod')
|
|
1696
|
+
* ```
|
|
1697
|
+
*
|
|
1698
|
+
* Bisected: naming a compiled schema from inside a `z.lazy()` body is the whole trigger, and
|
|
1699
|
+
* the self-referencing getter is not needed for it. So the same rule the registry already
|
|
1700
|
+
* applies to structural types applies here - every member of the cycle, not only the deferred
|
|
1701
|
+
* one - and `structural` is the fact it already holds for exactly that set.
|
|
1702
|
+
*
|
|
1703
|
+
* The peer range admits 4.6, so this reached a running server on the first parse.
|
|
1460
1704
|
*/
|
|
1461
|
-
return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.
|
|
1705
|
+
return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.structural === undefined)};\n`;
|
|
1462
1706
|
});
|
|
1463
1707
|
/**
|
|
1464
1708
|
* **Written only when something in the file names it**, like the `ResponseArm` import below and for
|
|
@@ -1490,10 +1734,9 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
|
|
|
1490
1734
|
* `p?: T` directly - at every depth, which a shallow mapped type never reached - so the narrowing
|
|
1491
1735
|
* happens once, in the schema, and this helper has nothing left to do.
|
|
1492
1736
|
*
|
|
1493
|
-
* The contract types in `requests.gen.ts`
|
|
1494
|
-
*
|
|
1495
|
-
*
|
|
1496
|
-
* Narrowing THAT surface was tried in `0.19.0` and reverted - it broke `(ctx, input) => ok(input)`.
|
|
1737
|
+
* The contract types in `requests.gen.ts` say the same since `0.28.0`: `?: T`, and
|
|
1738
|
+
* `?: T | undefined` only where `.default()` accepts an explicit `undefined`. They had kept it
|
|
1739
|
+
* everywhere on reasons that stopped holding once this schema moved - see `propertyToTs`.
|
|
1497
1740
|
*/
|
|
1498
1741
|
const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
|
|
1499
1742
|
const parts = [generatedBanner()];
|
|
@@ -1633,6 +1876,7 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
1633
1876
|
const query = declare("Query", gathers && queryFields !== undefined && route.queryFieldsSchema !== undefined
|
|
1634
1877
|
? route.querySchema?.replace(route.queryFieldsSchema, queryFields)
|
|
1635
1878
|
: route.querySchema);
|
|
1879
|
+
const cookie = declare("Cookie", route.cookieSchema);
|
|
1636
1880
|
const negotiating = shared.has(`${route.verb} ${route.path}`);
|
|
1637
1881
|
const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
|
|
1638
1882
|
// A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
|
|
@@ -1643,13 +1887,28 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
1643
1887
|
*/
|
|
1644
1888
|
const declaredBodies = new Map();
|
|
1645
1889
|
const arms = route.responses.map((response) => {
|
|
1646
|
-
|
|
1647
|
-
|
|
1890
|
+
const suffix = response.status === "default" ? "Default" : String(response.status);
|
|
1891
|
+
/**
|
|
1892
|
+
* The headers this status declares, as one object schema, so a server can check what it is
|
|
1893
|
+
* about to send the way it already checks the body.
|
|
1894
|
+
*
|
|
1895
|
+
* **A plain `z.object`, which strips.** A response carries headers the document does not
|
|
1896
|
+
* declare - `Content-Type` among them, which the server sets itself - and those are not a
|
|
1897
|
+
* contract violation. What is checked is that every header the document DOES declare carries
|
|
1898
|
+
* a value the document permits.
|
|
1899
|
+
*/
|
|
1900
|
+
const headers = response.headers.length === 0
|
|
1901
|
+
? undefined
|
|
1902
|
+
: declare(`Response${suffix}Headers`, `z.object({\n${response.headers
|
|
1903
|
+
.map((header) => `\t${objectKey(header.name)}: ${header.schema},`)
|
|
1904
|
+
.join("\n")}\n})`);
|
|
1905
|
+
if (response.schema === undefined) {
|
|
1906
|
+
return { status: response.status, schema: undefined, headers };
|
|
1907
|
+
}
|
|
1648
1908
|
if (!declaredBodies.has(response.schema)) {
|
|
1649
|
-
const suffix = response.status === "default" ? "Default" : String(response.status);
|
|
1650
1909
|
declaredBodies.set(response.schema, declare(`Response${suffix}`, response.schema));
|
|
1651
1910
|
}
|
|
1652
|
-
return { status: response.status, schema: declaredBodies.get(response.schema) };
|
|
1911
|
+
return { status: response.status, schema: declaredBodies.get(response.schema), headers };
|
|
1653
1912
|
});
|
|
1654
1913
|
const responses = schemaConst(route.operationId, "Responses");
|
|
1655
1914
|
declarations.push(`export const ${responses} = ${responseArmsOf(route, arms)} satisfies readonly ResponseArm[];`);
|
|
@@ -1659,6 +1918,8 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
1659
1918
|
query,
|
|
1660
1919
|
queryFields: gathers ? queryFields : query,
|
|
1661
1920
|
header,
|
|
1921
|
+
selectedAccept: negotiating ? route.accept : undefined,
|
|
1922
|
+
cookie,
|
|
1662
1923
|
body,
|
|
1663
1924
|
arms,
|
|
1664
1925
|
responses,
|
|
@@ -1691,118 +1952,127 @@ function responseArmsOf(route, arms) {
|
|
|
1691
1952
|
});
|
|
1692
1953
|
return `[${rendered.join(", ")}]`;
|
|
1693
1954
|
}
|
|
1694
|
-
const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
1695
1955
|
/**
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
1956
|
+
* Every operation's input, as TypeScript, exactly as a handler receives it.
|
|
1957
|
+
*
|
|
1958
|
+
* **One placement rule, the one the server emitters render from.** A model body is merged with the
|
|
1959
|
+
* path, query, header and cookie parameters; a body the merge cannot express is named under its
|
|
1960
|
+
* parameter's name, optional where the document lets it be absent; a `bytes` body is handed over
|
|
1961
|
+
* unread. {@link requestBodyPlacementOf} decides all three, for this walk and for
|
|
1962
|
+
* {@link EmittedRoute} alike. This walk used to merge every body, so for a union, an array, an
|
|
1963
|
+
* indexed model, an optional body, a scalar or an enum, `WireInputs` described an input no handler is
|
|
1964
|
+
* given - and one consumer carried a shim re-flattening the handler's input to reach a backend typed
|
|
1965
|
+
* by it, after the divergence cost it 171 failing tests.
|
|
1966
|
+
*
|
|
1967
|
+
* **Every operation, whatever it takes and whatever it answers.** An operation with no input is
|
|
1968
|
+
* `Record<string, never>`, which is what a handler for it is passed; one whose only success is a
|
|
1969
|
+
* range is as much an operation as any other. Both used to be skipped, so `keyof WireInputs` could
|
|
1970
|
+
* not enumerate a document's operations, and a consumer named the missing ones by hand.
|
|
1698
1971
|
*
|
|
1699
1972
|
* **This used to drop any parameter literally named `companyId`.** One application carried its
|
|
1700
1973
|
* tenant identifier in a context object rather than in the payload, and the emitter was taught to
|
|
1701
1974
|
* match - so for every consumer, a path parameter with that name vanished from the generated input
|
|
1702
1975
|
* type while the Zod validator went on requiring it. Two artefacts describing different shapes, from
|
|
1703
1976
|
* a rule no document states.
|
|
1704
|
-
*
|
|
1705
|
-
* It is the same defect class this emitter exists to delete, and it hid better than most: the
|
|
1706
|
-
* emitted assertions pair the validator against the contract types for MODELS, so a disagreement
|
|
1707
|
-
* about a merged input type is not among the things they compare.
|
|
1708
1977
|
*/
|
|
1978
|
+
/** A body type the contract declares by name, rather than spells inline: a model, union or enum. */
|
|
1979
|
+
function declaresComponent(type) {
|
|
1980
|
+
return type.kind === "Model" || type.kind === "Union" || type.kind === "Enum";
|
|
1981
|
+
}
|
|
1709
1982
|
function collectRequestTypes(program, registry, service) {
|
|
1710
1983
|
const entries = [];
|
|
1711
|
-
{
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1984
|
+
for (const operation of service.operations) {
|
|
1985
|
+
/**
|
|
1986
|
+
* **Walked at the operation's REQUEST visibility, which is the whole point of this file.**
|
|
1987
|
+
*
|
|
1988
|
+
* `resolveRequestVisibility` is `@typespec/http`'s own answer - POST is Create, PATCH is
|
|
1989
|
+
* Update, PUT is CreateOrUpdate - and it is what the Zod walk already uses at the route level.
|
|
1990
|
+
* Without it this walk ran at the default `Read`, so a POST body's contract type carried the
|
|
1991
|
+
* canonical property set while the validator beside it accepted the create projection. A
|
|
1992
|
+
* caller satisfying the published type sent read-only fields and a strict validator answered
|
|
1993
|
+
* 400.
|
|
1994
|
+
*
|
|
1995
|
+
* It is also what keeps `metadataInfo` scoped: the predicates `propertyToTs` consults live
|
|
1996
|
+
* behind `withVisibility`, and calling them outside one leaves a module-level singleton built
|
|
1997
|
+
* for the wrong program.
|
|
1998
|
+
*/
|
|
1999
|
+
withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => {
|
|
2000
|
+
const body = requestBodyOf(operation);
|
|
2001
|
+
const placement = requestBodyPlacementOf(program, operation);
|
|
2002
|
+
/** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
|
|
2003
|
+
const parameterBody = operation.parameters.body;
|
|
1715
2004
|
/**
|
|
1716
|
-
*
|
|
1717
|
-
*
|
|
1718
|
-
* `resolveRequestVisibility` is `@typespec/http`'s own answer - POST is Create, PATCH is
|
|
1719
|
-
* Update, PUT is CreateOrUpdate - and it is what the Zod walk already uses at the route
|
|
1720
|
-
* level. Without it this walk ran at the default `Read`, so a POST body's contract type
|
|
1721
|
-
* carried the canonical property set while the validator beside it accepted the create
|
|
1722
|
-
* projection. A caller satisfying the published type sent read-only fields and a strict
|
|
1723
|
-
* validator answered 400.
|
|
2005
|
+
* Headers are part of the input too.
|
|
1724
2006
|
*
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
1727
|
-
*
|
|
2007
|
+
* The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
|
|
2008
|
+
* beside the payload, they are arguments to the verification. Leaving them out of the wire
|
|
2009
|
+
* type would describe an input the application cannot act on.
|
|
1728
2010
|
*/
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
2011
|
+
const extraProperties = operation.parameters.parameters
|
|
2012
|
+
.filter((parameter) => parameter.type === "path" ||
|
|
2013
|
+
parameter.type === "query" ||
|
|
2014
|
+
parameter.type === "header" ||
|
|
2015
|
+
// The second half of the same omission: a validator with no matching type.
|
|
2016
|
+
parameter.type === "cookie")
|
|
1734
2017
|
/**
|
|
1735
|
-
*
|
|
1736
|
-
*
|
|
1737
|
-
*
|
|
1738
|
-
*
|
|
1739
|
-
* type would describe an input the application cannot act on.
|
|
2018
|
+
* Named as the WIRE names it, which is what the emitted validator keys on.
|
|
2019
|
+
* `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
|
|
2020
|
+
* `"x-thing"` here too: a consumer checking its handlers against these types otherwise
|
|
2021
|
+
* fails to typecheck against a server that is correct.
|
|
1740
2022
|
*/
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
*/
|
|
1751
|
-
.map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
|
|
1752
|
-
// `return` rather than `continue`: this body is a callback now, and returning from it
|
|
1753
|
-
// skips the operation exactly as the `continue` did.
|
|
1754
|
-
if (body === undefined && extraProperties.length === 0)
|
|
1755
|
-
return;
|
|
2023
|
+
.map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
|
|
2024
|
+
const fileContentType = fileContentTypeOf(operation);
|
|
2025
|
+
if (fileContentType !== undefined) {
|
|
2026
|
+
extraProperties.push(`${objectKey("Content-Type")}: ${registry.expressionFor(fileContentType.type)};`);
|
|
2027
|
+
}
|
|
2028
|
+
const parts = [];
|
|
2029
|
+
let streamsBody = false;
|
|
2030
|
+
let bodyRef;
|
|
2031
|
+
if (placement.rawBodyProperty !== undefined) {
|
|
1756
2032
|
/**
|
|
1757
|
-
* A
|
|
1758
|
-
*
|
|
1759
|
-
*
|
|
1760
|
-
*
|
|
2033
|
+
* **A binary body is the unread stream, and is spelled as one without naming
|
|
2034
|
+
* `ReadableStream`.** The server hands it over unread, so a backend it is forwarded to
|
|
2035
|
+
* receives a stream; a contract saying `ArrayBuffer` made a gateway read a 25 MiB upload
|
|
2036
|
+
* into an isolate to satisfy it. See `BYTE_STREAM`. A `bytes` body under a JSON type is
|
|
2037
|
+
* the text a signature covers, and arrives as a string.
|
|
1761
2038
|
*/
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
: undefined;
|
|
2039
|
+
streamsBody = placement.rawBodyBinary;
|
|
2040
|
+
parts.push(`{\n\t${objectKey(placement.rawBodyProperty)}: ${streamsBody ? "ByteStream | null" : "string"};\n}`);
|
|
1765
2041
|
/**
|
|
1766
|
-
* **
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1770
|
-
* This type said `string` for every `bytes` body whatever it was served as. A server reading
|
|
1771
|
-
* an `application/octet-stream` body hands the handler bytes, so a consumer checking its
|
|
1772
|
-
* handlers against these types could not typecheck against a correct server. It is the
|
|
1773
|
-
* request half of the corruption already fixed on the reader: decoding raw bytes as text
|
|
1774
|
-
* replaces every byte outside ASCII, and typing them as text is what invites it.
|
|
2042
|
+
* **The model a body nothing parses names is still declared**, as the document declares
|
|
2043
|
+
* it, though the input is the text or the bytes that arrived. 0.27.0 declared it, and a
|
|
2044
|
+
* consumer importing it lost the import when the body became raw.
|
|
1775
2045
|
*/
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
}
|
|
2046
|
+
if (body !== undefined && declaresComponent(body))
|
|
2047
|
+
registry.expressionFor(body);
|
|
2048
|
+
}
|
|
2049
|
+
else {
|
|
2050
|
+
const expression = parameterBody?.bodyKind === "multipart"
|
|
2051
|
+
? multipartTsOf({ parts: parameterBody.parts }, registry)
|
|
2052
|
+
: body === undefined
|
|
2053
|
+
? undefined
|
|
2054
|
+
: registry.expressionFor(body);
|
|
2055
|
+
if (parameterBody?.bodyKind !== "multipart")
|
|
2056
|
+
bodyRef = expression;
|
|
2057
|
+
if (expression !== undefined) {
|
|
2058
|
+
parts.push(placement.bodyProperty === undefined
|
|
2059
|
+
? expression
|
|
2060
|
+
: `{\n\t${objectKey(placement.bodyProperty)}${placement.optionalBody ? "?" : ""}: ${expression.replaceAll("\n", "\n\t")};\n}`);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
if (extraProperties.length > 0) {
|
|
2064
|
+
parts.push(`{\n${extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
|
|
2065
|
+
}
|
|
2066
|
+
entries.push({
|
|
2067
|
+
operationId: operationIdOf(program, operation.operation),
|
|
2068
|
+
bodyRef,
|
|
2069
|
+
input: parts.length === 0 ? "Record<string, never>" : `Simplify<${parts.join(" & ")}>`,
|
|
2070
|
+
streamsBody,
|
|
1792
2071
|
});
|
|
1793
|
-
}
|
|
2072
|
+
});
|
|
1794
2073
|
}
|
|
1795
2074
|
return entries;
|
|
1796
2075
|
}
|
|
1797
|
-
/**
|
|
1798
|
-
* The success response every operation declares, so a producer can be held to producing it.
|
|
1799
|
-
*
|
|
1800
|
-
* **Pinned fields only - these are PERMISSIVE shapes.** The gateway names the fields it depends on
|
|
1801
|
-
* and lets a richer view through untouched, so this is a floor and not an equality: the
|
|
1802
|
-
* domain's result must CONTAIN what the document promises, and may contain more. Asserting equality
|
|
1803
|
-
* would turn every field a producer legitimately adds into a build failure, which is the same mistake
|
|
1804
|
-
* as making a response schema strict.
|
|
1805
|
-
*/
|
|
1806
2076
|
function collectResponseTypes(program, registry, service) {
|
|
1807
2077
|
const entries = [];
|
|
1808
2078
|
/**
|
|
@@ -1815,20 +2085,135 @@ function collectResponseTypes(program, registry, service) {
|
|
|
1815
2085
|
*/
|
|
1816
2086
|
withVisibility(program, Visibility.Read, () => {
|
|
1817
2087
|
for (const operation of service.operations) {
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
2088
|
+
/**
|
|
2089
|
+
* **The same responses, and the same members, the server emitters render.** This walk used
|
|
2090
|
+
* to ask for the lowest success status and its body, so an operation answering `200 Thing`
|
|
2091
|
+
* or `201 Other` published `Thing`, one answering `200 Thing` or `204` published `Thing`,
|
|
2092
|
+
* and one answering only `204` was absent - while the runtime beside it carried every arm.
|
|
2093
|
+
*/
|
|
2094
|
+
const responses = inPrecedenceOrder(resolvedResponsesOf(program, operation));
|
|
2095
|
+
const hasBody = (response) => response.body !== undefined;
|
|
2096
|
+
let streams = false;
|
|
2097
|
+
const bodyRefs = [];
|
|
2098
|
+
/**
|
|
2099
|
+
* **What a producer SUPPLIES for a body, which is what the server emitters accept for it.** A
|
|
2100
|
+
* stream or a raw binary body is handed over as bytes and never parsed, so it is spelled as
|
|
2101
|
+
* bytes - `ByteStream` rather than the platform stream, for the same reason as a request body.
|
|
2102
|
+
* A model under a media type that is not JSON is what the producer serialised: text under a
|
|
2103
|
+
* textual media type, bytes under any other. Everything else is the producer's view of the
|
|
2104
|
+
* contract type.
|
|
2105
|
+
*/
|
|
2106
|
+
const bodyOf = (member) => {
|
|
2107
|
+
// A model a body served unvalidated names is declared all the same, as the request side
|
|
2108
|
+
// declares one under a media type nothing parses: the document declares it either way.
|
|
2109
|
+
if (member.unvalidated &&
|
|
2110
|
+
member.response.body !== undefined &&
|
|
2111
|
+
declaresComponent(member.response.body)) {
|
|
2112
|
+
registry.expressionFor(member.response.body);
|
|
2113
|
+
}
|
|
2114
|
+
switch (member.serving) {
|
|
2115
|
+
case "none":
|
|
2116
|
+
return undefined;
|
|
2117
|
+
case "stream":
|
|
2118
|
+
streams = true;
|
|
2119
|
+
return "ByteStream";
|
|
2120
|
+
case "binary":
|
|
2121
|
+
streams = true;
|
|
2122
|
+
return "ByteStream | Uint8Array<ArrayBuffer> | ArrayBuffer";
|
|
2123
|
+
case "unvalidated":
|
|
2124
|
+
return "string";
|
|
2125
|
+
default: {
|
|
2126
|
+
if (member.response.body === undefined)
|
|
2127
|
+
return undefined;
|
|
2128
|
+
const ref = registry.expressionFor(member.response.body);
|
|
2129
|
+
bodyRefs.push(ref);
|
|
2130
|
+
return `Produced<${ref}>`;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
};
|
|
2134
|
+
const members = resultMembersOf(responses, hasBody).map((member) => {
|
|
2135
|
+
const body = bodyOf(member);
|
|
2136
|
+
const codes = statusCodesOf(member.response, responses, hasBody);
|
|
2137
|
+
const fields = [`readonly status: ${codes.length === 0 ? "never" : codes.join(" | ")}`];
|
|
2138
|
+
if (member.choosesMediaType && member.mediaType !== undefined) {
|
|
2139
|
+
fields.push(`readonly contentType: ${mediaTypeTypeOf(member.mediaType)}`);
|
|
2140
|
+
}
|
|
2141
|
+
fields.push(body === undefined ? "readonly body?: undefined" : `readonly body: ${body}`);
|
|
2142
|
+
const headers = member.response.headers;
|
|
2143
|
+
if (headers.length > 0) {
|
|
2144
|
+
/**
|
|
2145
|
+
* Keyed by the WIRE name, as the document publishes it. `headers` is optional only when
|
|
2146
|
+
* every header in it is, and an optional header admits an explicit `undefined`, because
|
|
2147
|
+
* a server drops one before it checks and sends the rest.
|
|
2148
|
+
*/
|
|
2149
|
+
const every = headers.every(({ property }) => property.optional);
|
|
2150
|
+
const rendered = headers
|
|
2151
|
+
.map(({ name, property }) => property.optional
|
|
2152
|
+
? `readonly ${objectKey(name)}?: ${propertyTypeToTs(program, property)} | undefined`
|
|
2153
|
+
: `readonly ${objectKey(name)}: ${propertyTypeToTs(program, property)}`)
|
|
2154
|
+
.join("; ");
|
|
2155
|
+
fields.push(`readonly headers${every ? "?" : ""}: { ${rendered} }`);
|
|
2156
|
+
}
|
|
2157
|
+
return { member, body, text: `{ ${fields.join("; ")} }` };
|
|
2158
|
+
});
|
|
2159
|
+
/**
|
|
2160
|
+
* A success is a `2xx` or `3xx` outcome, exact or ranged, as {@link successStatusesOf} holds.
|
|
2161
|
+
* `default` is not one: it is every status nothing else declares, which is mostly failures.
|
|
2162
|
+
*/
|
|
2163
|
+
const outputs = [
|
|
2164
|
+
...new Set(members
|
|
2165
|
+
.filter(({ member }) => isSuccessStatus(member.response.status))
|
|
2166
|
+
.map(({ body }) => body ?? "undefined")),
|
|
2167
|
+
];
|
|
1824
2168
|
entries.push({
|
|
1825
2169
|
operationId: operationIdOf(program, operation.operation),
|
|
1826
|
-
|
|
2170
|
+
output: outputs.length === 0 ? "never" : outputs.join(" | "),
|
|
2171
|
+
result: members.map(({ text }) => text),
|
|
2172
|
+
streams,
|
|
2173
|
+
bodyRefs,
|
|
1827
2174
|
});
|
|
1828
2175
|
}
|
|
1829
2176
|
});
|
|
1830
2177
|
return entries;
|
|
1831
2178
|
}
|
|
2179
|
+
/** A `2xx` or `3xx` status, exact or as a range: an outcome a handler returns rather than a failure. */
|
|
2180
|
+
function isSuccessStatus(status) {
|
|
2181
|
+
if (typeof status === "number")
|
|
2182
|
+
return status >= 200 && status < 400;
|
|
2183
|
+
return status === "2XX" || status === "3XX";
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* **A stream of bytes, spelled as the reader every stream has rather than as `ReadableStream`.**
|
|
2187
|
+
*
|
|
2188
|
+
* The server hands a binary request body over unread, so the contract has to say "stream" - a
|
|
2189
|
+
* contract saying `ArrayBuffer` made a gateway read a 25 MiB upload into an isolate just to satisfy
|
|
2190
|
+
* a backend's type. But `requests.gen.ts` is framework-free, and a contracts package compiled under
|
|
2191
|
+
* `lib: ["ES2024"]` and no `types` cannot name `ReadableStream` at all (`TS2304`); only lib.dom,
|
|
2192
|
+
* lib.webworker, `@cloudflare/workers-types` and `@types/node` declare it.
|
|
2193
|
+
*
|
|
2194
|
+
* **This is the part of a stream every one of those declares, and nothing else.** Measured: a platform
|
|
2195
|
+
* `ReadableStream<Uint8Array> | null` is assignable to it under the default libs, lib.dom,
|
|
2196
|
+
* lib.webworker, workers-types and `@types/node`, on TypeScript 5.9.3 and 7.0.2, while an
|
|
2197
|
+
* `ArrayBuffer` is refused. `AsyncIterable<Uint8Array>`, the obvious alternative, is NOT satisfied
|
|
2198
|
+
* by lib.dom's stream on TypeScript 5.9 unless `dom.asynciterable` is also listed - which the usual
|
|
2199
|
+
* `["ES2020", "DOM", "DOM.Iterable"]` does not.
|
|
2200
|
+
*
|
|
2201
|
+
* The same precedent as a multipart file part: a type only an ambient library declares is spelled
|
|
2202
|
+
* structurally, so the emitted output compiles wherever it lands.
|
|
2203
|
+
*/
|
|
2204
|
+
const BYTE_STREAM = `
|
|
2205
|
+
/**
|
|
2206
|
+
* A stream of bytes, as every platform's \`ReadableStream<Uint8Array>\` satisfies it - spelled
|
|
2207
|
+
* structurally so this file needs no DOM or runtime library to name it.
|
|
2208
|
+
*/
|
|
2209
|
+
export interface ByteStream {
|
|
2210
|
+
getReader(): {
|
|
2211
|
+
read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>;
|
|
2212
|
+
releaseLock(): void;
|
|
2213
|
+
cancel?(reason?: unknown): Promise<void>;
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
`;
|
|
1832
2217
|
/**
|
|
1833
2218
|
* `requests.gen.ts` - the shape both sides are checked against.
|
|
1834
2219
|
*
|
|
@@ -1861,29 +2246,29 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
1861
2246
|
? `\nexport interface ${declaration.name} ${declaration.source}\n`
|
|
1862
2247
|
: `\nexport type ${declaration.name} = ${declaration.source};\n`;
|
|
1863
2248
|
});
|
|
1864
|
-
const
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
parts.push(entry.bodyRef);
|
|
1868
|
-
if (entry.extraProperties.length > 0) {
|
|
1869
|
-
parts.push(`{\n${entry.extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
|
|
1870
|
-
}
|
|
1871
|
-
return (`\n/** The effective input for \`${entry.operationId}\` - its body, with path and query parameters merged over it. */\n` +
|
|
1872
|
-
`export type ${upperFirst(entry.operationId)}Input = Simplify<${parts.join(" & ")}>;\n`);
|
|
1873
|
-
});
|
|
2249
|
+
const responseLookup = responses
|
|
2250
|
+
.map((entry) => `\t/** Every success body \`${entry.operationId}\` declares. */\n\t${entry.operationId}: ${entry.output};`)
|
|
2251
|
+
.join("\n");
|
|
1874
2252
|
/**
|
|
1875
|
-
*
|
|
1876
|
-
*
|
|
1877
|
-
*
|
|
1878
|
-
* only the value side, so `Contracts["AppointOfficerInput"]` does not resolve. Both sides'
|
|
1879
|
-
* assertions are written as mapped types over every operation, and this is what makes that
|
|
1880
|
-
* possible. Generated with the aliases above, so it cannot fall behind them.
|
|
2253
|
+
* **One member per declared response and media type, each pairing a status with its own body.**
|
|
2254
|
+
* The union a server emitter's handlers return, stated with nothing to import: a backend reached
|
|
2255
|
+
* over a service binding returns `WireResults[op]`, and a handler can hand it straight on.
|
|
1881
2256
|
*/
|
|
1882
|
-
const
|
|
1883
|
-
.map((entry) => `\t${entry.operationId}:
|
|
2257
|
+
const resultLookup = responses
|
|
2258
|
+
.map((entry) => `\t/** Every response \`${entry.operationId}\` declares. */\n\t${entry.operationId}:${entry.result.length === 0 ? " never" : entry.result.map((member) => `\n\t\t| ${member}`).join("")};`)
|
|
1884
2259
|
.join("\n");
|
|
2260
|
+
/**
|
|
2261
|
+
* **Keyed by operation id, and the only name an operation's input has.**
|
|
2262
|
+
*
|
|
2263
|
+
* Each input used to be declared a second time as an alias, `CreateUserInput` for `createUser`,
|
|
2264
|
+
* and this lookup referenced the aliases. That is a name a spec may well give its own request
|
|
2265
|
+
* model, so the two declarations collided (`TS2300`) and `requests.gen.ts` did not
|
|
2266
|
+
* compile for an operation as ordinary as `createUser(@body body: CreateUserInput)`. A type
|
|
2267
|
+
* exported under a name this emitter invents is a name a spec author can already be using.
|
|
2268
|
+
* `WireInputs["createUser"]` is the same type, and nothing a spec declares can collide with it.
|
|
2269
|
+
*/
|
|
1885
2270
|
const lookup = entries
|
|
1886
|
-
.map((entry) => `\t${entry.operationId}: ${
|
|
2271
|
+
.map((entry) => `\t/** The input for \`${entry.operationId}\`, as its handler receives it. */\n\t${entry.operationId}: ${entry.input.replaceAll("\n", "\n\t")};`)
|
|
1887
2272
|
.join("\n");
|
|
1888
2273
|
/**
|
|
1889
2274
|
* **Emitted only where an operation has something to flatten.**
|
|
@@ -1895,9 +2280,9 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
1895
2280
|
* `TS6196: 'Simplify' is declared but never used.` Third of its kind, after the unconditional
|
|
1896
2281
|
* `zValidator` and `z` imports.
|
|
1897
2282
|
*
|
|
1898
|
-
* Decided from the
|
|
2283
|
+
* Decided from the inputs actually rendered rather than by searching the output for the name.
|
|
1899
2284
|
*/
|
|
1900
|
-
const simplifyHelper =
|
|
2285
|
+
const simplifyHelper = !entries.some((entry) => entry.input.startsWith("Simplify<"))
|
|
1901
2286
|
? ""
|
|
1902
2287
|
: `/**
|
|
1903
2288
|
* Flattens an intersection into a single object type.
|
|
@@ -1933,14 +2318,19 @@ export type Produced<T> = T extends readonly (infer Element)[]
|
|
|
1933
2318
|
: T extends object
|
|
1934
2319
|
? { readonly [K in keyof T]: Produced<T[K]> }
|
|
1935
2320
|
: T;
|
|
1936
|
-
${
|
|
1937
|
-
/**
|
|
2321
|
+
${entries.some((entry) => entry.streamsBody) || responses.some((entry) => entry.streams) ? BYTE_STREAM : ""}${declarations.join("")}
|
|
2322
|
+
/**
|
|
2323
|
+
* Every operation's input, keyed by operation id: what its handler receives, and what a caller of a
|
|
2324
|
+
* backend typed by it hands over. An operation that takes nothing is \`Record<string, never>\`.
|
|
2325
|
+
*/
|
|
1938
2326
|
export interface WireInputs {
|
|
1939
2327
|
${lookup}
|
|
1940
2328
|
}
|
|
1941
2329
|
|
|
1942
2330
|
/**
|
|
1943
|
-
* Every operation's success
|
|
2331
|
+
* Every operation's success bodies, keyed by operation id: the body of each \`2xx\` and \`3xx\` response it
|
|
2332
|
+
* declares, \`undefined\` for one with no body, and \`never\` where it declares no success at all. Which
|
|
2333
|
+
* status goes with which body is \`WireResults\`.
|
|
1944
2334
|
*
|
|
1945
2335
|
* **A floor, not an equality.** These shapes are permissive: a caller pins the fields it depends
|
|
1946
2336
|
* on and lets a richer view through, so a producer's result must CONTAIN what the document promises
|
|
@@ -1950,6 +2340,15 @@ ${lookup}
|
|
|
1950
2340
|
export interface WireOutputs {
|
|
1951
2341
|
${responseLookup}
|
|
1952
2342
|
}
|
|
2343
|
+
|
|
2344
|
+
/**
|
|
2345
|
+
* Every operation's result, keyed by operation id: one member per response it declares, each pairing
|
|
2346
|
+
* a status with that status's body and headers. A status no response declares does not compile, and
|
|
2347
|
+
* a \`4XX\` or \`default\` response is the codes it stands for, written out.
|
|
2348
|
+
*/
|
|
2349
|
+
export interface WireResults {
|
|
2350
|
+
${resultLookup}
|
|
2351
|
+
}
|
|
1953
2352
|
`;
|
|
1954
2353
|
}
|
|
1955
2354
|
/**
|
|
@@ -1987,16 +2386,25 @@ ${responseLookup}
|
|
|
1987
2386
|
* `Equals` here, not assignability: the two describe one shape, and a property present on one side
|
|
1988
2387
|
* and absent from the other is exactly the drift this exists to catch.
|
|
1989
2388
|
*/
|
|
1990
|
-
function renderWireAssertions(schemas, types, entries, contractsPackage) {
|
|
2389
|
+
function renderWireAssertions(schemas, types, entries, responses, contractsPackage) {
|
|
1991
2390
|
// The assertions pair the emitted Zod against the request TYPES, which live in the contracts
|
|
1992
2391
|
// package. With no package named there is nothing to import them from and nothing to pair - the
|
|
1993
2392
|
// types are still written to `contracts-output-dir`, they simply have no shared specifier yet.
|
|
1994
2393
|
if (contractsPackage === undefined)
|
|
1995
2394
|
return undefined;
|
|
1996
|
-
|
|
2395
|
+
/**
|
|
2396
|
+
* **Every model a body names, the responses' as well as the requests'.** This paired request
|
|
2397
|
+
* bodies only, so a model that is only ever SENT was compared with nothing; that was harmless
|
|
2398
|
+
* while the contract types described a single success body and nothing consumed it, and is not now
|
|
2399
|
+
* that `WireResults` states every response a backend returns.
|
|
2400
|
+
*/
|
|
2401
|
+
const bodyModels = new Set([
|
|
2402
|
+
...entries.map((entry) => entry.bodyRef).filter((ref) => ref !== undefined),
|
|
2403
|
+
...responses.flatMap((entry) => entry.bodyRefs),
|
|
2404
|
+
]);
|
|
1997
2405
|
const paired = [];
|
|
1998
2406
|
for (const declaration of types.declarations()) {
|
|
1999
|
-
if (!
|
|
2407
|
+
if (!bodyModels.has(declaration.name))
|
|
2000
2408
|
continue;
|
|
2001
2409
|
const schema = schemas
|
|
2002
2410
|
.declarations()
|
|
@@ -2057,26 +2465,6 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
|
|
|
2057
2465
|
* dropped from a shape that has other keys, and kept - with its value type still mapped - from one
|
|
2058
2466
|
* that does not.
|
|
2059
2467
|
*/
|
|
2060
|
-
/**
|
|
2061
|
-
* \u26a0\ufe0f **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
|
|
2062
|
-
* deliberate on both sides.**
|
|
2063
|
-
*
|
|
2064
|
-
* The validator emits \`.exactOptional()\`, so what ARRIVES is absent or a value - JSON cannot carry an
|
|
2065
|
-
* \`undefined\` and the document says exactly that by leaving the property out of \`required\`. The
|
|
2066
|
-
* contract type is the floor a PRODUCER supplies, and \`{ p: undefined }\` serialises identically to
|
|
2067
|
-
* omitting \`p\`, so refusing it would cost every producer a conditional spread and buy the wire
|
|
2068
|
-
* nothing. Narrowing THAT surface was tried in \`0.19.0\` and reverted: it broke
|
|
2069
|
-
* \`(ctx, input) => ok(input)\`.
|
|
2070
|
-
*
|
|
2071
|
-
* So the two disagree on exactly one axis, on purpose, and this widens the inferred side to the
|
|
2072
|
-
* producer floor before comparing - the same job \`Declared\` does for openness, in the same pass
|
|
2073
|
-
* rather than a second deep one. Every other kind of drift still fails: a property that changes type,
|
|
2074
|
-
* appears, disappears or changes optionality is untouched by this.
|
|
2075
|
-
*/
|
|
2076
|
-
type Supplied<T, K extends keyof T> = {} extends Pick<T, K>
|
|
2077
|
-
? Declared<T[K]> | undefined
|
|
2078
|
-
: Declared<T[K]>;
|
|
2079
|
-
|
|
2080
2468
|
type DeclaredKeyOf<T> = keyof {
|
|
2081
2469
|
[K in keyof T as string extends K
|
|
2082
2470
|
? never
|
|
@@ -2105,9 +2493,8 @@ type Declared<T> = T extends (...args: never[]) => unknown
|
|
|
2105
2493
|
// equate a real dictionary with an empty object: a dictionary whose values are
|
|
2106
2494
|
// \`never\` accepts nothing either.
|
|
2107
2495
|
{}
|
|
2108
|
-
: // A shape with no declared keys is a dictionary:
|
|
2109
|
-
//
|
|
2110
|
-
// value type.
|
|
2496
|
+
: // A shape with no declared keys is a dictionary: its index signature is kept and
|
|
2497
|
+
// its value type mapped.
|
|
2111
2498
|
{ [K in keyof T]: Declared<T[K]> }
|
|
2112
2499
|
: {
|
|
2113
2500
|
[K in keyof T as string extends K
|
|
@@ -2116,13 +2503,13 @@ type Declared<T> = T extends (...args: never[]) => unknown
|
|
|
2116
2503
|
? never
|
|
2117
2504
|
: symbol extends K
|
|
2118
2505
|
? never
|
|
2119
|
-
: K]:
|
|
2506
|
+
: K]: Declared<T[K]>;
|
|
2120
2507
|
}
|
|
2121
2508
|
: T;
|
|
2122
2509
|
|
|
2123
2510
|
/**
|
|
2124
|
-
*
|
|
2125
|
-
* type when the two disagree, and a type alias to an odd type compiles fine
|
|
2511
|
+
* **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
|
|
2512
|
+
* type when the two disagree, and a type alias to an odd type compiles fine - 66 assertions that
|
|
2126
2513
|
* assert nothing. Only a constraint violation is an error.
|
|
2127
2514
|
*/
|
|
2128
2515
|
type MustHold<T extends true> = T;
|
|
@@ -2251,6 +2638,8 @@ export async function emitHttpZod(context) {
|
|
|
2251
2638
|
* there means a server emitter follows without knowing this rule exists.
|
|
2252
2639
|
*/
|
|
2253
2640
|
const distinctServices = new Set(snapshots.map((snapshot) => snapshot.service.namespace.name));
|
|
2641
|
+
/** The services writing to the top-level `contracts-output-dir`: every one not naming its own. */
|
|
2642
|
+
const sharingContracts = [...distinctServices].filter((name) => options.services?.[name]?.["contracts-output-dir"] === undefined);
|
|
2254
2643
|
/**
|
|
2255
2644
|
* **Every service must seal the same way, because `@typespec/openapi3` has no per-service
|
|
2256
2645
|
* options.**
|
|
@@ -2295,8 +2684,22 @@ export async function emitHttpZod(context) {
|
|
|
2295
2684
|
(distinctServices.size > 1
|
|
2296
2685
|
? resolvePath(context.emitterOutputDir, serviceName)
|
|
2297
2686
|
: context.emitterOutputDir);
|
|
2687
|
+
/**
|
|
2688
|
+
* **Split where two services would write one file, and nowhere else.** A shared contracts
|
|
2689
|
+
* directory was written once per service to one path, so the second service's
|
|
2690
|
+
* `requests.gen.ts` replaced the first's and a whole surface lost its contracts with no
|
|
2691
|
+
* diagnostic. Counted over the services that WRITE there: one service falling back to the
|
|
2692
|
+
* shared directory, beside others that name their own, collides with nothing, and splitting
|
|
2693
|
+
* it moved contracts a project already imported. A relative `contracts-package` is resolved
|
|
2694
|
+
* from each service's own `wire-contract.gen.ts`, so it names that service's contracts; a
|
|
2695
|
+
* package specifier has to be set per service.
|
|
2696
|
+
*/
|
|
2697
|
+
const sharedContractsDir = options["contracts-output-dir"];
|
|
2298
2698
|
const resolved = {
|
|
2299
|
-
contractsOutputDir: perService?.["contracts-output-dir"] ??
|
|
2699
|
+
contractsOutputDir: perService?.["contracts-output-dir"] ??
|
|
2700
|
+
(sharedContractsDir !== undefined && sharingContracts.length > 1
|
|
2701
|
+
? resolvePath(sharedContractsDir, serviceName)
|
|
2702
|
+
: sharedContractsDir),
|
|
2300
2703
|
contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
|
|
2301
2704
|
sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
|
|
2302
2705
|
compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
|
|
@@ -2362,8 +2765,15 @@ export async function emitHttpZod(context) {
|
|
|
2362
2765
|
});
|
|
2363
2766
|
if (resolved.contractsOutputDir !== undefined) {
|
|
2364
2767
|
const types = new TypeRegistry(context.program);
|
|
2365
|
-
|
|
2366
|
-
|
|
2768
|
+
/**
|
|
2769
|
+
* **Inside the same stream scope as the route walk**, because both read the same response
|
|
2770
|
+
* records: outside it `streamedTypeOf` cannot see `@typespec/streams`, so an SSE body was
|
|
2771
|
+
* stated as the string the document describes rather than the stream a server sends.
|
|
2772
|
+
*/
|
|
2773
|
+
const { entries, responses } = withStreamModule(streamModule, () => ({
|
|
2774
|
+
entries: collectRequestTypes(context.program, types, service),
|
|
2775
|
+
responses: collectResponseTypes(context.program, types, service),
|
|
2776
|
+
}));
|
|
2367
2777
|
await emitFile(context.program, {
|
|
2368
2778
|
path: resolvePath(resolved.contractsOutputDir, "requests.gen.ts"),
|
|
2369
2779
|
content: renderRequestTypes(entries, responses, types),
|
|
@@ -2372,7 +2782,7 @@ export async function emitHttpZod(context) {
|
|
|
2372
2782
|
path: resolvePath(resolved.contractsOutputDir, "vocabularies.gen.ts"),
|
|
2373
2783
|
content: renderVocabularies(vocabularies),
|
|
2374
2784
|
});
|
|
2375
|
-
const assertions = renderWireAssertions(registry, types, entries, resolved.contractsPackage);
|
|
2785
|
+
const assertions = renderWireAssertions(registry, types, entries, responses, resolved.contractsPackage);
|
|
2376
2786
|
if (assertions !== undefined) {
|
|
2377
2787
|
await emitFile(context.program, {
|
|
2378
2788
|
path: resolvePath(outputDir, "wire-contract.gen.ts"),
|
|
@@ -2380,7 +2790,12 @@ export async function emitHttpZod(context) {
|
|
|
2380
2790
|
});
|
|
2381
2791
|
}
|
|
2382
2792
|
}
|
|
2383
|
-
|
|
2793
|
+
// Still inside this service's id scope, so each operation resolves to the id its route carries.
|
|
2794
|
+
const operations = new Map(service.operations.map((operation) => [
|
|
2795
|
+
operationIdOf(context.program, operation.operation),
|
|
2796
|
+
operation,
|
|
2797
|
+
]));
|
|
2798
|
+
emitted.push({ service, routes, schemaNames: names, operations, outputDir, options: resolved });
|
|
2384
2799
|
}
|
|
2385
2800
|
return emitted;
|
|
2386
2801
|
}
|