typespec-http-zod 0.27.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 +42 -9
- package/dist/src/api.js +619 -295
- package/dist/src/constraints.d.ts +1 -1
- package/dist/src/constraints.js +14 -10
- package/dist/src/registry.js +26 -5
- package/dist/src/results.d.ts +113 -0
- package/dist/src/results.js +134 -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 +15 -1
- package/dist/src/zod.js +19 -1
- package/package.json +14 -14
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,30 +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
|
-
/**
|
|
320
|
-
* **The Zod expression, which used to be discarded here.** Every REQUEST parameter
|
|
321
|
-
* goes through `expressionForProperty` a few hundred lines below; a response header
|
|
322
|
-
* went through `typeToTs` and nothing else, so the emitted arm carried a name and an
|
|
323
|
-
* optionality and no way to check a value. A response body that breaks its schema
|
|
324
|
-
* throws `ResponseContractError`; a header that breaks its own was served.
|
|
325
|
-
*/
|
|
326
|
-
schema: registry.expressionForProperty(property),
|
|
327
|
-
optional: property.optional,
|
|
328
|
-
});
|
|
342
|
+
entry.headers.push({ name, property });
|
|
329
343
|
}
|
|
330
344
|
}
|
|
331
345
|
if (streamedTypeOf(program, response.type) !== undefined)
|
|
@@ -333,27 +347,73 @@ function responsesOf(program, operation, registry) {
|
|
|
333
347
|
byStatus.set(key, entry);
|
|
334
348
|
}
|
|
335
349
|
}
|
|
336
|
-
return [...byStatus.values()]
|
|
337
|
-
.map((entry) => {
|
|
350
|
+
return [...byStatus.values()].map((entry) => {
|
|
338
351
|
const contentTypes = [...entry.contentTypes];
|
|
339
|
-
const binary = entry.body !== undefined && isBinaryPart(entry.body) && isRawBinaryMediaType(contentTypes);
|
|
340
352
|
return {
|
|
341
353
|
status: entry.status,
|
|
342
|
-
|
|
343
|
-
? undefined
|
|
344
|
-
: binary
|
|
345
|
-
? "z.unknown()"
|
|
346
|
-
: registry.expressionFor(entry.body),
|
|
354
|
+
body: entry.body,
|
|
347
355
|
contentTypes,
|
|
348
356
|
headers: entry.headers,
|
|
349
|
-
binary
|
|
357
|
+
binary: entry.file ||
|
|
358
|
+
(entry.body !== undefined &&
|
|
359
|
+
isBinaryPart(entry.body) &&
|
|
360
|
+
isRawBinaryMediaType(contentTypes)),
|
|
350
361
|
streamed: entry.streamed,
|
|
351
|
-
textual: entry.body !== undefined && isStringType(entry.body),
|
|
362
|
+
textual: !entry.file && entry.body !== undefined && isStringType(entry.body),
|
|
352
363
|
};
|
|
353
|
-
})
|
|
354
|
-
|
|
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) ||
|
|
355
369
|
statusOrderOf(a.status) - statusOrderOf(b.status));
|
|
356
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
|
+
}
|
|
357
417
|
/**
|
|
358
418
|
* Where an arm sits in OpenAPI's own precedence order.
|
|
359
419
|
*
|
|
@@ -539,33 +599,6 @@ export function errorBodyOf(program, service) {
|
|
|
539
599
|
function bodyTypeOf(type) {
|
|
540
600
|
return isVoidType(type) || isNeverType(type) ? undefined : type;
|
|
541
601
|
}
|
|
542
|
-
/**
|
|
543
|
-
* The declared body type of the operation's success response, if it has one.
|
|
544
|
-
*
|
|
545
|
-
* **`void` and `never` ARE "no body", and reading them as types was a defect.** A response with
|
|
546
|
-
* no content reaches this in two shapes: as no body at all, which `undefined` already covered, and
|
|
547
|
-
* as a body whose type is `void` - which is what `tsp-openapi3` writes when it converts a `204`,
|
|
548
|
-
* and what `op remove(): void` means. The second used to be returned as a type, walked, and refused
|
|
549
|
-
* with `Cannot emit Zod for Intrinsic: 'void' has no runtime representation'`. That took out the
|
|
550
|
-
* Swagger Petstore, the most widely implemented reference API there is, on the round-trip suite's
|
|
551
|
-
* first run.
|
|
552
|
-
*
|
|
553
|
-
* One fact arriving by two roads. Answering it here rather than at each of the four call sites is
|
|
554
|
-
* what keeps them from drifting - `collectRoutes`, the contract types, the dual-status
|
|
555
|
-
* discriminator and the alternate-arm schema all want the same answer to the same question.
|
|
556
|
-
*/
|
|
557
|
-
function successBodyOf(operation, status) {
|
|
558
|
-
for (const response of operation.responses) {
|
|
559
|
-
if (response.statusCodes !== status)
|
|
560
|
-
continue;
|
|
561
|
-
for (const content of response.responses) {
|
|
562
|
-
if (content.body?.bodyKind !== "single")
|
|
563
|
-
continue;
|
|
564
|
-
return bodyTypeOf(content.body.type);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
return undefined;
|
|
568
|
-
}
|
|
569
602
|
/**
|
|
570
603
|
* The media types this operation's success response can be served as.
|
|
571
604
|
*
|
|
@@ -801,7 +834,18 @@ function isBinaryPart(type) {
|
|
|
801
834
|
export function isRawBinaryMediaType(contentTypes) {
|
|
802
835
|
if (contentTypes.length === 0)
|
|
803
836
|
return false;
|
|
804
|
-
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/"));
|
|
805
849
|
}
|
|
806
850
|
/** The declared request body type, if the operation takes one. */
|
|
807
851
|
function requestBodyOf(operation) {
|
|
@@ -1000,15 +1044,28 @@ function mediaTypeDecoded(declared) {
|
|
|
1000
1044
|
*/
|
|
1001
1045
|
if (value === "*/*")
|
|
1002
1046
|
return "z.string()";
|
|
1003
|
-
return value
|
|
1004
|
-
? `z.templateLiteral([${JSON.stringify(value.slice(0, -1))}, z.string()])`
|
|
1047
|
+
return isMediaRange(value)
|
|
1048
|
+
? `z.templateLiteral([${JSON.stringify(mediaTypeEssenceOf(value).slice(0, -1))}, z.string()])`
|
|
1005
1049
|
: `z.literal(${JSON.stringify(value)})`;
|
|
1006
1050
|
});
|
|
1007
1051
|
const schema = admitted.length === 1 ? (admitted[0] ?? declared) : `z.union([${admitted.join(", ")}])`;
|
|
1008
|
-
const ranged = values.some(
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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})`;
|
|
1012
1069
|
}
|
|
1013
1070
|
function parameterSchemasOf(program, operation, registry, segments,
|
|
1014
1071
|
/** The names of a query string written into the route itself, which no parameter may gather. */
|
|
@@ -1149,6 +1206,13 @@ literalQueryNames) {
|
|
|
1149
1206
|
(byTarget["header-without-accept"] ??= []).push(entry);
|
|
1150
1207
|
}
|
|
1151
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
|
+
}
|
|
1152
1216
|
const shape = (target) => {
|
|
1153
1217
|
const entries = byTarget[target];
|
|
1154
1218
|
return entries === undefined ? undefined : `z.object({\n${entries.join("\n")}\n})`;
|
|
@@ -1186,6 +1250,178 @@ function modelPropertiesOf(model) {
|
|
|
1186
1250
|
}
|
|
1187
1251
|
return chain.flatMap((current) => [...current.properties.values()]);
|
|
1188
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
|
+
}
|
|
1189
1425
|
export function collectRoutes(program, registry, service) {
|
|
1190
1426
|
const routes = [];
|
|
1191
1427
|
{
|
|
@@ -1215,73 +1451,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1215
1451
|
// `@requestVisibility` when the operation states one, otherwise the verb's default - the
|
|
1216
1452
|
// same resolution `@typespec/openapi3` performs before emitting a request schema.
|
|
1217
1453
|
const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
|
|
1218
|
-
|
|
1219
|
-
* A `bytes` body means the BYTES are the contract.
|
|
1220
|
-
*
|
|
1221
|
-
* A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
|
|
1222
|
-
* verify a different string and every delivery would fail authentication.
|
|
1223
|
-
*/
|
|
1224
|
-
const bodyParameter = operation.parameters.body;
|
|
1225
|
-
const rawBodyProperty = requestType?.kind === "Scalar" && requestType.name === "bytes"
|
|
1226
|
-
? (bodyParameter?.property?.name ?? "body")
|
|
1227
|
-
: undefined;
|
|
1228
|
-
/**
|
|
1229
|
-
* A body with an indexer is named rather than spread - see `bodyProperty`. Read from the
|
|
1230
|
-
* resolved model's own indexer rather than from the emitted schema text, which would be a
|
|
1231
|
-
* decision made by pattern-matching generated output.
|
|
1232
|
-
*/
|
|
1233
|
-
/**
|
|
1234
|
-
* **An OPTIONAL body is named for the same reason an indexed one is**: the merge cannot
|
|
1235
|
-
* express "absent". See {@link EmittedRoute.optionalBody}.
|
|
1236
|
-
*/
|
|
1237
|
-
const optionalBody = bodyParameter?.property?.optional === true;
|
|
1238
|
-
/**
|
|
1239
|
-
* **An optional MULTIPART body reaches the same rule by a second route, because
|
|
1240
|
-
* `requestType` cannot see it.** `requestBodyOf` resolves a type only for
|
|
1241
|
-
* `bodyKind === "single"`, so `requestType` is `undefined` for every multipart operation
|
|
1242
|
-
* and the `optionalBody` disjunct above was unreachable however the document declared it.
|
|
1243
|
-
*
|
|
1244
|
-
* The emitted server for such an operation did not compile. Measured, and the mechanism is
|
|
1245
|
-
* worth stating because it is not a union: spreading a value typed `Parts | undefined`
|
|
1246
|
-
* makes every part OPTIONAL, so `{ file?: ...; pages?: ... }` was passed where
|
|
1247
|
-
* `{ file: ...; pages: ... }` was required. `TS2345`, inside `app.gen.ts` itself.
|
|
1248
|
-
*
|
|
1249
|
-
* That is the failure {@link EmittedRoute.optionalBody} predicts in prose - a merge has no
|
|
1250
|
-
* way to say "these properties are here only sometimes" without making every one of them
|
|
1251
|
-
* optional, which is a weaker and different claim about the body that IS sent. So this is
|
|
1252
|
-
* the stated rule reaching the one body kind it had never been applied to, not a new rule.
|
|
1253
|
-
*
|
|
1254
|
-
* **The REQUIRED arm is deliberately excluded.** The indexed reason does not apply to a
|
|
1255
|
-
* parts model, so a required multipart body merges as safely as a required single one, and
|
|
1256
|
-
* `typespec-hono/test/filepart/` compiles a handler that reads `input.file` directly.
|
|
1257
|
-
* Widening this to "multipart is always named" would rewrite that directory for no defect.
|
|
1258
|
-
*
|
|
1259
|
-
* This is the third hole the `"single"` gate has produced, after `multipartSchemaOf` and
|
|
1260
|
-
* `multipartTsOf`, both recorded above.
|
|
1261
|
-
*/
|
|
1262
|
-
const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
|
|
1263
|
-
/**
|
|
1264
|
-
* **A body that is not a MODEL has no properties to merge, and merging it anyway lost it.**
|
|
1265
|
-
*
|
|
1266
|
-
* `@body body: string` emitted a handler input of `Record<string, never>` and a call site of
|
|
1267
|
-
* `{}`: the server validated the body and then never handed it to the handler, silently, from
|
|
1268
|
-
* a compile that reported success. `@body body: SomeEnum` was the same rule failing loudly,
|
|
1269
|
-
* `TS2698: Spread types may only be created from object types`, which is what
|
|
1270
|
-
* `payload/media-type`, `type/enum/*`, `type/scalar` and `versioning/returnTypeChangedFrom`
|
|
1271
|
-
* had been pinned on in the conformance corpus.
|
|
1272
|
-
*
|
|
1273
|
-
* A scalar, an enum, a union or a literal body is therefore NAMED, which is the treatment an
|
|
1274
|
-
* indexed or optional body already gets and for the same reason: the merge cannot express it.
|
|
1275
|
-
*
|
|
1276
|
-
* **Except a `bytes` body**, which {@link EmittedRoute.rawBodyProperty} already names, and
|
|
1277
|
-
* naming it twice would put the same value under two keys.
|
|
1278
|
-
*/
|
|
1279
|
-
const unmergeableBody = requestType !== undefined && requestType.kind !== "Model" && rawBodyProperty === undefined;
|
|
1280
|
-
const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
|
|
1281
|
-
optionalMultipartBody ||
|
|
1282
|
-
unmergeableBody
|
|
1283
|
-
? (bodyParameter?.property?.name ?? "body")
|
|
1284
|
-
: undefined;
|
|
1454
|
+
const { rawBodyProperty, rawBodyBinary, rawBodyUnvalidated, bodyProperty, optionalBody } = requestBodyPlacementOf(program, operation);
|
|
1285
1455
|
const authentication = authenticationFor(program, operation);
|
|
1286
1456
|
const template = routeTemplateOf(operation.uriTemplate, operation.parameters.parameters
|
|
1287
1457
|
.filter((parameter) => parameter.type === "path")
|
|
@@ -1370,6 +1540,8 @@ export function collectRoutes(program, registry, service) {
|
|
|
1370
1540
|
*/
|
|
1371
1541
|
responses: withVisibility(program, Visibility.Read, () => responsesOf(program, operation, registry)),
|
|
1372
1542
|
rawBodyProperty,
|
|
1543
|
+
rawBodyBinary,
|
|
1544
|
+
rawBodyUnvalidated,
|
|
1373
1545
|
bodyProperty,
|
|
1374
1546
|
optionalBody,
|
|
1375
1547
|
scopes: authentication.scopes,
|
|
@@ -1562,10 +1734,9 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
|
|
|
1562
1734
|
* `p?: T` directly - at every depth, which a shallow mapped type never reached - so the narrowing
|
|
1563
1735
|
* happens once, in the schema, and this helper has nothing left to do.
|
|
1564
1736
|
*
|
|
1565
|
-
* The contract types in `requests.gen.ts`
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1568
|
-
* 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`.
|
|
1569
1740
|
*/
|
|
1570
1741
|
const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
|
|
1571
1742
|
const parts = [generatedBanner()];
|
|
@@ -1747,6 +1918,7 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
1747
1918
|
query,
|
|
1748
1919
|
queryFields: gathers ? queryFields : query,
|
|
1749
1920
|
header,
|
|
1921
|
+
selectedAccept: negotiating ? route.accept : undefined,
|
|
1750
1922
|
cookie,
|
|
1751
1923
|
body,
|
|
1752
1924
|
arms,
|
|
@@ -1780,120 +1952,127 @@ function responseArmsOf(route, arms) {
|
|
|
1780
1952
|
});
|
|
1781
1953
|
return `[${rendered.join(", ")}]`;
|
|
1782
1954
|
}
|
|
1783
|
-
const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
1784
1955
|
/**
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
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.
|
|
1787
1971
|
*
|
|
1788
1972
|
* **This used to drop any parameter literally named `companyId`.** One application carried its
|
|
1789
1973
|
* tenant identifier in a context object rather than in the payload, and the emitter was taught to
|
|
1790
1974
|
* match - so for every consumer, a path parameter with that name vanished from the generated input
|
|
1791
1975
|
* type while the Zod validator went on requiring it. Two artefacts describing different shapes, from
|
|
1792
1976
|
* a rule no document states.
|
|
1793
|
-
*
|
|
1794
|
-
* It is the same defect class this emitter exists to delete, and it hid better than most: the
|
|
1795
|
-
* emitted assertions pair the validator against the contract types for MODELS, so a disagreement
|
|
1796
|
-
* about a merged input type is not among the things they compare.
|
|
1797
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
|
+
}
|
|
1798
1982
|
function collectRequestTypes(program, registry, service) {
|
|
1799
1983
|
const entries = [];
|
|
1800
|
-
{
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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;
|
|
1804
2004
|
/**
|
|
1805
|
-
*
|
|
1806
|
-
*
|
|
1807
|
-
* `resolveRequestVisibility` is `@typespec/http`'s own answer - POST is Create, PATCH is
|
|
1808
|
-
* Update, PUT is CreateOrUpdate - and it is what the Zod walk already uses at the route
|
|
1809
|
-
* level. Without it this walk ran at the default `Read`, so a POST body's contract type
|
|
1810
|
-
* carried the canonical property set while the validator beside it accepted the create
|
|
1811
|
-
* projection. A caller satisfying the published type sent read-only fields and a strict
|
|
1812
|
-
* validator answered 400.
|
|
2005
|
+
* Headers are part of the input too.
|
|
1813
2006
|
*
|
|
1814
|
-
*
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
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.
|
|
1817
2010
|
*/
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
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")
|
|
1823
2017
|
/**
|
|
1824
|
-
*
|
|
1825
|
-
*
|
|
1826
|
-
*
|
|
1827
|
-
*
|
|
1828
|
-
* 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.
|
|
1829
2022
|
*/
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
* `"x-thing"` here too: a consumer checking its handlers against these types otherwise
|
|
1840
|
-
* fails to typecheck against a server that is correct.
|
|
1841
|
-
*/
|
|
1842
|
-
.map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
|
|
1843
|
-
// `return` rather than `continue`: this body is a callback now, and returning from it
|
|
1844
|
-
// skips the operation exactly as the `continue` did.
|
|
1845
|
-
if (body === undefined && extraProperties.length === 0)
|
|
1846
|
-
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) {
|
|
1847
2032
|
/**
|
|
1848
|
-
* A
|
|
1849
|
-
*
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
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.
|
|
1852
2038
|
*/
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
: undefined;
|
|
2039
|
+
streamsBody = placement.rawBodyBinary;
|
|
2040
|
+
parts.push(`{\n\t${objectKey(placement.rawBodyProperty)}: ${streamsBody ? "ByteStream | null" : "string"};\n}`);
|
|
1856
2041
|
/**
|
|
1857
|
-
* **
|
|
1858
|
-
*
|
|
1859
|
-
*
|
|
1860
|
-
*
|
|
1861
|
-
* This type said `string` for every `bytes` body whatever it was served as. A server reading
|
|
1862
|
-
* an `application/octet-stream` body hands the handler bytes, so a consumer checking its
|
|
1863
|
-
* handlers against these types could not typecheck against a correct server. It is the
|
|
1864
|
-
* request half of the corruption already fixed on the reader: decoding raw bytes as text
|
|
1865
|
-
* 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.
|
|
1866
2045
|
*/
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
}
|
|
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,
|
|
1883
2071
|
});
|
|
1884
|
-
}
|
|
2072
|
+
});
|
|
1885
2073
|
}
|
|
1886
2074
|
return entries;
|
|
1887
2075
|
}
|
|
1888
|
-
/**
|
|
1889
|
-
* The success response every operation declares, so a producer can be held to producing it.
|
|
1890
|
-
*
|
|
1891
|
-
* **Pinned fields only - these are PERMISSIVE shapes.** The gateway names the fields it depends on
|
|
1892
|
-
* and lets a richer view through untouched, so this is a floor and not an equality: the
|
|
1893
|
-
* domain's result must CONTAIN what the document promises, and may contain more. Asserting equality
|
|
1894
|
-
* would turn every field a producer legitimately adds into a build failure, which is the same mistake
|
|
1895
|
-
* as making a response schema strict.
|
|
1896
|
-
*/
|
|
1897
2076
|
function collectResponseTypes(program, registry, service) {
|
|
1898
2077
|
const entries = [];
|
|
1899
2078
|
/**
|
|
@@ -1906,20 +2085,135 @@ function collectResponseTypes(program, registry, service) {
|
|
|
1906
2085
|
*/
|
|
1907
2086
|
withVisibility(program, Visibility.Read, () => {
|
|
1908
2087
|
for (const operation of service.operations) {
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
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
|
+
];
|
|
1915
2168
|
entries.push({
|
|
1916
2169
|
operationId: operationIdOf(program, operation.operation),
|
|
1917
|
-
|
|
2170
|
+
output: outputs.length === 0 ? "never" : outputs.join(" | "),
|
|
2171
|
+
result: members.map(({ text }) => text),
|
|
2172
|
+
streams,
|
|
2173
|
+
bodyRefs,
|
|
1918
2174
|
});
|
|
1919
2175
|
}
|
|
1920
2176
|
});
|
|
1921
2177
|
return entries;
|
|
1922
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
|
+
`;
|
|
1923
2217
|
/**
|
|
1924
2218
|
* `requests.gen.ts` - the shape both sides are checked against.
|
|
1925
2219
|
*
|
|
@@ -1952,29 +2246,29 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
1952
2246
|
? `\nexport interface ${declaration.name} ${declaration.source}\n`
|
|
1953
2247
|
: `\nexport type ${declaration.name} = ${declaration.source};\n`;
|
|
1954
2248
|
});
|
|
1955
|
-
const
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
parts.push(entry.bodyRef);
|
|
1959
|
-
if (entry.extraProperties.length > 0) {
|
|
1960
|
-
parts.push(`{\n${entry.extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
|
|
1961
|
-
}
|
|
1962
|
-
return (`\n/** The effective input for \`${entry.operationId}\` - its body, with path and query parameters merged over it. */\n` +
|
|
1963
|
-
`export type ${upperFirst(entry.operationId)}Input = Simplify<${parts.join(" & ")}>;\n`);
|
|
1964
|
-
});
|
|
2249
|
+
const responseLookup = responses
|
|
2250
|
+
.map((entry) => `\t/** Every success body \`${entry.operationId}\` declares. */\n\t${entry.operationId}: ${entry.output};`)
|
|
2251
|
+
.join("\n");
|
|
1965
2252
|
/**
|
|
1966
|
-
*
|
|
1967
|
-
*
|
|
1968
|
-
*
|
|
1969
|
-
* only the value side, so `Contracts["AppointOfficerInput"]` does not resolve. Both sides'
|
|
1970
|
-
* assertions are written as mapped types over every operation, and this is what makes that
|
|
1971
|
-
* 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.
|
|
1972
2256
|
*/
|
|
1973
|
-
const
|
|
1974
|
-
.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("")};`)
|
|
1975
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
|
+
*/
|
|
1976
2270
|
const lookup = entries
|
|
1977
|
-
.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")};`)
|
|
1978
2272
|
.join("\n");
|
|
1979
2273
|
/**
|
|
1980
2274
|
* **Emitted only where an operation has something to flatten.**
|
|
@@ -1986,9 +2280,9 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
1986
2280
|
* `TS6196: 'Simplify' is declared but never used.` Third of its kind, after the unconditional
|
|
1987
2281
|
* `zValidator` and `z` imports.
|
|
1988
2282
|
*
|
|
1989
|
-
* Decided from the
|
|
2283
|
+
* Decided from the inputs actually rendered rather than by searching the output for the name.
|
|
1990
2284
|
*/
|
|
1991
|
-
const simplifyHelper =
|
|
2285
|
+
const simplifyHelper = !entries.some((entry) => entry.input.startsWith("Simplify<"))
|
|
1992
2286
|
? ""
|
|
1993
2287
|
: `/**
|
|
1994
2288
|
* Flattens an intersection into a single object type.
|
|
@@ -2024,14 +2318,19 @@ export type Produced<T> = T extends readonly (infer Element)[]
|
|
|
2024
2318
|
: T extends object
|
|
2025
2319
|
? { readonly [K in keyof T]: Produced<T[K]> }
|
|
2026
2320
|
: T;
|
|
2027
|
-
${
|
|
2028
|
-
/**
|
|
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
|
+
*/
|
|
2029
2326
|
export interface WireInputs {
|
|
2030
2327
|
${lookup}
|
|
2031
2328
|
}
|
|
2032
2329
|
|
|
2033
2330
|
/**
|
|
2034
|
-
* 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\`.
|
|
2035
2334
|
*
|
|
2036
2335
|
* **A floor, not an equality.** These shapes are permissive: a caller pins the fields it depends
|
|
2037
2336
|
* on and lets a richer view through, so a producer's result must CONTAIN what the document promises
|
|
@@ -2041,6 +2340,15 @@ ${lookup}
|
|
|
2041
2340
|
export interface WireOutputs {
|
|
2042
2341
|
${responseLookup}
|
|
2043
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
|
+
}
|
|
2044
2352
|
`;
|
|
2045
2353
|
}
|
|
2046
2354
|
/**
|
|
@@ -2078,16 +2386,25 @@ ${responseLookup}
|
|
|
2078
2386
|
* `Equals` here, not assignability: the two describe one shape, and a property present on one side
|
|
2079
2387
|
* and absent from the other is exactly the drift this exists to catch.
|
|
2080
2388
|
*/
|
|
2081
|
-
function renderWireAssertions(schemas, types, entries, contractsPackage) {
|
|
2389
|
+
function renderWireAssertions(schemas, types, entries, responses, contractsPackage) {
|
|
2082
2390
|
// The assertions pair the emitted Zod against the request TYPES, which live in the contracts
|
|
2083
2391
|
// package. With no package named there is nothing to import them from and nothing to pair - the
|
|
2084
2392
|
// types are still written to `contracts-output-dir`, they simply have no shared specifier yet.
|
|
2085
2393
|
if (contractsPackage === undefined)
|
|
2086
2394
|
return undefined;
|
|
2087
|
-
|
|
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
|
+
]);
|
|
2088
2405
|
const paired = [];
|
|
2089
2406
|
for (const declaration of types.declarations()) {
|
|
2090
|
-
if (!
|
|
2407
|
+
if (!bodyModels.has(declaration.name))
|
|
2091
2408
|
continue;
|
|
2092
2409
|
const schema = schemas
|
|
2093
2410
|
.declarations()
|
|
@@ -2148,26 +2465,6 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
|
|
|
2148
2465
|
* dropped from a shape that has other keys, and kept - with its value type still mapped - from one
|
|
2149
2466
|
* that does not.
|
|
2150
2467
|
*/
|
|
2151
|
-
/**
|
|
2152
|
-
* **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
|
|
2153
|
-
* deliberate on both sides.**
|
|
2154
|
-
*
|
|
2155
|
-
* The validator emits \`.exactOptional()\`, so what ARRIVES is absent or a value - JSON cannot carry an
|
|
2156
|
-
* \`undefined\` and the document says exactly that by leaving the property out of \`required\`. The
|
|
2157
|
-
* contract type is the floor a PRODUCER supplies, and \`{ p: undefined }\` serialises identically to
|
|
2158
|
-
* omitting \`p\`, so refusing it would cost every producer a conditional spread and buy the wire
|
|
2159
|
-
* nothing. Narrowing THAT surface was tried in \`0.19.0\` and reverted: it broke
|
|
2160
|
-
* \`(ctx, input) => ok(input)\`.
|
|
2161
|
-
*
|
|
2162
|
-
* So the two disagree on exactly one axis, on purpose, and this widens the inferred side to the
|
|
2163
|
-
* producer floor before comparing - the same job \`Declared\` does for openness, in the same pass
|
|
2164
|
-
* rather than a second deep one. Every other kind of drift still fails: a property that changes type,
|
|
2165
|
-
* appears, disappears or changes optionality is untouched by this.
|
|
2166
|
-
*/
|
|
2167
|
-
type Supplied<T, K extends keyof T> = {} extends Pick<T, K>
|
|
2168
|
-
? Declared<T[K]> | undefined
|
|
2169
|
-
: Declared<T[K]>;
|
|
2170
|
-
|
|
2171
2468
|
type DeclaredKeyOf<T> = keyof {
|
|
2172
2469
|
[K in keyof T as string extends K
|
|
2173
2470
|
? never
|
|
@@ -2196,9 +2493,8 @@ type Declared<T> = T extends (...args: never[]) => unknown
|
|
|
2196
2493
|
// equate a real dictionary with an empty object: a dictionary whose values are
|
|
2197
2494
|
// \`never\` accepts nothing either.
|
|
2198
2495
|
{}
|
|
2199
|
-
: // A shape with no declared keys is a dictionary:
|
|
2200
|
-
//
|
|
2201
|
-
// value type.
|
|
2496
|
+
: // A shape with no declared keys is a dictionary: its index signature is kept and
|
|
2497
|
+
// its value type mapped.
|
|
2202
2498
|
{ [K in keyof T]: Declared<T[K]> }
|
|
2203
2499
|
: {
|
|
2204
2500
|
[K in keyof T as string extends K
|
|
@@ -2207,7 +2503,7 @@ type Declared<T> = T extends (...args: never[]) => unknown
|
|
|
2207
2503
|
? never
|
|
2208
2504
|
: symbol extends K
|
|
2209
2505
|
? never
|
|
2210
|
-
: K]:
|
|
2506
|
+
: K]: Declared<T[K]>;
|
|
2211
2507
|
}
|
|
2212
2508
|
: T;
|
|
2213
2509
|
|
|
@@ -2342,6 +2638,8 @@ export async function emitHttpZod(context) {
|
|
|
2342
2638
|
* there means a server emitter follows without knowing this rule exists.
|
|
2343
2639
|
*/
|
|
2344
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);
|
|
2345
2643
|
/**
|
|
2346
2644
|
* **Every service must seal the same way, because `@typespec/openapi3` has no per-service
|
|
2347
2645
|
* options.**
|
|
@@ -2386,8 +2684,22 @@ export async function emitHttpZod(context) {
|
|
|
2386
2684
|
(distinctServices.size > 1
|
|
2387
2685
|
? resolvePath(context.emitterOutputDir, serviceName)
|
|
2388
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"];
|
|
2389
2698
|
const resolved = {
|
|
2390
|
-
contractsOutputDir: perService?.["contracts-output-dir"] ??
|
|
2699
|
+
contractsOutputDir: perService?.["contracts-output-dir"] ??
|
|
2700
|
+
(sharedContractsDir !== undefined && sharingContracts.length > 1
|
|
2701
|
+
? resolvePath(sharedContractsDir, serviceName)
|
|
2702
|
+
: sharedContractsDir),
|
|
2391
2703
|
contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
|
|
2392
2704
|
sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
|
|
2393
2705
|
compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
|
|
@@ -2453,8 +2765,15 @@ export async function emitHttpZod(context) {
|
|
|
2453
2765
|
});
|
|
2454
2766
|
if (resolved.contractsOutputDir !== undefined) {
|
|
2455
2767
|
const types = new TypeRegistry(context.program);
|
|
2456
|
-
|
|
2457
|
-
|
|
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
|
+
}));
|
|
2458
2777
|
await emitFile(context.program, {
|
|
2459
2778
|
path: resolvePath(resolved.contractsOutputDir, "requests.gen.ts"),
|
|
2460
2779
|
content: renderRequestTypes(entries, responses, types),
|
|
@@ -2463,7 +2782,7 @@ export async function emitHttpZod(context) {
|
|
|
2463
2782
|
path: resolvePath(resolved.contractsOutputDir, "vocabularies.gen.ts"),
|
|
2464
2783
|
content: renderVocabularies(vocabularies),
|
|
2465
2784
|
});
|
|
2466
|
-
const assertions = renderWireAssertions(registry, types, entries, resolved.contractsPackage);
|
|
2785
|
+
const assertions = renderWireAssertions(registry, types, entries, responses, resolved.contractsPackage);
|
|
2467
2786
|
if (assertions !== undefined) {
|
|
2468
2787
|
await emitFile(context.program, {
|
|
2469
2788
|
path: resolvePath(outputDir, "wire-contract.gen.ts"),
|
|
@@ -2471,7 +2790,12 @@ export async function emitHttpZod(context) {
|
|
|
2471
2790
|
});
|
|
2472
2791
|
}
|
|
2473
2792
|
}
|
|
2474
|
-
|
|
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 });
|
|
2475
2799
|
}
|
|
2476
2800
|
return emitted;
|
|
2477
2801
|
}
|