typespec-http-zod 0.28.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 +43 -0
- package/dist/src/api.js +342 -57
- package/dist/src/constraints.d.ts +15 -0
- package/dist/src/constraints.js +46 -12
- package/dist/src/lib.d.ts +37 -1
- package/dist/src/lib.js +80 -0
- package/dist/src/literal.d.ts +27 -0
- package/dist/src/literal.js +33 -0
- package/dist/src/registry.js +30 -7
- package/dist/src/results.js +2 -1
- package/dist/src/types.js +34 -10
- package/dist/src/uri-template.d.ts +0 -12
- package/dist/src/uri-template.js +3 -2
- package/dist/src/zod.d.ts +78 -0
- package/dist/src/zod.js +366 -35
- package/package.json +1 -1
package/dist/src/api.js
CHANGED
|
@@ -2,6 +2,7 @@ import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary
|
|
|
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
|
+
import { literal } from "./literal.js";
|
|
5
6
|
import { SchemaRegistry, TypeRegistry } from "./registry.js";
|
|
6
7
|
import { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
|
|
7
8
|
import { propertyTypeToTs } from "./types.js";
|
|
@@ -10,7 +11,7 @@ import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
|
|
|
10
11
|
import { EXPLODED_QUERY_SOURCE, explodedQueryCall, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, } from "./uri-template.js";
|
|
11
12
|
export { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
|
|
12
13
|
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";
|
|
14
|
+
import { collectExternalImports, encodedTypeOf, isPayloadProperty, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
|
|
14
15
|
/**
|
|
15
16
|
* `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
|
|
16
17
|
* service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
|
|
@@ -53,6 +54,12 @@ export { EmitterOptionsSchema } from "./lib.js";
|
|
|
53
54
|
* independently.
|
|
54
55
|
*/
|
|
55
56
|
export { jsDocComment, objectKey } from "./zod.js";
|
|
57
|
+
/**
|
|
58
|
+
* And the same for a VALUE written into emitted code: a string, enum value or media type as a literal
|
|
59
|
+
* whose source is ASCII. Spec text outside ASCII is the case, and a sibling emitter spelling it with a
|
|
60
|
+
* bare `JSON.stringify` writes the character itself into a file its consumer commits.
|
|
61
|
+
*/
|
|
62
|
+
export { literal } from "./literal.js";
|
|
56
63
|
/** Named once. The emitted banner and the internal-invariant messages both quote it. */
|
|
57
64
|
const PACKAGE_NAME = "typespec-http-zod";
|
|
58
65
|
/**
|
|
@@ -321,19 +328,20 @@ function resolveResponses(program, operation) {
|
|
|
321
328
|
file: false,
|
|
322
329
|
};
|
|
323
330
|
for (const content of response.responses) {
|
|
324
|
-
|
|
325
|
-
|
|
331
|
+
const body = presentBody(content.body);
|
|
332
|
+
if (entry.body === undefined && body?.bodyKind === "single") {
|
|
333
|
+
entry.body = body.type;
|
|
326
334
|
}
|
|
327
335
|
/**
|
|
328
336
|
* **An `Http.File` response's body is its contents, served as bytes.** The same reading
|
|
329
337
|
* as a file request body: a media type and nothing to serialise. Read only for
|
|
330
338
|
* `"single"`, every download in `type/file` declared `image/png` and was served empty.
|
|
331
339
|
*/
|
|
332
|
-
if (entry.body === undefined &&
|
|
333
|
-
entry.body =
|
|
340
|
+
if (entry.body === undefined && body?.bodyKind === "file") {
|
|
341
|
+
entry.body = body.contents.type;
|
|
334
342
|
entry.file = true;
|
|
335
343
|
}
|
|
336
|
-
for (const contentType of
|
|
344
|
+
for (const contentType of body?.contentTypes ?? []) {
|
|
337
345
|
entry.contentTypes.add(contentType);
|
|
338
346
|
}
|
|
339
347
|
for (const [name, property] of Object.entries(content.headers ?? {})) {
|
|
@@ -599,6 +607,26 @@ export function errorBodyOf(program, service) {
|
|
|
599
607
|
function bodyTypeOf(type) {
|
|
600
608
|
return isVoidType(type) || isNeverType(type) ? undefined : type;
|
|
601
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
|
+
}
|
|
602
630
|
/**
|
|
603
631
|
* The media types this operation's success response can be served as.
|
|
604
632
|
*
|
|
@@ -616,15 +644,24 @@ function responseContentTypesOf(operation, status) {
|
|
|
616
644
|
if (response.statusCodes !== status)
|
|
617
645
|
continue;
|
|
618
646
|
for (const content of response.responses) {
|
|
619
|
-
for (const contentType of content.body?.contentTypes ?? [])
|
|
647
|
+
for (const contentType of presentBody(content.body)?.contentTypes ?? []) {
|
|
620
648
|
types.add(contentType);
|
|
649
|
+
}
|
|
621
650
|
}
|
|
622
651
|
}
|
|
623
652
|
return [...types];
|
|
624
653
|
}
|
|
625
|
-
/**
|
|
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
|
+
*/
|
|
626
663
|
function encodedParameterType(program, property) {
|
|
627
|
-
return
|
|
664
|
+
return encodedTypeOf(program, property) ?? property.type;
|
|
628
665
|
}
|
|
629
666
|
/**
|
|
630
667
|
* The wire kind of an ARRAY parameter's elements, or `undefined` where it is not such an array.
|
|
@@ -849,7 +886,7 @@ function isParseableMediaType(type) {
|
|
|
849
886
|
}
|
|
850
887
|
/** The declared request body type, if the operation takes one. */
|
|
851
888
|
function requestBodyOf(operation) {
|
|
852
|
-
const body = operation
|
|
889
|
+
const body = requestBodyParameterOf(operation);
|
|
853
890
|
if (body?.bodyKind === "single")
|
|
854
891
|
return body.type;
|
|
855
892
|
return undefined;
|
|
@@ -886,9 +923,17 @@ const COLLECTION_DELIMITERS = {
|
|
|
886
923
|
"ArrayEncoding.commaDelimited": ",",
|
|
887
924
|
"ArrayEncoding.newlineDelimited": "\n",
|
|
888
925
|
};
|
|
889
|
-
/**
|
|
890
|
-
|
|
891
|
-
|
|
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));
|
|
892
937
|
}
|
|
893
938
|
/**
|
|
894
939
|
* Whether the values arrive one per OCCURRENCE (RFC 6570 explode) rather than joined into one.
|
|
@@ -899,8 +944,8 @@ function isCollectionParameter(property) {
|
|
|
899
944
|
* refused a conformant request while accepting the same list sent twice, which no document
|
|
900
945
|
* describes. Found by the first consumer's `?topics=ip` (#1).
|
|
901
946
|
*/
|
|
902
|
-
function isExplodedCollection(parameter) {
|
|
903
|
-
return (isCollectionParameter(parameter.param) &&
|
|
947
|
+
function isExplodedCollection(program, parameter) {
|
|
948
|
+
return (isCollectionParameter(program, parameter.param) &&
|
|
904
949
|
(parameter.type === "query" || parameter.type === "header") &&
|
|
905
950
|
parameter.explode === true);
|
|
906
951
|
}
|
|
@@ -912,7 +957,7 @@ function isExplodedCollection(parameter) {
|
|
|
912
957
|
* Those are the two that looked correct because the document and the validator agreed.
|
|
913
958
|
*/
|
|
914
959
|
function collectionDelimiterOf(program, parameter) {
|
|
915
|
-
if (!isCollectionParameter(parameter.param))
|
|
960
|
+
if (!isCollectionParameter(program, parameter.param))
|
|
916
961
|
return undefined;
|
|
917
962
|
if (parameter.type !== "query" && parameter.type !== "header")
|
|
918
963
|
return undefined;
|
|
@@ -1045,8 +1090,8 @@ function mediaTypeDecoded(declared) {
|
|
|
1045
1090
|
if (value === "*/*")
|
|
1046
1091
|
return "z.string()";
|
|
1047
1092
|
return isMediaRange(value)
|
|
1048
|
-
? `z.templateLiteral([${
|
|
1049
|
-
: `z.literal(${
|
|
1093
|
+
? `z.templateLiteral([${literal(mediaTypeEssenceOf(value).slice(0, -1))}, z.string()])`
|
|
1094
|
+
: `z.literal(${literal(value)})`;
|
|
1050
1095
|
});
|
|
1051
1096
|
const schema = admitted.length === 1 ? (admitted[0] ?? declared) : `z.union([${admitted.join(", ")}])`;
|
|
1052
1097
|
const ranged = values.some(isMediaRange) ? schema : declared;
|
|
@@ -1063,7 +1108,7 @@ function mediaTypeDecoded(declared) {
|
|
|
1063
1108
|
const decoded = restored.length === 0
|
|
1064
1109
|
? essence
|
|
1065
1110
|
: `((essence) => ${restored
|
|
1066
|
-
.map((value) => `essence === ${
|
|
1111
|
+
.map((value) => `essence === ${literal(mediaTypeEssenceOf(value))} ? ${literal(value)} : `)
|
|
1067
1112
|
.join("")}essence)(${essence})`;
|
|
1068
1113
|
return `z.preprocess((raw) => (typeof raw === "string" ? ${decoded} : raw), ${ranged})`;
|
|
1069
1114
|
}
|
|
@@ -1071,6 +1116,9 @@ function parameterSchemasOf(program, operation, registry, segments,
|
|
|
1071
1116
|
/** The names of a query string written into the route itself, which no parameter may gather. */
|
|
1072
1117
|
literalQueryNames) {
|
|
1073
1118
|
const byTarget = {};
|
|
1119
|
+
const add = (target, key, expression) => {
|
|
1120
|
+
(byTarget[target] ??= []).push(`\t${objectKey(key)}: ${expression},`);
|
|
1121
|
+
};
|
|
1074
1122
|
let accept;
|
|
1075
1123
|
/** Form-exploded records and models, gathered back under their names around the query object. */
|
|
1076
1124
|
const gathered = [];
|
|
@@ -1108,8 +1156,21 @@ literalQueryNames) {
|
|
|
1108
1156
|
* via `resolveEncodedName` - the document publishes one name per parameter, and it is this one.
|
|
1109
1157
|
* The TypeSpec property name is an authoring convenience that does not survive to the contract.
|
|
1110
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;
|
|
1111
1170
|
const delimiter = collectionDelimiterOf(program, parameter);
|
|
1112
|
-
const declared =
|
|
1171
|
+
const declared = sharing
|
|
1172
|
+
.map((other) => registry.expressionForProperty(other.param))
|
|
1173
|
+
.reduce((first, other) => `${first}.and(${other})`);
|
|
1113
1174
|
/**
|
|
1114
1175
|
* The wire form is decoded before it is validated, never instead of it. `z.preprocess` undoes
|
|
1115
1176
|
* exactly the flattening the document's `style` describes and then applies the schema the
|
|
@@ -1139,22 +1200,23 @@ literalQueryNames) {
|
|
|
1139
1200
|
* occurrences arrive already an array and pass through untouched, exactly as the delimiter
|
|
1140
1201
|
* arm leaves a pre-split value alone.
|
|
1141
1202
|
*/
|
|
1142
|
-
const boxedOrSplit = isExplodedCollection(parameter)
|
|
1203
|
+
const boxedOrSplit = isExplodedCollection(program, parameter)
|
|
1143
1204
|
? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
|
|
1144
1205
|
: delimiter === undefined
|
|
1145
1206
|
? decoded
|
|
1146
|
-
: `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${
|
|
1207
|
+
: `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${literal(delimiter)}) : raw), ${decoded})`;
|
|
1147
1208
|
/**
|
|
1148
1209
|
* **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
|
|
1149
1210
|
* wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
|
|
1150
1211
|
* run on that. See {@link uriExpansionCall}.
|
|
1151
1212
|
*/
|
|
1152
|
-
const
|
|
1153
|
-
const
|
|
1213
|
+
const wire = encodedParameterType(program, parameter.param);
|
|
1214
|
+
const shape = expansionShapeOf(wire);
|
|
1215
|
+
const values = recordValuesOf(program, wire);
|
|
1154
1216
|
let expression = boxedOrSplit;
|
|
1155
1217
|
if (parameter.type === "path") {
|
|
1156
|
-
const segment =
|
|
1157
|
-
if (segment !== undefined
|
|
1218
|
+
const segment = decodedSegmentOf(program, parameter, segments);
|
|
1219
|
+
if (segment !== undefined) {
|
|
1158
1220
|
expression = `z.preprocess(${uriExpansionCall({ ...segment, name: parameter.name, shape, values })}, ${decoded})`;
|
|
1159
1221
|
}
|
|
1160
1222
|
}
|
|
@@ -1164,9 +1226,7 @@ literalQueryNames) {
|
|
|
1164
1226
|
* `{?param*}` over an object expands to the object's own keys, so there is no `param` key for
|
|
1165
1227
|
* this entry to read; the gatherer around the query object puts one back.
|
|
1166
1228
|
*/
|
|
1167
|
-
const model =
|
|
1168
|
-
? parameter.param.type
|
|
1169
|
-
: undefined;
|
|
1229
|
+
const model = wire.kind === "Model" && wire.indexer === undefined ? wire : undefined;
|
|
1170
1230
|
const properties = model === undefined ? [] : modelPropertiesOf(model);
|
|
1171
1231
|
gathered.push(explodedQueryCall({
|
|
1172
1232
|
name: parameter.name,
|
|
@@ -1175,10 +1235,10 @@ literalQueryNames) {
|
|
|
1175
1235
|
? undefined
|
|
1176
1236
|
: properties.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1177
1237
|
numbers: properties
|
|
1178
|
-
.filter((property) => wireKindOf(program, property
|
|
1238
|
+
.filter((property) => wireKindOf(program, encodedParameterType(program, property)) === "number")
|
|
1179
1239
|
.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1180
1240
|
booleans: properties
|
|
1181
|
-
.filter((property) => wireKindOf(program, property
|
|
1241
|
+
.filter((property) => wireKindOf(program, encodedParameterType(program, property)) === "boolean")
|
|
1182
1242
|
.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1183
1243
|
values,
|
|
1184
1244
|
}));
|
|
@@ -1187,8 +1247,7 @@ literalQueryNames) {
|
|
|
1187
1247
|
expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode: false, shape, values })}, ${decoded})`;
|
|
1188
1248
|
}
|
|
1189
1249
|
}
|
|
1190
|
-
|
|
1191
|
-
(byTarget[parameter.type] ??= []).push(entry);
|
|
1250
|
+
add(parameter.type, parameter.name, expression);
|
|
1192
1251
|
/**
|
|
1193
1252
|
* **On a route several operations share, `accept` SELECTS the operation - it is not a
|
|
1194
1253
|
* constraint on one of them.** Each member declares its own literal, so validating against any
|
|
@@ -1198,20 +1257,20 @@ literalQueryNames) {
|
|
|
1198
1257
|
* validates every OTHER header and leaves this one to the negotiation.
|
|
1199
1258
|
*/
|
|
1200
1259
|
if (parameter.type === "header" && parameter.name.toLowerCase() === "accept") {
|
|
1201
|
-
const
|
|
1202
|
-
if (
|
|
1203
|
-
accept = { name: parameter.name, value:
|
|
1260
|
+
const declared = parameter.param.type;
|
|
1261
|
+
if (declared.kind === "String")
|
|
1262
|
+
accept = { name: parameter.name, value: declared.value };
|
|
1204
1263
|
}
|
|
1205
1264
|
else if (parameter.type === "header") {
|
|
1206
|
-
(
|
|
1265
|
+
add("header-without-accept", parameter.name, expression);
|
|
1207
1266
|
}
|
|
1208
1267
|
}
|
|
1209
1268
|
const fileContentType = fileContentTypeOf(operation);
|
|
1210
1269
|
if (fileContentType !== undefined) {
|
|
1211
1270
|
// The same decoding a declared `Content-Type` header gets: parameters dropped, lowercased.
|
|
1212
|
-
const
|
|
1213
|
-
(
|
|
1214
|
-
(
|
|
1271
|
+
const expression = mediaTypeDecoded(registry.expressionFor(fileContentType.type));
|
|
1272
|
+
add("header", "Content-Type", expression);
|
|
1273
|
+
add("header-without-accept", "Content-Type", expression);
|
|
1215
1274
|
}
|
|
1216
1275
|
const shape = (target) => {
|
|
1217
1276
|
const entries = byTarget[target];
|
|
@@ -1230,6 +1289,24 @@ literalQueryNames) {
|
|
|
1230
1289
|
accept,
|
|
1231
1290
|
};
|
|
1232
1291
|
}
|
|
1292
|
+
/**
|
|
1293
|
+
* The route segment a path parameter's validator decodes, or `undefined` where it validates the
|
|
1294
|
+
* segment's text as the value. The one rule for both the validator and
|
|
1295
|
+
* {@link EmittedRoute.decodedPathParameters}, so a consumer reading the route cannot disagree with
|
|
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.
|
|
1302
|
+
*/
|
|
1303
|
+
function decodedSegmentOf(program, parameter, segments) {
|
|
1304
|
+
const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
|
|
1305
|
+
return segment !== undefined &&
|
|
1306
|
+
needsExpansionDecoding(segment, expansionShapeOf(encodedParameterType(program, parameter.param)))
|
|
1307
|
+
? segment
|
|
1308
|
+
: undefined;
|
|
1309
|
+
}
|
|
1233
1310
|
/** What a parameter's value decodes into once its expansion is undone. */
|
|
1234
1311
|
function expansionShapeOf(type) {
|
|
1235
1312
|
if (isArrayType(type))
|
|
@@ -1260,7 +1337,7 @@ function modelPropertiesOf(model) {
|
|
|
1260
1337
|
* `undefined` where the body is not a file, or where the operation already declares the header.
|
|
1261
1338
|
*/
|
|
1262
1339
|
function fileContentTypeOf(operation) {
|
|
1263
|
-
const body = operation
|
|
1340
|
+
const body = requestBodyParameterOf(operation);
|
|
1264
1341
|
if (body?.bodyKind !== "file")
|
|
1265
1342
|
return undefined;
|
|
1266
1343
|
const declared = operation.parameters.parameters.some((parameter) => parameter.type === "header" && parameter.name.toLowerCase() === "content-type");
|
|
@@ -1282,7 +1359,7 @@ function fileContentTypeOf(operation) {
|
|
|
1282
1359
|
* body. A read-only property is not sent, so it contributes nothing.
|
|
1283
1360
|
*/
|
|
1284
1361
|
function mergedBodyKeysOf(program, operation) {
|
|
1285
|
-
const body = operation
|
|
1362
|
+
const body = requestBodyParameterOf(operation);
|
|
1286
1363
|
if (body?.bodyKind === "multipart") {
|
|
1287
1364
|
return body.parts.flatMap((part) => (part.name === undefined ? [] : [part.name]));
|
|
1288
1365
|
}
|
|
@@ -1293,9 +1370,86 @@ function mergedBodyKeysOf(program, operation) {
|
|
|
1293
1370
|
.filter((property) => isPayloadProperty(program, property))
|
|
1294
1371
|
.map((property) => resolveEncodedName(program, property, "application/json")));
|
|
1295
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();
|
|
1296
1450
|
function requestBodyPlacementOf(program, operation) {
|
|
1297
1451
|
const requestType = requestBodyOf(operation);
|
|
1298
|
-
const bodyParameter = operation
|
|
1452
|
+
const bodyParameter = requestBodyParameterOf(operation);
|
|
1299
1453
|
const contentTypes = [...(bodyParameter?.contentTypes ?? [])];
|
|
1300
1454
|
/**
|
|
1301
1455
|
* A `bytes` body means the BYTES are the contract.
|
|
@@ -1486,15 +1640,19 @@ export function collectRoutes(program, registry, service) {
|
|
|
1486
1640
|
.filter((parameter) => parameter.type === "path" && parameter.allowReserved)
|
|
1487
1641
|
.map((parameter) => parameter.name),
|
|
1488
1642
|
pathSegments: template.segments,
|
|
1643
|
+
sharedInputKeys: sharedInputKeysOf(program, operation),
|
|
1644
|
+
decodedPathParameters: operation.parameters.parameters
|
|
1645
|
+
.filter((parameter) => decodedSegmentOf(program, parameter, template.segments) !== undefined)
|
|
1646
|
+
.map((parameter) => parameter.name),
|
|
1489
1647
|
literalQuery: template.literalQuery,
|
|
1490
1648
|
responseContentTypes: statusCode === undefined ? [] : responseContentTypesOf(operation, statusCode),
|
|
1491
|
-
requestContentTypes: [...(operation
|
|
1649
|
+
requestContentTypes: [...(requestBodyParameterOf(operation)?.contentTypes ?? [])],
|
|
1492
1650
|
// `isRawBinaryMediaType` is "every declared type is non-JSON", which is the same question
|
|
1493
1651
|
// here: a string body under `application/json` IS json, and its reader is the JSON one.
|
|
1494
1652
|
requestTextual: requestType !== undefined &&
|
|
1495
1653
|
rawBodyProperty === undefined &&
|
|
1496
1654
|
isStringType(requestType) &&
|
|
1497
|
-
isRawBinaryMediaType([...(operation
|
|
1655
|
+
isRawBinaryMediaType([...(requestBodyParameterOf(operation)?.contentTypes ?? [])]),
|
|
1498
1656
|
/**
|
|
1499
1657
|
* `@summary` first, `@doc` as the fallback.
|
|
1500
1658
|
*
|
|
@@ -1514,7 +1672,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1514
1672
|
requestSchema: withVisibility(program, requestVisibility, () => {
|
|
1515
1673
|
// Multipart first: its body has no single type to walk, and skipping it left every
|
|
1516
1674
|
// such route mounted with nothing checking the request at all.
|
|
1517
|
-
const parameterBody = operation
|
|
1675
|
+
const parameterBody = requestBodyParameterOf(operation);
|
|
1518
1676
|
if (parameterBody?.bodyKind === "multipart") {
|
|
1519
1677
|
return multipartSchemaOf(program, parameterBody, registry);
|
|
1520
1678
|
}
|
|
@@ -1581,9 +1739,121 @@ export function collectRoutes(program, registry, service) {
|
|
|
1581
1739
|
* Tokenising has neither, needs no escaping to stay correct, and is one pass rather than one pattern
|
|
1582
1740
|
* per name. `typespec-hono` arrived at it after the same defect went live there: a spec whose every
|
|
1583
1741
|
* identifier carried a `$` emitted a file that imported nothing and did not compile.
|
|
1742
|
+
*
|
|
1743
|
+
* **Only CODE is tokenised: string, template and regular-expression literals and comments are
|
|
1744
|
+
* skipped.** Spec-authored text reaches emitted source inside them - a `.describe("...")`, a
|
|
1745
|
+
* `.regex(/.../)` - and a word in that text is not a reference. Measured: a parameter documented as
|
|
1746
|
+
* "names uriExpansion and explodedQuery" declared both RFC 6570 decoders in a service that decodes
|
|
1747
|
+
* nothing, and the file failed `noUnusedLocals` - the lint a generated file has to pass like any
|
|
1748
|
+
* other. A literal is told from code by its opening character; a `/` opens a regular expression only
|
|
1749
|
+
* where an expression may start, which is the rule a JavaScript scanner uses, and this emitter
|
|
1750
|
+
* writes no division.
|
|
1584
1751
|
*/
|
|
1585
1752
|
function identifiersIn(source) {
|
|
1586
|
-
|
|
1753
|
+
const found = new Set();
|
|
1754
|
+
scanCode(source, 0, found, false);
|
|
1755
|
+
return found;
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* Collect the identifiers of the code from `start`, returning where it stopped: at the end, or -
|
|
1759
|
+
* `nested`, for a template's `${...}` - just past the brace that closes it.
|
|
1760
|
+
*/
|
|
1761
|
+
function scanCode(source, start, found, nested) {
|
|
1762
|
+
const identifier = /[A-Za-z_$][A-Za-z0-9_$]*/y;
|
|
1763
|
+
let previous = "";
|
|
1764
|
+
let depth = 0;
|
|
1765
|
+
let index = start;
|
|
1766
|
+
while (index < source.length) {
|
|
1767
|
+
const char = source.charAt(index);
|
|
1768
|
+
const next = source.charAt(index + 1);
|
|
1769
|
+
if (char === "`") {
|
|
1770
|
+
index = scanTemplate(source, index, found);
|
|
1771
|
+
previous = char;
|
|
1772
|
+
}
|
|
1773
|
+
else if (char === '"' || char === "'") {
|
|
1774
|
+
index = quotedEnd(source, index, char);
|
|
1775
|
+
previous = char;
|
|
1776
|
+
}
|
|
1777
|
+
else if (char === "/" && next === "/") {
|
|
1778
|
+
const end = source.indexOf("\n", index);
|
|
1779
|
+
index = end === -1 ? source.length : end;
|
|
1780
|
+
}
|
|
1781
|
+
else if (char === "/" && next === "*") {
|
|
1782
|
+
const end = source.indexOf("*/", index + 2);
|
|
1783
|
+
index = end === -1 ? source.length : end + 2;
|
|
1784
|
+
}
|
|
1785
|
+
else if (char === "/" && (previous === "" || "(,=:[!&|?{};".includes(previous))) {
|
|
1786
|
+
index = regexEnd(source, index);
|
|
1787
|
+
previous = char;
|
|
1788
|
+
}
|
|
1789
|
+
else if (/[A-Za-z_$]/.test(char)) {
|
|
1790
|
+
identifier.lastIndex = index;
|
|
1791
|
+
const [name = char] = identifier.exec(source) ?? [];
|
|
1792
|
+
found.add(name);
|
|
1793
|
+
index += name.length;
|
|
1794
|
+
previous = "a";
|
|
1795
|
+
}
|
|
1796
|
+
else {
|
|
1797
|
+
if (char === "}" && nested && depth === 0)
|
|
1798
|
+
return index + 1;
|
|
1799
|
+
if (char === "{")
|
|
1800
|
+
depth += 1;
|
|
1801
|
+
if (char === "}")
|
|
1802
|
+
depth -= 1;
|
|
1803
|
+
if (!/\s/.test(char))
|
|
1804
|
+
previous = char;
|
|
1805
|
+
index += 1;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
return index;
|
|
1809
|
+
}
|
|
1810
|
+
/** Past a template literal opened at `start`, collecting the code in each `${...}` on the way. */
|
|
1811
|
+
function scanTemplate(source, start, found) {
|
|
1812
|
+
let index = start + 1;
|
|
1813
|
+
while (index < source.length) {
|
|
1814
|
+
const char = source.charAt(index);
|
|
1815
|
+
if (char === "\\")
|
|
1816
|
+
index += 2;
|
|
1817
|
+
else if (char === "`")
|
|
1818
|
+
return index + 1;
|
|
1819
|
+
else if (char === "$" && source.charAt(index + 1) === "{") {
|
|
1820
|
+
index = scanCode(source, index + 2, found, true);
|
|
1821
|
+
}
|
|
1822
|
+
else
|
|
1823
|
+
index += 1;
|
|
1824
|
+
}
|
|
1825
|
+
return index;
|
|
1826
|
+
}
|
|
1827
|
+
/** Past a quoted string opened at `start`, escapes honoured. */
|
|
1828
|
+
function quotedEnd(source, start, quote) {
|
|
1829
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
1830
|
+
const char = source.charAt(index);
|
|
1831
|
+
if (char === "\\")
|
|
1832
|
+
index += 1;
|
|
1833
|
+
else if (char === quote)
|
|
1834
|
+
return index + 1;
|
|
1835
|
+
}
|
|
1836
|
+
return source.length;
|
|
1837
|
+
}
|
|
1838
|
+
/** Past a regular-expression literal opened at `start`, flags included. */
|
|
1839
|
+
function regexEnd(source, start) {
|
|
1840
|
+
let inClass = false;
|
|
1841
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
1842
|
+
const char = source.charAt(index);
|
|
1843
|
+
if (char === "\\")
|
|
1844
|
+
index += 1;
|
|
1845
|
+
else if (char === "[")
|
|
1846
|
+
inClass = true;
|
|
1847
|
+
else if (char === "]")
|
|
1848
|
+
inClass = false;
|
|
1849
|
+
else if (char === "/" && !inClass) {
|
|
1850
|
+
let end = index + 1;
|
|
1851
|
+
while (/[a-z]/.test(source.charAt(end)))
|
|
1852
|
+
end += 1;
|
|
1853
|
+
return end;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
return source.length;
|
|
1587
1857
|
}
|
|
1588
1858
|
/** Whether emitted source references `name` as an identifier rather than as part of a longer one. */
|
|
1589
1859
|
export function referencedIn(source, name) {
|
|
@@ -1595,7 +1865,7 @@ function renderExternalImports(externals, ...bodies) {
|
|
|
1595
1865
|
for (const [module, names] of externals) {
|
|
1596
1866
|
const used = [...names].filter((name) => referencedIn(haystack, name));
|
|
1597
1867
|
if (used.length > 0) {
|
|
1598
|
-
lines.push(`import { ${used.toSorted().join(", ")} } from ${
|
|
1868
|
+
lines.push(`import { ${used.toSorted().join(", ")} } from ${literal(module)};\n`);
|
|
1599
1869
|
}
|
|
1600
1870
|
}
|
|
1601
1871
|
return lines.join("");
|
|
@@ -1944,11 +2214,11 @@ function responseArmsOf(route, arms) {
|
|
|
1944
2214
|
const schema = arms[index]?.schema ?? "undefined";
|
|
1945
2215
|
const contentTypes = response.contentTypes.length === 0
|
|
1946
2216
|
? ""
|
|
1947
|
-
: `, contentTypes: [${response.contentTypes.map((type) =>
|
|
2217
|
+
: `, contentTypes: [${response.contentTypes.map((type) => literal(type)).join(", ")}]`;
|
|
1948
2218
|
const headers = response.headers.length === 0
|
|
1949
2219
|
? ""
|
|
1950
|
-
: `, headers: [${response.headers.map((header) => `{ name: ${
|
|
1951
|
-
return `{ status: ${
|
|
2220
|
+
: `, headers: [${response.headers.map((header) => `{ name: ${literal(header.name)}, optional: ${header.optional} }`).join(", ")}]`;
|
|
2221
|
+
return `{ status: ${literal(response.status)}, schema: ${schema}${contentTypes}${headers} }`;
|
|
1952
2222
|
});
|
|
1953
2223
|
return `[${rendered.join(", ")}]`;
|
|
1954
2224
|
}
|
|
@@ -2000,7 +2270,7 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2000
2270
|
const body = requestBodyOf(operation);
|
|
2001
2271
|
const placement = requestBodyPlacementOf(program, operation);
|
|
2002
2272
|
/** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
|
|
2003
|
-
const parameterBody = operation
|
|
2273
|
+
const parameterBody = requestBodyParameterOf(operation);
|
|
2004
2274
|
/**
|
|
2005
2275
|
* Headers are part of the input too.
|
|
2006
2276
|
*
|
|
@@ -2020,11 +2290,24 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2020
2290
|
* `"x-thing"` here too: a consumer checking its handlers against these types otherwise
|
|
2021
2291
|
* fails to typecheck against a server that is correct.
|
|
2022
2292
|
*/
|
|
2023
|
-
.map((parameter) =>
|
|
2293
|
+
.map((parameter) => ({
|
|
2294
|
+
key: parameter.name,
|
|
2295
|
+
line: registry.expressionForProperty(parameter.param, parameter.name),
|
|
2296
|
+
}));
|
|
2024
2297
|
const fileContentType = fileContentTypeOf(operation);
|
|
2025
2298
|
if (fileContentType !== undefined) {
|
|
2026
|
-
extraProperties.push(
|
|
2299
|
+
extraProperties.push({
|
|
2300
|
+
key: "Content-Type",
|
|
2301
|
+
line: `${objectKey("Content-Type")}: ${registry.expressionFor(fileContentType.type)};`,
|
|
2302
|
+
});
|
|
2027
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));
|
|
2028
2311
|
const parts = [];
|
|
2029
2312
|
let streamsBody = false;
|
|
2030
2313
|
let bodyRef;
|
|
@@ -2060,9 +2343,11 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2060
2343
|
: `{\n\t${objectKey(placement.bodyProperty)}${placement.optionalBody ? "?" : ""}: ${expression.replaceAll("\n", "\n\t")};\n}`);
|
|
2061
2344
|
}
|
|
2062
2345
|
}
|
|
2063
|
-
if (
|
|
2064
|
-
parts.push(`{\n${
|
|
2346
|
+
if (distinct.length > 0) {
|
|
2347
|
+
parts.push(`{\n${distinct.map(({ line }) => `\t${line}`).join("\n")}\n}`);
|
|
2065
2348
|
}
|
|
2349
|
+
for (const { line } of repeated)
|
|
2350
|
+
parts.push(`{\n\t${line}\n}`);
|
|
2066
2351
|
entries.push({
|
|
2067
2352
|
operationId: operationIdOf(program, operation.operation),
|
|
2068
2353
|
bodyRef,
|
|
@@ -2528,7 +2813,7 @@ ${assertions.join("\n")}
|
|
|
2528
2813
|
function renderVocabularies(vocabularies) {
|
|
2529
2814
|
const entries = [...vocabularies.entries()]
|
|
2530
2815
|
.toSorted(([a], [b]) => a.localeCompare(b))
|
|
2531
|
-
.map(([name, values]) => `\t${name}: [${values.map((v) =>
|
|
2816
|
+
.map(([name, values]) => `\t${name}: [${values.map((v) => literal(v)).join(", ")}],`);
|
|
2532
2817
|
return `${generatedBanner()}
|
|
2533
2818
|
/**
|
|
2534
2819
|
* The spec's vocabularies - every enum the surface reaches, and what each one contains.
|