typespec-http-zod 0.29.0 → 0.30.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/dist/src/api.d.ts +24 -0
- package/dist/src/api.js +197 -47
- package/dist/src/constraints.d.ts +15 -0
- package/dist/src/constraints.js +1 -1
- package/dist/src/lib.d.ts +19 -1
- package/dist/src/lib.js +38 -0
- package/dist/src/registry.js +2 -2
- package/dist/src/types.js +29 -6
- package/dist/src/zod.d.ts +44 -0
- package/dist/src/zod.js +165 -16
- package/package.json +1 -1
package/dist/src/api.d.ts
CHANGED
|
@@ -257,6 +257,19 @@ export interface EmittedRoute {
|
|
|
257
257
|
* a scalar. Empty when there is none, never `undefined`.
|
|
258
258
|
*/
|
|
259
259
|
readonly decodedPathParameters: readonly string[];
|
|
260
|
+
/**
|
|
261
|
+
* Every pair of inputs a handler would be handed under ONE key - `@header("x")` beside
|
|
262
|
+
* `@query("x")`, two `@query("x")`, a `@path id` beside a `@header("id")`, a parameter named as
|
|
263
|
+
* the body is. Empty for almost every operation.
|
|
264
|
+
*
|
|
265
|
+
* **A server emitter spreads the parameter groups into one input, so the later group's value
|
|
266
|
+
* silently replaced the earlier one**, after each had been validated against its own schema.
|
|
267
|
+
* `@typespec/http` refuses only a path and a query parameter of one name. The handler input has
|
|
268
|
+
* one value per key and cannot carry both, so a server should not mount such an operation; this
|
|
269
|
+
* package reports it as `shared-input-key`, and states the key as both declarations at once. See
|
|
270
|
+
* {@link SharedInputKey}.
|
|
271
|
+
*/
|
|
272
|
+
readonly sharedInputKeys: readonly SharedInputKey[];
|
|
260
273
|
/**
|
|
261
274
|
* A query string written into the route itself, as the pairs it spells:
|
|
262
275
|
* `/items?fixed=true{¶m}` carries `[["fixed", "true"]]`. It identifies the route as much as
|
|
@@ -439,6 +452,17 @@ export interface EmittedRoute {
|
|
|
439
452
|
*/
|
|
440
453
|
readonly security: readonly SecurityRequirement[];
|
|
441
454
|
}
|
|
455
|
+
/** Where a value reaches a handler's input from. */
|
|
456
|
+
export type InputLocation = "path" | "query" | "header" | "cookie" | "body";
|
|
457
|
+
/** Two inputs a handler would be handed under one key. See {@link EmittedRoute.sharedInputKeys}. */
|
|
458
|
+
export interface SharedInputKey {
|
|
459
|
+
/** The key both arrive under, as the input spells it. */
|
|
460
|
+
readonly key: string;
|
|
461
|
+
/** Where the input declared first comes from. */
|
|
462
|
+
readonly first: InputLocation;
|
|
463
|
+
/** Where the input declared second comes from. */
|
|
464
|
+
readonly second: InputLocation;
|
|
465
|
+
}
|
|
442
466
|
export declare function collectRoutes(program: Program, registry: SchemaRegistry, service: HttpService): EmittedRoute[];
|
|
443
467
|
/** Whether emitted source references `name` as an identifier rather than as part of a longer one. */
|
|
444
468
|
export declare function referencedIn(source: string, name: string): boolean;
|
package/dist/src/api.js
CHANGED
|
@@ -11,7 +11,7 @@ import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
|
|
|
11
11
|
import { EXPLODED_QUERY_SOURCE, explodedQueryCall, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, } from "./uri-template.js";
|
|
12
12
|
export { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
|
|
13
13
|
export { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
|
|
14
|
-
import { collectExternalImports, isPayloadProperty, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
|
|
14
|
+
import { collectExternalImports, encodedTypeOf, isPayloadProperty, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
|
|
15
15
|
/**
|
|
16
16
|
* `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
|
|
17
17
|
* service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
|
|
@@ -328,19 +328,20 @@ function resolveResponses(program, operation) {
|
|
|
328
328
|
file: false,
|
|
329
329
|
};
|
|
330
330
|
for (const content of response.responses) {
|
|
331
|
-
|
|
332
|
-
|
|
331
|
+
const body = presentBody(content.body);
|
|
332
|
+
if (entry.body === undefined && body?.bodyKind === "single") {
|
|
333
|
+
entry.body = body.type;
|
|
333
334
|
}
|
|
334
335
|
/**
|
|
335
336
|
* **An `Http.File` response's body is its contents, served as bytes.** The same reading
|
|
336
337
|
* as a file request body: a media type and nothing to serialise. Read only for
|
|
337
338
|
* `"single"`, every download in `type/file` declared `image/png` and was served empty.
|
|
338
339
|
*/
|
|
339
|
-
if (entry.body === undefined &&
|
|
340
|
-
entry.body =
|
|
340
|
+
if (entry.body === undefined && body?.bodyKind === "file") {
|
|
341
|
+
entry.body = body.contents.type;
|
|
341
342
|
entry.file = true;
|
|
342
343
|
}
|
|
343
|
-
for (const contentType of
|
|
344
|
+
for (const contentType of body?.contentTypes ?? []) {
|
|
344
345
|
entry.contentTypes.add(contentType);
|
|
345
346
|
}
|
|
346
347
|
for (const [name, property] of Object.entries(content.headers ?? {})) {
|
|
@@ -606,6 +607,26 @@ export function errorBodyOf(program, service) {
|
|
|
606
607
|
function bodyTypeOf(type) {
|
|
607
608
|
return isVoidType(type) || isNeverType(type) ? undefined : type;
|
|
608
609
|
}
|
|
610
|
+
/**
|
|
611
|
+
* A declared body as the wire carries it: `undefined` where it is `void` or `never`, so no media
|
|
612
|
+
* type is listed for it either.
|
|
613
|
+
*
|
|
614
|
+
* **`@typespec/http` keeps a body it was told is empty**, media types and all, whenever the response
|
|
615
|
+
* or request declares it explicitly - `@statusCode _: 409; @body body: void`. Every artefact read
|
|
616
|
+
* the media types off it, so a 409 with no body was published as `contentTypes:
|
|
617
|
+
* ["application/json"]` and `schema: undefined`, which a consumer reads as a body it cannot validate,
|
|
618
|
+
* and a `void` request body reached the Zod walk and refused the compile. openapi3 publishes no
|
|
619
|
+
* `content` for `void`. For `never` it publishes `schema: {}` - any value - because its body paths
|
|
620
|
+
* test only for `void`, while it omits a `never` PROPERTY from every schema; a type with no values is
|
|
621
|
+
* no body here, and `reference.md` records the divergence.
|
|
622
|
+
*/
|
|
623
|
+
function presentBody(body) {
|
|
624
|
+
return body?.bodyKind === "single" && bodyTypeOf(body.type) === undefined ? undefined : body;
|
|
625
|
+
}
|
|
626
|
+
/** The operation's request body, absent where it is declared `void` or `never`. */
|
|
627
|
+
function requestBodyParameterOf(operation) {
|
|
628
|
+
return presentBody(operation.parameters.body);
|
|
629
|
+
}
|
|
609
630
|
/**
|
|
610
631
|
* The media types this operation's success response can be served as.
|
|
611
632
|
*
|
|
@@ -623,15 +644,24 @@ function responseContentTypesOf(operation, status) {
|
|
|
623
644
|
if (response.statusCodes !== status)
|
|
624
645
|
continue;
|
|
625
646
|
for (const content of response.responses) {
|
|
626
|
-
for (const contentType of content.body?.contentTypes ?? [])
|
|
647
|
+
for (const contentType of presentBody(content.body)?.contentTypes ?? []) {
|
|
627
648
|
types.add(contentType);
|
|
649
|
+
}
|
|
628
650
|
}
|
|
629
651
|
}
|
|
630
652
|
return [...types];
|
|
631
653
|
}
|
|
632
|
-
/**
|
|
654
|
+
/**
|
|
655
|
+
* The type a parameter actually crosses the wire as, after any `@encode` on the property.
|
|
656
|
+
*
|
|
657
|
+
* **Read through `encodedTypeOf`, the rule the parameter's schema is built by.** This read
|
|
658
|
+
* `getEncode(...).type` directly, which for an array encoding on a query parameter is `string`: the
|
|
659
|
+
* schema kept the declared `int32[]`, as the document does, while the element decoder found no list
|
|
660
|
+
* and left `"1"` to meet `z.int32()`. Every path here that decides how a value is split, decoded or
|
|
661
|
+
* expanded reads this type, so none of them can disagree with the schema it feeds.
|
|
662
|
+
*/
|
|
633
663
|
function encodedParameterType(program, property) {
|
|
634
|
-
return
|
|
664
|
+
return encodedTypeOf(program, property) ?? property.type;
|
|
635
665
|
}
|
|
636
666
|
/**
|
|
637
667
|
* The wire kind of an ARRAY parameter's elements, or `undefined` where it is not such an array.
|
|
@@ -856,7 +886,7 @@ function isParseableMediaType(type) {
|
|
|
856
886
|
}
|
|
857
887
|
/** The declared request body type, if the operation takes one. */
|
|
858
888
|
function requestBodyOf(operation) {
|
|
859
|
-
const body = operation
|
|
889
|
+
const body = requestBodyParameterOf(operation);
|
|
860
890
|
if (body?.bodyKind === "single")
|
|
861
891
|
return body.type;
|
|
862
892
|
return undefined;
|
|
@@ -893,9 +923,17 @@ const COLLECTION_DELIMITERS = {
|
|
|
893
923
|
"ArrayEncoding.commaDelimited": ",",
|
|
894
924
|
"ArrayEncoding.newlineDelimited": "\n",
|
|
895
925
|
};
|
|
896
|
-
/**
|
|
897
|
-
|
|
898
|
-
|
|
926
|
+
/**
|
|
927
|
+
* Whether the parameter crosses the wire as a list, which is what makes a delimiter meaningful.
|
|
928
|
+
*
|
|
929
|
+
* **The encoded type, not the declared one.** `@header @encode(ArrayEncoding.commaDelimited) tags:
|
|
930
|
+
* string[]` is a string on the wire - the document publishes `type: string` - and its schema is
|
|
931
|
+
* `z.string()`; reading the declared list split `"a,b"` into an array and handed that to the string
|
|
932
|
+
* schema, so every request was refused. Only a query parameter keeps its list under an array
|
|
933
|
+
* encoding, and only there is a split undone.
|
|
934
|
+
*/
|
|
935
|
+
function isCollectionParameter(program, property) {
|
|
936
|
+
return isArrayType(encodedParameterType(program, property));
|
|
899
937
|
}
|
|
900
938
|
/**
|
|
901
939
|
* Whether the values arrive one per OCCURRENCE (RFC 6570 explode) rather than joined into one.
|
|
@@ -906,8 +944,8 @@ function isCollectionParameter(property) {
|
|
|
906
944
|
* refused a conformant request while accepting the same list sent twice, which no document
|
|
907
945
|
* describes. Found by the first consumer's `?topics=ip` (#1).
|
|
908
946
|
*/
|
|
909
|
-
function isExplodedCollection(parameter) {
|
|
910
|
-
return (isCollectionParameter(parameter.param) &&
|
|
947
|
+
function isExplodedCollection(program, parameter) {
|
|
948
|
+
return (isCollectionParameter(program, parameter.param) &&
|
|
911
949
|
(parameter.type === "query" || parameter.type === "header") &&
|
|
912
950
|
parameter.explode === true);
|
|
913
951
|
}
|
|
@@ -919,7 +957,7 @@ function isExplodedCollection(parameter) {
|
|
|
919
957
|
* Those are the two that looked correct because the document and the validator agreed.
|
|
920
958
|
*/
|
|
921
959
|
function collectionDelimiterOf(program, parameter) {
|
|
922
|
-
if (!isCollectionParameter(parameter.param))
|
|
960
|
+
if (!isCollectionParameter(program, parameter.param))
|
|
923
961
|
return undefined;
|
|
924
962
|
if (parameter.type !== "query" && parameter.type !== "header")
|
|
925
963
|
return undefined;
|
|
@@ -1078,6 +1116,9 @@ function parameterSchemasOf(program, operation, registry, segments,
|
|
|
1078
1116
|
/** The names of a query string written into the route itself, which no parameter may gather. */
|
|
1079
1117
|
literalQueryNames) {
|
|
1080
1118
|
const byTarget = {};
|
|
1119
|
+
const add = (target, key, expression) => {
|
|
1120
|
+
(byTarget[target] ??= []).push(`\t${objectKey(key)}: ${expression},`);
|
|
1121
|
+
};
|
|
1081
1122
|
let accept;
|
|
1082
1123
|
/** Form-exploded records and models, gathered back under their names around the query object. */
|
|
1083
1124
|
const gathered = [];
|
|
@@ -1115,8 +1156,21 @@ literalQueryNames) {
|
|
|
1115
1156
|
* via `resolveEncodedName` - the document publishes one name per parameter, and it is this one.
|
|
1116
1157
|
* The TypeSpec property name is an authoring convenience that does not survive to the contract.
|
|
1117
1158
|
*/
|
|
1159
|
+
/**
|
|
1160
|
+
* **Two parameters of one location and one wire name are one value on the wire**, and both
|
|
1161
|
+
* declarations apply to it: their schemas are intersected and the value is decoded once, by
|
|
1162
|
+
* the first declaration's wire form, so each judges the same decoded value. Decoded by each
|
|
1163
|
+
* and then intersected, `@query("x") a: string` beside `@query("x") b: int32` would hand Zod
|
|
1164
|
+
* `"1"` and `1` to merge, which it does not refuse but throws on. An object literal cannot
|
|
1165
|
+
* hold the key twice either. `shared-input-key` reports it.
|
|
1166
|
+
*/
|
|
1167
|
+
const sharing = operation.parameters.parameters.filter((other) => other.type === parameter.type && other.name === parameter.name);
|
|
1168
|
+
if (sharing[0] !== parameter)
|
|
1169
|
+
continue;
|
|
1118
1170
|
const delimiter = collectionDelimiterOf(program, parameter);
|
|
1119
|
-
const declared =
|
|
1171
|
+
const declared = sharing
|
|
1172
|
+
.map((other) => registry.expressionForProperty(other.param))
|
|
1173
|
+
.reduce((first, other) => `${first}.and(${other})`);
|
|
1120
1174
|
/**
|
|
1121
1175
|
* The wire form is decoded before it is validated, never instead of it. `z.preprocess` undoes
|
|
1122
1176
|
* exactly the flattening the document's `style` describes and then applies the schema the
|
|
@@ -1146,7 +1200,7 @@ literalQueryNames) {
|
|
|
1146
1200
|
* occurrences arrive already an array and pass through untouched, exactly as the delimiter
|
|
1147
1201
|
* arm leaves a pre-split value alone.
|
|
1148
1202
|
*/
|
|
1149
|
-
const boxedOrSplit = isExplodedCollection(parameter)
|
|
1203
|
+
const boxedOrSplit = isExplodedCollection(program, parameter)
|
|
1150
1204
|
? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
|
|
1151
1205
|
: delimiter === undefined
|
|
1152
1206
|
? decoded
|
|
@@ -1156,11 +1210,12 @@ literalQueryNames) {
|
|
|
1156
1210
|
* wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
|
|
1157
1211
|
* run on that. See {@link uriExpansionCall}.
|
|
1158
1212
|
*/
|
|
1159
|
-
const
|
|
1160
|
-
const
|
|
1213
|
+
const wire = encodedParameterType(program, parameter.param);
|
|
1214
|
+
const shape = expansionShapeOf(wire);
|
|
1215
|
+
const values = recordValuesOf(program, wire);
|
|
1161
1216
|
let expression = boxedOrSplit;
|
|
1162
1217
|
if (parameter.type === "path") {
|
|
1163
|
-
const segment = decodedSegmentOf(parameter, segments);
|
|
1218
|
+
const segment = decodedSegmentOf(program, parameter, segments);
|
|
1164
1219
|
if (segment !== undefined) {
|
|
1165
1220
|
expression = `z.preprocess(${uriExpansionCall({ ...segment, name: parameter.name, shape, values })}, ${decoded})`;
|
|
1166
1221
|
}
|
|
@@ -1171,9 +1226,7 @@ literalQueryNames) {
|
|
|
1171
1226
|
* `{?param*}` over an object expands to the object's own keys, so there is no `param` key for
|
|
1172
1227
|
* this entry to read; the gatherer around the query object puts one back.
|
|
1173
1228
|
*/
|
|
1174
|
-
const model =
|
|
1175
|
-
? parameter.param.type
|
|
1176
|
-
: undefined;
|
|
1229
|
+
const model = wire.kind === "Model" && wire.indexer === undefined ? wire : undefined;
|
|
1177
1230
|
const properties = model === undefined ? [] : modelPropertiesOf(model);
|
|
1178
1231
|
gathered.push(explodedQueryCall({
|
|
1179
1232
|
name: parameter.name,
|
|
@@ -1182,10 +1235,10 @@ literalQueryNames) {
|
|
|
1182
1235
|
? undefined
|
|
1183
1236
|
: properties.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1184
1237
|
numbers: properties
|
|
1185
|
-
.filter((property) => wireKindOf(program, property
|
|
1238
|
+
.filter((property) => wireKindOf(program, encodedParameterType(program, property)) === "number")
|
|
1186
1239
|
.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1187
1240
|
booleans: properties
|
|
1188
|
-
.filter((property) => wireKindOf(program, property
|
|
1241
|
+
.filter((property) => wireKindOf(program, encodedParameterType(program, property)) === "boolean")
|
|
1189
1242
|
.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1190
1243
|
values,
|
|
1191
1244
|
}));
|
|
@@ -1194,8 +1247,7 @@ literalQueryNames) {
|
|
|
1194
1247
|
expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode: false, shape, values })}, ${decoded})`;
|
|
1195
1248
|
}
|
|
1196
1249
|
}
|
|
1197
|
-
|
|
1198
|
-
(byTarget[parameter.type] ??= []).push(entry);
|
|
1250
|
+
add(parameter.type, parameter.name, expression);
|
|
1199
1251
|
/**
|
|
1200
1252
|
* **On a route several operations share, `accept` SELECTS the operation - it is not a
|
|
1201
1253
|
* constraint on one of them.** Each member declares its own literal, so validating against any
|
|
@@ -1210,15 +1262,15 @@ literalQueryNames) {
|
|
|
1210
1262
|
accept = { name: parameter.name, value: declared.value };
|
|
1211
1263
|
}
|
|
1212
1264
|
else if (parameter.type === "header") {
|
|
1213
|
-
(
|
|
1265
|
+
add("header-without-accept", parameter.name, expression);
|
|
1214
1266
|
}
|
|
1215
1267
|
}
|
|
1216
1268
|
const fileContentType = fileContentTypeOf(operation);
|
|
1217
1269
|
if (fileContentType !== undefined) {
|
|
1218
1270
|
// The same decoding a declared `Content-Type` header gets: parameters dropped, lowercased.
|
|
1219
|
-
const
|
|
1220
|
-
(
|
|
1221
|
-
(
|
|
1271
|
+
const expression = mediaTypeDecoded(registry.expressionFor(fileContentType.type));
|
|
1272
|
+
add("header", "Content-Type", expression);
|
|
1273
|
+
add("header-without-accept", "Content-Type", expression);
|
|
1222
1274
|
}
|
|
1223
1275
|
const shape = (target) => {
|
|
1224
1276
|
const entries = byTarget[target];
|
|
@@ -1237,20 +1289,25 @@ literalQueryNames) {
|
|
|
1237
1289
|
accept,
|
|
1238
1290
|
};
|
|
1239
1291
|
}
|
|
1240
|
-
/** What a parameter's value decodes into once its expansion is undone. */
|
|
1241
1292
|
/**
|
|
1242
1293
|
* The route segment a path parameter's validator decodes, or `undefined` where it validates the
|
|
1243
1294
|
* segment's text as the value. The one rule for both the validator and
|
|
1244
1295
|
* {@link EmittedRoute.decodedPathParameters}, so a consumer reading the route cannot disagree with
|
|
1245
1296
|
* the validator it describes.
|
|
1297
|
+
*
|
|
1298
|
+
* **Shaped from the encoded type, as the validator's schema is.** `@path
|
|
1299
|
+
* @encode(ArrayEncoding.commaDelimited) ids: string[]` is a string on the wire, published as `type:
|
|
1300
|
+
* string`, and validated by `z.string()`; shaped from the declared list it was expanded into
|
|
1301
|
+
* `["a", "b"]` first, and every request was refused.
|
|
1246
1302
|
*/
|
|
1247
|
-
function decodedSegmentOf(parameter, segments) {
|
|
1303
|
+
function decodedSegmentOf(program, parameter, segments) {
|
|
1248
1304
|
const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
|
|
1249
1305
|
return segment !== undefined &&
|
|
1250
|
-
needsExpansionDecoding(segment, expansionShapeOf(parameter.param
|
|
1306
|
+
needsExpansionDecoding(segment, expansionShapeOf(encodedParameterType(program, parameter.param)))
|
|
1251
1307
|
? segment
|
|
1252
1308
|
: undefined;
|
|
1253
1309
|
}
|
|
1310
|
+
/** What a parameter's value decodes into once its expansion is undone. */
|
|
1254
1311
|
function expansionShapeOf(type) {
|
|
1255
1312
|
if (isArrayType(type))
|
|
1256
1313
|
return "list";
|
|
@@ -1280,7 +1337,7 @@ function modelPropertiesOf(model) {
|
|
|
1280
1337
|
* `undefined` where the body is not a file, or where the operation already declares the header.
|
|
1281
1338
|
*/
|
|
1282
1339
|
function fileContentTypeOf(operation) {
|
|
1283
|
-
const body = operation
|
|
1340
|
+
const body = requestBodyParameterOf(operation);
|
|
1284
1341
|
if (body?.bodyKind !== "file")
|
|
1285
1342
|
return undefined;
|
|
1286
1343
|
const declared = operation.parameters.parameters.some((parameter) => parameter.type === "header" && parameter.name.toLowerCase() === "content-type");
|
|
@@ -1302,7 +1359,7 @@ function fileContentTypeOf(operation) {
|
|
|
1302
1359
|
* body. A read-only property is not sent, so it contributes nothing.
|
|
1303
1360
|
*/
|
|
1304
1361
|
function mergedBodyKeysOf(program, operation) {
|
|
1305
|
-
const body = operation
|
|
1362
|
+
const body = requestBodyParameterOf(operation);
|
|
1306
1363
|
if (body?.bodyKind === "multipart") {
|
|
1307
1364
|
return body.parts.flatMap((part) => (part.name === undefined ? [] : [part.name]));
|
|
1308
1365
|
}
|
|
@@ -1313,9 +1370,86 @@ function mergedBodyKeysOf(program, operation) {
|
|
|
1313
1370
|
.filter((property) => isPayloadProperty(program, property))
|
|
1314
1371
|
.map((property) => resolveEncodedName(program, property, "application/json")));
|
|
1315
1372
|
}
|
|
1373
|
+
const INPUT_LOCATION_NOUNS = {
|
|
1374
|
+
path: "path parameter",
|
|
1375
|
+
query: "query parameter",
|
|
1376
|
+
header: "header",
|
|
1377
|
+
cookie: "cookie",
|
|
1378
|
+
body: "body",
|
|
1379
|
+
};
|
|
1380
|
+
/**
|
|
1381
|
+
* The pairs of inputs an operation hands its handler under one key, reported once per pair and
|
|
1382
|
+
* program.
|
|
1383
|
+
*
|
|
1384
|
+
* **Read from the same facts the input is rendered from**: each parameter under its wire name, the
|
|
1385
|
+
* `Content-Type` of an `Http.File` body, and the body where {@link requestBodyPlacementOf} names it.
|
|
1386
|
+
* A merged body is not listed, because that decision already names the body whenever one of its keys
|
|
1387
|
+
* is a parameter's - which is how a `@query("body")` beside a body named `body` stays possible to
|
|
1388
|
+
* write, and why it is caught here rather than there.
|
|
1389
|
+
*
|
|
1390
|
+
* Resolved once per operation, since the route walk and the contract walk both read it and the
|
|
1391
|
+
* warning must be raised once.
|
|
1392
|
+
*/
|
|
1393
|
+
function sharedInputKeysOf(program, operation) {
|
|
1394
|
+
const resolved = sharedInputKeys.get(operation);
|
|
1395
|
+
if (resolved !== undefined)
|
|
1396
|
+
return resolved;
|
|
1397
|
+
const inputs = [];
|
|
1398
|
+
for (const parameter of operation.parameters.parameters) {
|
|
1399
|
+
if (parameter.type === "path" ||
|
|
1400
|
+
parameter.type === "query" ||
|
|
1401
|
+
parameter.type === "header" ||
|
|
1402
|
+
parameter.type === "cookie") {
|
|
1403
|
+
inputs.push({ key: parameter.name, location: parameter.type, target: parameter.param });
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
const fileContentType = fileContentTypeOf(operation);
|
|
1407
|
+
if (fileContentType !== undefined) {
|
|
1408
|
+
inputs.push({ key: "Content-Type", location: "header", target: fileContentType });
|
|
1409
|
+
}
|
|
1410
|
+
const placement = requestBodyPlacementOf(program, operation);
|
|
1411
|
+
const bodyKey = placement.rawBodyProperty ?? placement.bodyProperty;
|
|
1412
|
+
if (bodyKey !== undefined) {
|
|
1413
|
+
inputs.push({
|
|
1414
|
+
key: bodyKey,
|
|
1415
|
+
location: "body",
|
|
1416
|
+
target: requestBodyParameterOf(operation)?.property ?? operation.operation,
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
const shared = [];
|
|
1420
|
+
inputs.forEach((input, index) => {
|
|
1421
|
+
const first = inputs.slice(0, index).find((earlier) => earlier.key === input.key);
|
|
1422
|
+
if (first === undefined)
|
|
1423
|
+
return;
|
|
1424
|
+
shared.push({ key: input.key, first: first.location, second: input.location });
|
|
1425
|
+
const format = {
|
|
1426
|
+
operation: operationIdOf(program, operation.operation),
|
|
1427
|
+
key: input.key,
|
|
1428
|
+
first: INPUT_LOCATION_NOUNS[first.location],
|
|
1429
|
+
second: INPUT_LOCATION_NOUNS[input.location],
|
|
1430
|
+
};
|
|
1431
|
+
/**
|
|
1432
|
+
* **Once per program, keyed on what the warning says.** Each emitter that runs the library
|
|
1433
|
+
* collects its own operations, over types a visibility or version projection may have cloned,
|
|
1434
|
+
* so neither the operation nor the declaration is the same object twice: `typespec-hono` beside
|
|
1435
|
+
* `typespec-http-mcp` raised the warning twice per pair.
|
|
1436
|
+
*/
|
|
1437
|
+
const reported = sharedInputKeysReported.get(program) ?? new Set();
|
|
1438
|
+
sharedInputKeysReported.set(program, reported);
|
|
1439
|
+
const said = JSON.stringify(format);
|
|
1440
|
+
if (reported.has(said))
|
|
1441
|
+
return;
|
|
1442
|
+
reported.add(said);
|
|
1443
|
+
reportDiagnostic(program, { code: "shared-input-key", target: input.target, format });
|
|
1444
|
+
});
|
|
1445
|
+
sharedInputKeys.set(operation, shared);
|
|
1446
|
+
return shared;
|
|
1447
|
+
}
|
|
1448
|
+
const sharedInputKeys = new WeakMap();
|
|
1449
|
+
const sharedInputKeysReported = new WeakMap();
|
|
1316
1450
|
function requestBodyPlacementOf(program, operation) {
|
|
1317
1451
|
const requestType = requestBodyOf(operation);
|
|
1318
|
-
const bodyParameter = operation
|
|
1452
|
+
const bodyParameter = requestBodyParameterOf(operation);
|
|
1319
1453
|
const contentTypes = [...(bodyParameter?.contentTypes ?? [])];
|
|
1320
1454
|
/**
|
|
1321
1455
|
* A `bytes` body means the BYTES are the contract.
|
|
@@ -1506,18 +1640,19 @@ export function collectRoutes(program, registry, service) {
|
|
|
1506
1640
|
.filter((parameter) => parameter.type === "path" && parameter.allowReserved)
|
|
1507
1641
|
.map((parameter) => parameter.name),
|
|
1508
1642
|
pathSegments: template.segments,
|
|
1643
|
+
sharedInputKeys: sharedInputKeysOf(program, operation),
|
|
1509
1644
|
decodedPathParameters: operation.parameters.parameters
|
|
1510
|
-
.filter((parameter) => decodedSegmentOf(parameter, template.segments) !== undefined)
|
|
1645
|
+
.filter((parameter) => decodedSegmentOf(program, parameter, template.segments) !== undefined)
|
|
1511
1646
|
.map((parameter) => parameter.name),
|
|
1512
1647
|
literalQuery: template.literalQuery,
|
|
1513
1648
|
responseContentTypes: statusCode === undefined ? [] : responseContentTypesOf(operation, statusCode),
|
|
1514
|
-
requestContentTypes: [...(operation
|
|
1649
|
+
requestContentTypes: [...(requestBodyParameterOf(operation)?.contentTypes ?? [])],
|
|
1515
1650
|
// `isRawBinaryMediaType` is "every declared type is non-JSON", which is the same question
|
|
1516
1651
|
// here: a string body under `application/json` IS json, and its reader is the JSON one.
|
|
1517
1652
|
requestTextual: requestType !== undefined &&
|
|
1518
1653
|
rawBodyProperty === undefined &&
|
|
1519
1654
|
isStringType(requestType) &&
|
|
1520
|
-
isRawBinaryMediaType([...(operation
|
|
1655
|
+
isRawBinaryMediaType([...(requestBodyParameterOf(operation)?.contentTypes ?? [])]),
|
|
1521
1656
|
/**
|
|
1522
1657
|
* `@summary` first, `@doc` as the fallback.
|
|
1523
1658
|
*
|
|
@@ -1537,7 +1672,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1537
1672
|
requestSchema: withVisibility(program, requestVisibility, () => {
|
|
1538
1673
|
// Multipart first: its body has no single type to walk, and skipping it left every
|
|
1539
1674
|
// such route mounted with nothing checking the request at all.
|
|
1540
|
-
const parameterBody = operation
|
|
1675
|
+
const parameterBody = requestBodyParameterOf(operation);
|
|
1541
1676
|
if (parameterBody?.bodyKind === "multipart") {
|
|
1542
1677
|
return multipartSchemaOf(program, parameterBody, registry);
|
|
1543
1678
|
}
|
|
@@ -2135,7 +2270,7 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2135
2270
|
const body = requestBodyOf(operation);
|
|
2136
2271
|
const placement = requestBodyPlacementOf(program, operation);
|
|
2137
2272
|
/** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
|
|
2138
|
-
const parameterBody = operation
|
|
2273
|
+
const parameterBody = requestBodyParameterOf(operation);
|
|
2139
2274
|
/**
|
|
2140
2275
|
* Headers are part of the input too.
|
|
2141
2276
|
*
|
|
@@ -2155,11 +2290,24 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2155
2290
|
* `"x-thing"` here too: a consumer checking its handlers against these types otherwise
|
|
2156
2291
|
* fails to typecheck against a server that is correct.
|
|
2157
2292
|
*/
|
|
2158
|
-
.map((parameter) =>
|
|
2293
|
+
.map((parameter) => ({
|
|
2294
|
+
key: parameter.name,
|
|
2295
|
+
line: registry.expressionForProperty(parameter.param, parameter.name),
|
|
2296
|
+
}));
|
|
2159
2297
|
const fileContentType = fileContentTypeOf(operation);
|
|
2160
2298
|
if (fileContentType !== undefined) {
|
|
2161
|
-
extraProperties.push(
|
|
2299
|
+
extraProperties.push({
|
|
2300
|
+
key: "Content-Type",
|
|
2301
|
+
line: `${objectKey("Content-Type")}: ${registry.expressionFor(fileContentType.type)};`,
|
|
2302
|
+
});
|
|
2162
2303
|
}
|
|
2304
|
+
/**
|
|
2305
|
+
* **A key two parameters share is written once per declaration, each in its own part**,
|
|
2306
|
+
* so the intersection types it as both - what the validators beside it accept - where two
|
|
2307
|
+
* members of one object literal do not compile (TS2300). `shared-input-key` reports it.
|
|
2308
|
+
*/
|
|
2309
|
+
const repeated = extraProperties.filter((property, index) => extraProperties.slice(0, index).some((earlier) => earlier.key === property.key));
|
|
2310
|
+
const distinct = extraProperties.filter((property) => !repeated.includes(property));
|
|
2163
2311
|
const parts = [];
|
|
2164
2312
|
let streamsBody = false;
|
|
2165
2313
|
let bodyRef;
|
|
@@ -2195,9 +2343,11 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2195
2343
|
: `{\n\t${objectKey(placement.bodyProperty)}${placement.optionalBody ? "?" : ""}: ${expression.replaceAll("\n", "\n\t")};\n}`);
|
|
2196
2344
|
}
|
|
2197
2345
|
}
|
|
2198
|
-
if (
|
|
2199
|
-
parts.push(`{\n${
|
|
2346
|
+
if (distinct.length > 0) {
|
|
2347
|
+
parts.push(`{\n${distinct.map(({ line }) => `\t${line}`).join("\n")}\n}`);
|
|
2200
2348
|
}
|
|
2349
|
+
for (const { line } of repeated)
|
|
2350
|
+
parts.push(`{\n\t${line}\n}`);
|
|
2201
2351
|
entries.push({
|
|
2202
2352
|
operationId: operationIdOf(program, operation.operation),
|
|
2203
2353
|
bodyRef,
|
|
@@ -15,6 +15,21 @@ import { type Program, type Type } from "@typespec/compiler";
|
|
|
15
15
|
* asserts that no emitted file carries a `.refine(` outside the one multipart-file carve-out.
|
|
16
16
|
*/
|
|
17
17
|
export declare function applyConstraints(program: Program, expression: string, target: Type): string;
|
|
18
|
+
/**
|
|
19
|
+
* **Whether this diagnostic has already been raised against this declaration.**
|
|
20
|
+
*
|
|
21
|
+
* Keyed on the code as well as the target. Keying on the target alone once made the memo shared
|
|
22
|
+
* between every pattern diagnostic, so whichever ran first claimed the declaration and silenced
|
|
23
|
+
* the rest: measured on `^(\w+\s?)*\-$`, one warning where two were due. The ReDoS judgement has
|
|
24
|
+
* since moved to a linter rule, which reports per declaration by construction, so that pairing can
|
|
25
|
+
* no longer meet here - but a memo keyed on the target alone is the wrong shape for the next
|
|
26
|
+
* diagnostic added beside this one, and costs nothing to keep right.
|
|
27
|
+
*
|
|
28
|
+
* The de-duplication itself is per declaration, because emission reaches a scalar once per USE -
|
|
29
|
+
* 153 times for a single scalar in the reference service - and repeating one warning that often
|
|
30
|
+
* is noise a reader learns to skip, which is how a real one gets missed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function firstReportOf(program: Program, code: string, target: Type): boolean;
|
|
18
33
|
/**
|
|
19
34
|
* **Whether a `@pattern` can be proven safe against catastrophic backtracking.**
|
|
20
35
|
*
|
package/dist/src/constraints.js
CHANGED
|
@@ -160,7 +160,7 @@ const reported = new WeakMap();
|
|
|
160
160
|
* 153 times for a single scalar in the reference service - and repeating one warning that often
|
|
161
161
|
* is noise a reader learns to skip, which is how a real one gets missed.
|
|
162
162
|
*/
|
|
163
|
-
function firstReportOf(program, code, target) {
|
|
163
|
+
export function firstReportOf(program, code, target) {
|
|
164
164
|
let byCode = reported.get(program);
|
|
165
165
|
if (byCode === undefined) {
|
|
166
166
|
byCode = new Map();
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -174,11 +174,17 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
174
174
|
"inline-cycle": {
|
|
175
175
|
readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
|
|
176
176
|
};
|
|
177
|
+
"encode-cycle": {
|
|
178
|
+
readonly default: import("@typespec/compiler").CallableMessage<["type", "chain"]>;
|
|
179
|
+
};
|
|
180
|
+
"shared-input-key": {
|
|
181
|
+
readonly default: import("@typespec/compiler").CallableMessage<["operation", "first", "key", "second", "key", "key"]>;
|
|
182
|
+
};
|
|
177
183
|
"duplicate-declaration": {
|
|
178
184
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
179
185
|
};
|
|
180
186
|
}, EmitterOptions, never>;
|
|
181
|
-
export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "inline-cycle" | "non-unicode-pattern" | "optional-prototype-name" | "truncated-doc-comment" | "undeclared-discriminator" | "unenforceable-encoded-bound" | "unenforceable-property-name" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
187
|
+
export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "encode-cycle" | "inline-cycle" | "non-unicode-pattern" | "optional-prototype-name" | "shared-input-key" | "truncated-doc-comment" | "undeclared-discriminator" | "unenforceable-encoded-bound" | "unenforceable-property-name" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
182
188
|
"unsupported-type": {
|
|
183
189
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
184
190
|
};
|
|
@@ -224,6 +230,12 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
224
230
|
"inline-cycle": {
|
|
225
231
|
readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
|
|
226
232
|
};
|
|
233
|
+
"encode-cycle": {
|
|
234
|
+
readonly default: import("@typespec/compiler").CallableMessage<["type", "chain"]>;
|
|
235
|
+
};
|
|
236
|
+
"shared-input-key": {
|
|
237
|
+
readonly default: import("@typespec/compiler").CallableMessage<["operation", "first", "key", "second", "key", "key"]>;
|
|
238
|
+
};
|
|
227
239
|
"duplicate-declaration": {
|
|
228
240
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
229
241
|
};
|
|
@@ -273,6 +285,12 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
273
285
|
"inline-cycle": {
|
|
274
286
|
readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
|
|
275
287
|
};
|
|
288
|
+
"encode-cycle": {
|
|
289
|
+
readonly default: import("@typespec/compiler").CallableMessage<["type", "chain"]>;
|
|
290
|
+
};
|
|
291
|
+
"shared-input-key": {
|
|
292
|
+
readonly default: import("@typespec/compiler").CallableMessage<["operation", "first", "key", "second", "key", "key"]>;
|
|
293
|
+
};
|
|
276
294
|
"duplicate-declaration": {
|
|
277
295
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
278
296
|
};
|
package/dist/src/lib.js
CHANGED
|
@@ -380,6 +380,44 @@ const diagnostics = {
|
|
|
380
380
|
default: paramMessage `'${"type"}' closes a reference cycle without passing through anything this emitter can declare, so the cycle has no name to reference itself by. A template instantiation is inlined rather than declared, exactly as '@typespec/openapi3' inlines it, and that emitter refuses the same spec with its own 'inline-cycle' error. Give the type a name with '@friendlyName' so it becomes a declaration, or break the cycle.`,
|
|
381
381
|
},
|
|
382
382
|
},
|
|
383
|
+
/**
|
|
384
|
+
* **An `@encode` chain that returns to where it started never reaches a wire type.**
|
|
385
|
+
*
|
|
386
|
+
* `@encode("x", Pong) scalar Ping` and `@encode("x", Ping) scalar Pong` compile cleanly - the
|
|
387
|
+
* compiler checks neither the encoding's name nor where its type leads - and the walks here
|
|
388
|
+
* followed `@encode` by calling themselves on the encoded type. Measured before this existed:
|
|
389
|
+
* `RangeError: Maximum call stack size exceeded`, "Emitter crashed! This is a bug."
|
|
390
|
+
*
|
|
391
|
+
* **An error, as `inline-cycle` is.** A value of such a scalar has no form on the wire to validate,
|
|
392
|
+
* so there is no validator to emit. openapi3 reads one link of the chain and publishes a schema
|
|
393
|
+
* with no `type` at all - `{ format: "x" }` - which states nothing about the value either.
|
|
394
|
+
*/
|
|
395
|
+
"encode-cycle": {
|
|
396
|
+
severity: "error",
|
|
397
|
+
messages: {
|
|
398
|
+
default: paramMessage `'${"type"}' is encoded as ${"chain"}: the '@encode' chain returns to a scalar it already passed, so it never reaches a type that crosses the wire. Encode one of them as a standard scalar such as 'string', or break the cycle.`,
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
/**
|
|
402
|
+
* **Two inputs of one operation handed to its handler under one key.**
|
|
403
|
+
*
|
|
404
|
+
* A handler's input is the path, query, header and cookie groups spread into one object, with the
|
|
405
|
+
* body merged or named beside them. `@header("x") a: string` beside `@query("x") b: int32` therefore
|
|
406
|
+
* reached the handler as one `x`, the header's string replacing the validated number, while the
|
|
407
|
+
* input was typed `number & string`. `@typespec/http` refuses only a path and a query parameter of
|
|
408
|
+
* one name; the rest compiled with no diagnostic (typespec-http-graphql, 2026-10-16).
|
|
409
|
+
*
|
|
410
|
+
* **A warning, because the spec is valid and openapi3 publishes it**: the parameters are distinct on
|
|
411
|
+
* the wire. It is the input this package states that cannot carry both, so the key is typed and
|
|
412
|
+
* validated as both declarations at once, and the contract types still compile. A server emitter
|
|
413
|
+
* reading `EmittedRoute.sharedInputKeys` declines to mount the operation.
|
|
414
|
+
*/
|
|
415
|
+
"shared-input-key": {
|
|
416
|
+
severity: "warning",
|
|
417
|
+
messages: {
|
|
418
|
+
default: paramMessage `'${"operation"}' takes the ${"first"} '${"key"}' and the ${"second"} '${"key"}', and a handler's input holds one value under each key, so '${"key"}' is stated as both declarations at once and accepts only a value satisfying both. Give one of them a different name.`,
|
|
419
|
+
},
|
|
420
|
+
},
|
|
383
421
|
"duplicate-declaration": {
|
|
384
422
|
severity: "error",
|
|
385
423
|
messages: {
|
package/dist/src/registry.js
CHANGED
|
@@ -451,8 +451,8 @@ export class SchemaRegistry {
|
|
|
451
451
|
* them.
|
|
452
452
|
*
|
|
453
453
|
* **Subtypes are deliberately NOT walked here, because nothing would be walking them.** A
|
|
454
|
-
* discriminated base renders as
|
|
455
|
-
* already asks the registry for each one. A loop here looked load-bearing and was measured not to
|
|
454
|
+
* discriminated base renders as a dispatch over its subtypes - `z.discriminatedUnion`, beside a base
|
|
455
|
+
* arm where its discriminator is open - and rendering that already asks the registry for each one. A loop here looked load-bearing and was measured not to
|
|
456
456
|
* be: removing it left all 23 polymorphism and differential arms green.
|
|
457
457
|
*
|
|
458
458
|
* **Wrapped in {@link captureBackEdges} and the result discarded.** This call can land on a
|
package/dist/src/types.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getDiscriminatedUnion, isNeverType, resolveEncodedName, } from "@typespec/compiler";
|
|
2
2
|
import { reportDiagnostic } from "./lib.js";
|
|
3
3
|
import { literal } from "./literal.js";
|
|
4
|
-
import { discriminatedSubtypes, effectiveIndexer, emitsDefault, encodedTypeOf, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
|
|
4
|
+
import { discriminatedBaseOf, discriminatedSubtypes, effectiveIndexer, emitsDefault, encodeCycleOf, encodedTypeOf, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
|
|
5
5
|
/**
|
|
6
6
|
* TypeSpec type -> plain TypeScript type text.
|
|
7
7
|
*
|
|
@@ -75,8 +75,12 @@ function refuse(program, type, why, remedy = "") {
|
|
|
75
75
|
function scalarToTs(program, scalar) {
|
|
76
76
|
// A scalar declaration can carry the encoding itself - see `scalarToZod`.
|
|
77
77
|
const encoded = encodedTypeOf(program, scalar);
|
|
78
|
-
if (encoded !== undefined && encoded !== scalar)
|
|
79
|
-
|
|
78
|
+
if (encoded !== undefined && encoded !== scalar) {
|
|
79
|
+
// Reported once, by the schema walk - see `encodeCycleOf`.
|
|
80
|
+
return encodeCycleOf(program, scalar) === undefined
|
|
81
|
+
? scalarToTs(program, encoded)
|
|
82
|
+
: UNREPRESENTABLE;
|
|
83
|
+
}
|
|
80
84
|
let current = scalar;
|
|
81
85
|
// Walk `extends` so `scalar TrimmedString extends string` resolves to the primitive underneath.
|
|
82
86
|
while (current !== undefined) {
|
|
@@ -153,11 +157,26 @@ function modelToTs(program, model) {
|
|
|
153
157
|
* the emitter disagreeing with itself - one TypeSpec, walked twice - and this is the second defect
|
|
154
158
|
* of that class it has found. The first was a vocabulary inlined by one walk and referenced by the
|
|
155
159
|
* other.
|
|
160
|
+
*
|
|
161
|
+
* **An open base adds itself as the last arm**, as `modelToZod` does: a value whose discriminator
|
|
162
|
+
* names no subtype is the base's own object. The base arm's discriminator keeps its declared type
|
|
163
|
+
* here; TypeScript cannot say "a string other than these", and the validator's refinement does not
|
|
164
|
+
* change what it accepts as input, so `z.input` and this type stay identical.
|
|
156
165
|
*/
|
|
157
|
-
const
|
|
158
|
-
if (
|
|
159
|
-
|
|
166
|
+
const base = discriminatedBaseOf(program, model);
|
|
167
|
+
if (base !== undefined) {
|
|
168
|
+
const arms = discriminatedSubtypes(program, model).map((derived) => typeToTs(program, derived));
|
|
169
|
+
if (base.open)
|
|
170
|
+
arms.push(modelObjectToTs(program, model, base));
|
|
171
|
+
return arms.join(" | ");
|
|
160
172
|
}
|
|
173
|
+
return modelObjectToTs(program, model);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* A model as the object of its own properties, inherited ones included - for a discriminated base,
|
|
177
|
+
* its base arm, with the `string` discriminator openapi3 adds where the base declares none.
|
|
178
|
+
*/
|
|
179
|
+
function modelObjectToTs(program, model, base) {
|
|
161
180
|
const indexer = effectiveIndexer(model);
|
|
162
181
|
const indexerKey = indexer?.key.name;
|
|
163
182
|
// Inherited properties included - see `inheritedAndOwnProperties`. Both artefacts describe the
|
|
@@ -214,6 +233,10 @@ function modelToTs(program, model) {
|
|
|
214
233
|
.filter((property) => isPayloadProperty(program, property))
|
|
215
234
|
.filter((property) => !isNeverType(property.type))
|
|
216
235
|
.map((property) => `\t${withDeclarationSite(property, () => propertyToTs(program, property))}`);
|
|
236
|
+
if (base !== undefined &&
|
|
237
|
+
!declared.some((property) => resolveEncodedName(program, property, "application/json") === base.propertyName)) {
|
|
238
|
+
entries.push(`\t${objectKey(base.propertyName)}: string;`);
|
|
239
|
+
}
|
|
217
240
|
/**
|
|
218
241
|
* **The indexer is deliberately NOT intersected in.**
|
|
219
242
|
*
|
package/dist/src/zod.d.ts
CHANGED
|
@@ -109,6 +109,16 @@ export declare function described(expression: string, doc: string | undefined):
|
|
|
109
109
|
* Without that, `string`'s own "A sequence of textual characters." would sit on every `z.string()`.
|
|
110
110
|
*/
|
|
111
111
|
export declare function scalarDocOf(program: Program, scalar: Scalar): string | undefined;
|
|
112
|
+
/** A scalar's schema without its description, for a position that publishes its own. */
|
|
113
|
+
/**
|
|
114
|
+
* The scalars an `@encode` chain passes through, when it returns to one it already passed - and
|
|
115
|
+
* `undefined` when it ends at a type that crosses the wire.
|
|
116
|
+
*
|
|
117
|
+
* **The one guard for every walk that follows `@encode`.** The schema, the wire kind and the contract
|
|
118
|
+
* type each follow the chain by calling themselves on the encoded type, and each one overflowed the
|
|
119
|
+
* stack on `Ping -> Pong -> Ping`. A scalar encoded as itself is no cycle: the chain ends there.
|
|
120
|
+
*/
|
|
121
|
+
export declare function encodeCycleOf(program: Program, scalar: Scalar): readonly Scalar[] | undefined;
|
|
112
122
|
export declare function withSyntheticDeclarations<T>(hook: (name: string, build: () => string) => string, run: () => T): T;
|
|
113
123
|
/**
|
|
114
124
|
* Every property the model declares, **including the ones it inherits**.
|
|
@@ -142,6 +152,40 @@ export declare function effectiveIndexer(model: Model): Model["indexer"];
|
|
|
142
152
|
* for a real subtype - the same rule that decides whether a base may be sealed.
|
|
143
153
|
*/
|
|
144
154
|
export declare function discriminatedSubtypes(program: Program, model: Model): readonly Model[];
|
|
155
|
+
/**
|
|
156
|
+
* **How a `@discriminator` base is validated: a value whose discriminator names a subtype as that
|
|
157
|
+
* subtype, and any other value as the base model itself.**
|
|
158
|
+
*
|
|
159
|
+
* The document publishes the base as an object of its own properties carrying `discriminator:
|
|
160
|
+
* {propertyName, mapping}`, and every subtype as `allOf` that base. The mapping says which subtype a
|
|
161
|
+
* value naming it is; nothing in the document refuses a value naming none. This emitter dispatched
|
|
162
|
+
* on the mapping and stopped there, so `{ "kind": "wrongKind", "weight": 8 }` was refused for a
|
|
163
|
+
* `Dog` whose `kind` is `string | "golden"`, though `Dog` requires only `kind` and `weight`. That is
|
|
164
|
+
* the response `@typespec/http-specs` declares for `type/model/inheritance/enum-discriminator`,
|
|
165
|
+
* `single-discriminator` and `nested-discriminator`, and a handler returning it failed its own
|
|
166
|
+
* response contract (typespec-http-graphql, 2026-10-16).
|
|
167
|
+
*
|
|
168
|
+
* **A closed discriminator every value of which a subtype claims has no base arm**: a value outside
|
|
169
|
+
* it is refused by the base's own discriminator, and a value inside it always names a subtype. So a
|
|
170
|
+
* fixed enum fully covered by its subtypes is validated exactly as before.
|
|
171
|
+
*/
|
|
172
|
+
export interface DiscriminatedBase {
|
|
173
|
+
/** The discriminator's wire name. */
|
|
174
|
+
readonly propertyName: string;
|
|
175
|
+
/** The subtypes a `z.discriminatedUnion` dispatches to, each carrying literal discriminator values. */
|
|
176
|
+
readonly dispatched: readonly Model[];
|
|
177
|
+
/**
|
|
178
|
+
* Subtypes that are themselves bases validated with a base arm. A `z.union` has no literal values
|
|
179
|
+
* to dispatch on, so no `z.discriminatedUnion` can hold one; it sits beside the dispatch, where its
|
|
180
|
+
* own literal discriminator keeps it exclusive.
|
|
181
|
+
*/
|
|
182
|
+
readonly beside: readonly Model[];
|
|
183
|
+
/** Every value a subtype claims, which are the keys of the document's `mapping`. */
|
|
184
|
+
readonly claimed: readonly string[];
|
|
185
|
+
/** Whether a value no subtype claims is validated as the base model. */
|
|
186
|
+
readonly open: boolean;
|
|
187
|
+
}
|
|
188
|
+
export declare function discriminatedBaseOf(program: Program, model: Model): DiscriminatedBase | undefined;
|
|
145
189
|
export declare function wireKindOf(program: Program, type: Type): "number" | "boolean" | undefined;
|
|
146
190
|
/**
|
|
147
191
|
* A name as an object key - quoted when it is not a legal JavaScript identifier.
|
package/dist/src/zod.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { getDiscriminatedUnion, getDiscriminator, getDoc, getEncode, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
|
|
1
|
+
import { getDiscriminatedUnion, getDiscriminatedUnionFromInheritance, getDiscriminator, getDoc, getEncode, ignoreDiagnostics, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
|
|
2
2
|
import { createMetadataInfo, getCookieParamOptions, getHeaderFieldName, getPathParamName, getQueryParamName, isCookieParam, isHeader, isPathParam, isQueryParam, Visibility, } from "@typespec/http";
|
|
3
|
-
import { applyConstraints } from "./constraints.js";
|
|
3
|
+
import { applyConstraints, firstReportOf } from "./constraints.js";
|
|
4
4
|
import { literal } from "./literal.js";
|
|
5
5
|
import { reportDiagnostic } from "./lib.js";
|
|
6
6
|
/**
|
|
@@ -453,10 +453,48 @@ export function scalarDocOf(program, scalar) {
|
|
|
453
453
|
return undefined;
|
|
454
454
|
}
|
|
455
455
|
/** A scalar's schema without its description, for a position that publishes its own. */
|
|
456
|
+
/**
|
|
457
|
+
* The scalars an `@encode` chain passes through, when it returns to one it already passed - and
|
|
458
|
+
* `undefined` when it ends at a type that crosses the wire.
|
|
459
|
+
*
|
|
460
|
+
* **The one guard for every walk that follows `@encode`.** The schema, the wire kind and the contract
|
|
461
|
+
* type each follow the chain by calling themselves on the encoded type, and each one overflowed the
|
|
462
|
+
* stack on `Ping -> Pong -> Ping`. A scalar encoded as itself is no cycle: the chain ends there.
|
|
463
|
+
*/
|
|
464
|
+
export function encodeCycleOf(program, scalar) {
|
|
465
|
+
const passed = [];
|
|
466
|
+
for (let current = scalar; current !== undefined;) {
|
|
467
|
+
if (passed.includes(current))
|
|
468
|
+
return [...passed, current];
|
|
469
|
+
passed.push(current);
|
|
470
|
+
const encoded = encodedTypeOf(program, current);
|
|
471
|
+
current = encoded === current ? undefined : encoded;
|
|
472
|
+
}
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
456
475
|
function scalarSchemaOf(program, scalar) {
|
|
457
476
|
// `scalar unixTimestamp32 is utcDateTime;` with `@encode(int32)` on the SCALAR, not on each use.
|
|
458
477
|
const encoded = encodedTypeOf(program, scalar);
|
|
459
478
|
if (encoded !== undefined && encoded !== scalar) {
|
|
479
|
+
const cycle = encodeCycleOf(program, scalar);
|
|
480
|
+
if (cycle !== undefined) {
|
|
481
|
+
// Reached once per use; reported once per scalar.
|
|
482
|
+
if (firstReportOf(program, "encode-cycle", scalar)) {
|
|
483
|
+
reportDiagnostic(program, {
|
|
484
|
+
code: "encode-cycle",
|
|
485
|
+
target: scalar,
|
|
486
|
+
format: {
|
|
487
|
+
type: scalar.name,
|
|
488
|
+
// From the first link on: `Ping` is encoded as `'Pong' -> 'Ping'`.
|
|
489
|
+
chain: cycle
|
|
490
|
+
.slice(1)
|
|
491
|
+
.map((link) => `'${link.name}'`)
|
|
492
|
+
.join(" -> "),
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return UNREPRESENTABLE;
|
|
497
|
+
}
|
|
460
498
|
return applyConstraints(program, scalarSchemaOf(program, encoded), scalar);
|
|
461
499
|
}
|
|
462
500
|
let current = scalar;
|
|
@@ -726,31 +764,130 @@ export function discriminatedSubtypes(program, model) {
|
|
|
726
764
|
return [];
|
|
727
765
|
return model.derivedModels.filter((derived) => includeDerivedModel(derived));
|
|
728
766
|
}
|
|
767
|
+
export function discriminatedBaseOf(program, model) {
|
|
768
|
+
const discriminator = getDiscriminator(program, model);
|
|
769
|
+
const subtypes = discriminatedSubtypes(program, model);
|
|
770
|
+
if (discriminator === undefined || subtypes.length === 0)
|
|
771
|
+
return undefined;
|
|
772
|
+
/**
|
|
773
|
+
* The compiler's own reading of which subtype claims which value, which is what openapi3 writes
|
|
774
|
+
* the `mapping` from.
|
|
775
|
+
*/
|
|
776
|
+
const claimed = [
|
|
777
|
+
...ignoreDiagnostics(getDiscriminatedUnionFromInheritance(model, discriminator)).variants.keys(),
|
|
778
|
+
];
|
|
779
|
+
const declared = discriminatorPropertyOf(program, model, discriminator.propertyName);
|
|
780
|
+
/**
|
|
781
|
+
* Undeclared, the discriminator is the `string` openapi3 adds to the base - open. Declared, it is
|
|
782
|
+
* closed only where its type is a finite set of strings.
|
|
783
|
+
*/
|
|
784
|
+
const values = declared === undefined ? undefined : closedValuesOf(declared.type);
|
|
785
|
+
const beside = subtypes.filter((subtype) => isValidatedWithBaseArm(program, subtype));
|
|
786
|
+
return {
|
|
787
|
+
propertyName: discriminator.propertyName,
|
|
788
|
+
dispatched: subtypes.filter((subtype) => !beside.includes(subtype)),
|
|
789
|
+
beside,
|
|
790
|
+
claimed,
|
|
791
|
+
open: values === undefined || values.some((value) => !claimed.includes(value)),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
/** Whether a model is a base validated as a `z.union` rather than a `z.discriminatedUnion`. */
|
|
795
|
+
function isValidatedWithBaseArm(program, model) {
|
|
796
|
+
const base = discriminatedBaseOf(program, model);
|
|
797
|
+
return base !== undefined && (base.open || base.beside.length > 0);
|
|
798
|
+
}
|
|
799
|
+
/** The base's own discriminator property, matched by wire name as openapi3 matches it. */
|
|
800
|
+
function discriminatorPropertyOf(program, model, propertyName) {
|
|
801
|
+
return inheritedAndOwnProperties(model).find((property) => rawPropertyKey(program, property) === propertyName);
|
|
802
|
+
}
|
|
803
|
+
/** The strings a type admits, where they are finite; `undefined` where it admits any string. */
|
|
804
|
+
function closedValuesOf(type) {
|
|
805
|
+
if (type.kind === "String")
|
|
806
|
+
return [type.value];
|
|
807
|
+
if (type.kind === "EnumMember") {
|
|
808
|
+
const value = type.value ?? type.name;
|
|
809
|
+
return typeof value === "string" ? [value] : undefined;
|
|
810
|
+
}
|
|
811
|
+
const members = type.kind === "Enum"
|
|
812
|
+
? [...type.members.values()]
|
|
813
|
+
: type.kind === "Union"
|
|
814
|
+
? [...type.variants.values()].map((variant) => variant.type)
|
|
815
|
+
: undefined;
|
|
816
|
+
if (members === undefined)
|
|
817
|
+
return undefined;
|
|
818
|
+
const values = [];
|
|
819
|
+
for (const member of members) {
|
|
820
|
+
const closed = closedValuesOf(member);
|
|
821
|
+
if (closed === undefined)
|
|
822
|
+
return undefined;
|
|
823
|
+
values.push(...closed);
|
|
824
|
+
}
|
|
825
|
+
return values;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* The base arm's discriminator, refused for every value a subtype claims.
|
|
829
|
+
*
|
|
830
|
+
* **Without this a known kind falls through to the base.** `z.union` tries the dispatch and then the
|
|
831
|
+
* base arm, and a base with subtypes is never sealed, so `{kind: "sparrow", wingspan: 1, x: 1}` -
|
|
832
|
+
* refused by the sealed `Sparrow` - would be accepted as a `Bird` with `x` stripped. Guarded, a value
|
|
833
|
+
* naming a subtype is validated as that subtype and nothing else.
|
|
834
|
+
*
|
|
835
|
+
* `abort` keeps this refusal out of the error a caller reads: Zod reports the one arm that did not
|
|
836
|
+
* abort, which for a known kind is the subtype that was actually broken.
|
|
837
|
+
*/
|
|
838
|
+
function guardedDiscriminator(schema, base, model) {
|
|
839
|
+
if (base.claimed.length === 0)
|
|
840
|
+
return schema;
|
|
841
|
+
const test = base.claimed.map((value) => `value !== ${literal(value)}`).join(" && ");
|
|
842
|
+
const error = literal(`Validated as the subtype this '${base.propertyName}' names, not as '${model.name}'.`);
|
|
843
|
+
return `${schema}.refine((value) => ${test}, { abort: true, error: ${error} })`;
|
|
844
|
+
}
|
|
729
845
|
function modelToZod(program, model) {
|
|
730
846
|
/**
|
|
731
|
-
* **A `@discriminator` base is a CHOICE between its subtypes,
|
|
847
|
+
* **A `@discriminator` base is a CHOICE between its subtypes, and its own shape for any value
|
|
848
|
+
* naming none of them.** See {@link DiscriminatedBase}.
|
|
732
849
|
*
|
|
733
850
|
* `@discriminator("kind") model Bird { kind: string; wingspan: int32 }` with four models extending
|
|
734
851
|
* it reaches OpenAPI as a component carrying `discriminator: {propertyName, mapping}`, and the
|
|
735
852
|
* mapping is an instruction: validate the body against whichever subtype the discriminator names.
|
|
736
|
-
* Emitting the base's own properties
|
|
853
|
+
* Emitting the base's own properties alone produced `z.object({kind: z.string(), wingspan})`,
|
|
737
854
|
* which accepts `{kind: "eagle"}` while ignoring everything `Eagle` actually declares - a
|
|
738
855
|
* polymorphic endpoint validating none of its polymorphism.
|
|
739
856
|
*
|
|
740
|
-
* `z.discriminatedUnion` is the faithful encoding and composes both ways this
|
|
741
|
-
* Measured on Zod 4.4.3: a union may be an OPTION of another union, which is how
|
|
857
|
+
* `z.discriminatedUnion` is the faithful encoding of the dispatch and composes both ways this
|
|
858
|
+
* corpus needs. Measured on Zod 4.4.3: a union may be an OPTION of another union, which is how
|
|
742
859
|
* `Fish -> Shark -> SawShark` works; and an option carrying a getter that refers back to the union
|
|
743
860
|
* being defined does **not** throw, because the discriminator is read without enumerating the rest
|
|
744
861
|
* of the shape. `Eagle.friends?: Bird[]` is exactly that, and it decides whether polymorphism and
|
|
745
862
|
* recursion can coexist at all.
|
|
863
|
+
*
|
|
864
|
+
* **`z.discriminatedUnion` has no arm for "any other value"**, and `unionFallback` cannot add one:
|
|
865
|
+
* it retries the same options, each of which must carry literal discriminator values. So an open
|
|
866
|
+
* base is `z.union([dispatch, ...nested bases, base arm])`, every arm exclusive on the
|
|
867
|
+
* discriminator - each subtype by its literal, the base arm by {@link guardedDiscriminator}.
|
|
746
868
|
*/
|
|
747
|
-
const
|
|
748
|
-
if (
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
869
|
+
const base = discriminatedBaseOf(program, model);
|
|
870
|
+
if (base !== undefined) {
|
|
871
|
+
const arms = [];
|
|
872
|
+
if (base.dispatched.length > 0) {
|
|
873
|
+
const options = base.dispatched.map((derived) => typeToZod(program, derived));
|
|
874
|
+
arms.push(`z.discriminatedUnion(${literal(base.propertyName)}, [${options.join(", ")}])`);
|
|
875
|
+
}
|
|
876
|
+
arms.push(...base.beside.map((derived) => typeToZod(program, derived)));
|
|
877
|
+
if (base.open)
|
|
878
|
+
arms.push(modelObjectToZod(program, model, base));
|
|
879
|
+
return arms.length === 1 ? (arms[0] ?? UNREPRESENTABLE) : `z.union([${arms.join(", ")}])`;
|
|
753
880
|
}
|
|
881
|
+
return modelObjectToZod(program, model);
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* A model as the object of its own properties, inherited ones included.
|
|
885
|
+
*
|
|
886
|
+
* **Given a discriminated base, this is its base arm**: the same object, with its discriminator
|
|
887
|
+
* refused for every value a subtype claims, and - where the base does not declare the discriminator -
|
|
888
|
+
* the required `string` openapi3 adds to the base's properties in its place.
|
|
889
|
+
*/
|
|
890
|
+
function modelObjectToZod(program, model, base) {
|
|
754
891
|
const indexer = effectiveIndexer(model);
|
|
755
892
|
const indexerKey = indexer?.key.name;
|
|
756
893
|
const declared = inheritedAndOwnProperties(model);
|
|
@@ -865,13 +1002,23 @@ function modelToZod(program, model) {
|
|
|
865
1002
|
* parameters for the reason `renderSchemas` names both: one leaves the input side `unknown`.
|
|
866
1003
|
*/
|
|
867
1004
|
const annotation = deferred ? (annotateDeferred(property) ?? "") : "";
|
|
1005
|
+
const guarded = base !== undefined && rawPropertyKey(program, property) === base.propertyName
|
|
1006
|
+
? guardedDiscriminator(value, base, model)
|
|
1007
|
+
: value;
|
|
868
1008
|
return {
|
|
869
1009
|
deferred,
|
|
870
1010
|
text: deferred
|
|
871
|
-
? `\tget ${key}()${annotation} {\n\t\treturn ${
|
|
872
|
-
: `\t${key}: ${
|
|
1011
|
+
? `\tget ${key}()${annotation} {\n\t\treturn ${guarded};\n\t},`
|
|
1012
|
+
: `\t${key}: ${guarded},`,
|
|
873
1013
|
};
|
|
874
1014
|
});
|
|
1015
|
+
if (base !== undefined &&
|
|
1016
|
+
discriminatorPropertyOf(program, model, base.propertyName) === undefined) {
|
|
1017
|
+
members.push({
|
|
1018
|
+
deferred: false,
|
|
1019
|
+
text: `\t${objectKey(base.propertyName)}: ${guardedDiscriminator("z.string()", base, model)},`,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
875
1022
|
const entries = members.map((member) => member.text);
|
|
876
1023
|
/**
|
|
877
1024
|
* A model-level `@refine` goes through the same path as every other constraint, so the predicate's
|
|
@@ -950,8 +1097,10 @@ const NUMERIC_EXPRESSIONS = ["z.number()", "z.int(", "z.int32(", "z.uint32("];
|
|
|
950
1097
|
export function wireKindOf(program, type) {
|
|
951
1098
|
if (type.kind === "Scalar") {
|
|
952
1099
|
const encoded = encodedTypeOf(program, type);
|
|
953
|
-
if (encoded !== undefined && encoded !== type)
|
|
954
|
-
|
|
1100
|
+
if (encoded !== undefined && encoded !== type) {
|
|
1101
|
+
// Reported by the schema walk; a chain with no end has no kind to decode into.
|
|
1102
|
+
return encodeCycleOf(program, type) === undefined ? wireKindOf(program, encoded) : undefined;
|
|
1103
|
+
}
|
|
955
1104
|
for (let current = type; current !== undefined; current = current.baseScalar) {
|
|
956
1105
|
const mapped = SCALARS[current.name];
|
|
957
1106
|
if (mapped === undefined)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-http-zod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "TypeSpec emitter and library: turn an HTTP service definition into Zod validators and framework-free contract types that agree, keyword for keyword, with the OpenAPI document @typespec/openapi3 publishes from the same source.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codegen",
|