typespec-http-zod 0.28.0 → 0.29.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 +19 -0
- package/dist/src/api.js +150 -15
- package/dist/src/constraints.js +45 -11
- package/dist/src/lib.d.ts +19 -1
- package/dist/src/lib.js +42 -0
- package/dist/src/literal.d.ts +27 -0
- package/dist/src/literal.js +33 -0
- package/dist/src/registry.js +28 -5
- package/dist/src/results.js +2 -1
- package/dist/src/types.js +5 -4
- package/dist/src/uri-template.d.ts +0 -12
- package/dist/src/uri-template.js +3 -2
- package/dist/src/zod.d.ts +34 -0
- package/dist/src/zod.js +205 -23
- package/package.json +1 -1
package/dist/src/api.d.ts
CHANGED
|
@@ -48,6 +48,12 @@ export { EmitterOptionsSchema, type EmitterOptions } from "./lib.js";
|
|
|
48
48
|
* independently.
|
|
49
49
|
*/
|
|
50
50
|
export { jsDocComment, objectKey } from "./zod.js";
|
|
51
|
+
/**
|
|
52
|
+
* And the same for a VALUE written into emitted code: a string, enum value or media type as a literal
|
|
53
|
+
* whose source is ASCII. Spec text outside ASCII is the case, and a sibling emitter spelling it with a
|
|
54
|
+
* bare `JSON.stringify` writes the character itself into a file its consumer commits.
|
|
55
|
+
*/
|
|
56
|
+
export { literal } from "./literal.js";
|
|
51
57
|
export declare function beginBanner(hint: string | undefined): void;
|
|
52
58
|
/**
|
|
53
59
|
* The status a row registers: the first non-error status the operation declares.
|
|
@@ -238,6 +244,19 @@ export interface EmittedRoute {
|
|
|
238
244
|
* `unsupported`: no segment router can split it.
|
|
239
245
|
*/
|
|
240
246
|
readonly pathSegments: readonly EmittedPathSegment[];
|
|
247
|
+
/**
|
|
248
|
+
* **The path parameters whose validator undoes an RFC 6570 expansion**, by wire name.
|
|
249
|
+
*
|
|
250
|
+
* For these, the path validator's field is `z.preprocess(<decode>, <value schema>)`: it takes the
|
|
251
|
+
* TEXT of the segment a router extracts - `primitive.a` for `{.param}` - and hands on the value,
|
|
252
|
+
* `a`. A caller already holding the value, such as a tool call or a service-binding request, must
|
|
253
|
+
* validate it against the value schema, `pathSchema.shape[name].out`: through the decoder, the value
|
|
254
|
+
* `a` is refused as not the expansion the route declares, and a value that happens to start with
|
|
255
|
+
* the route's literal is rewritten without an error. Decided by the same rule the validator is
|
|
256
|
+
* built by: a prefix or suffix in the segment, the `.` or `;` operator, or a parameter that is not
|
|
257
|
+
* a scalar. Empty when there is none, never `undefined`.
|
|
258
|
+
*/
|
|
259
|
+
readonly decodedPathParameters: readonly string[];
|
|
241
260
|
/**
|
|
242
261
|
* A query string written into the route itself, as the pairs it spells:
|
|
243
262
|
* `/items?fixed=true{¶m}` carries `[["fixed", "true"]]`. It identifies the route as much as
|
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";
|
|
@@ -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
|
/**
|
|
@@ -1045,8 +1052,8 @@ function mediaTypeDecoded(declared) {
|
|
|
1045
1052
|
if (value === "*/*")
|
|
1046
1053
|
return "z.string()";
|
|
1047
1054
|
return isMediaRange(value)
|
|
1048
|
-
? `z.templateLiteral([${
|
|
1049
|
-
: `z.literal(${
|
|
1055
|
+
? `z.templateLiteral([${literal(mediaTypeEssenceOf(value).slice(0, -1))}, z.string()])`
|
|
1056
|
+
: `z.literal(${literal(value)})`;
|
|
1050
1057
|
});
|
|
1051
1058
|
const schema = admitted.length === 1 ? (admitted[0] ?? declared) : `z.union([${admitted.join(", ")}])`;
|
|
1052
1059
|
const ranged = values.some(isMediaRange) ? schema : declared;
|
|
@@ -1063,7 +1070,7 @@ function mediaTypeDecoded(declared) {
|
|
|
1063
1070
|
const decoded = restored.length === 0
|
|
1064
1071
|
? essence
|
|
1065
1072
|
: `((essence) => ${restored
|
|
1066
|
-
.map((value) => `essence === ${
|
|
1073
|
+
.map((value) => `essence === ${literal(mediaTypeEssenceOf(value))} ? ${literal(value)} : `)
|
|
1067
1074
|
.join("")}essence)(${essence})`;
|
|
1068
1075
|
return `z.preprocess((raw) => (typeof raw === "string" ? ${decoded} : raw), ${ranged})`;
|
|
1069
1076
|
}
|
|
@@ -1143,7 +1150,7 @@ literalQueryNames) {
|
|
|
1143
1150
|
? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
|
|
1144
1151
|
: delimiter === undefined
|
|
1145
1152
|
? decoded
|
|
1146
|
-
: `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${
|
|
1153
|
+
: `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${literal(delimiter)}) : raw), ${decoded})`;
|
|
1147
1154
|
/**
|
|
1148
1155
|
* **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
|
|
1149
1156
|
* wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
|
|
@@ -1153,8 +1160,8 @@ literalQueryNames) {
|
|
|
1153
1160
|
const values = recordValuesOf(program, parameter.param.type);
|
|
1154
1161
|
let expression = boxedOrSplit;
|
|
1155
1162
|
if (parameter.type === "path") {
|
|
1156
|
-
const segment =
|
|
1157
|
-
if (segment !== undefined
|
|
1163
|
+
const segment = decodedSegmentOf(parameter, segments);
|
|
1164
|
+
if (segment !== undefined) {
|
|
1158
1165
|
expression = `z.preprocess(${uriExpansionCall({ ...segment, name: parameter.name, shape, values })}, ${decoded})`;
|
|
1159
1166
|
}
|
|
1160
1167
|
}
|
|
@@ -1198,9 +1205,9 @@ literalQueryNames) {
|
|
|
1198
1205
|
* validates every OTHER header and leaves this one to the negotiation.
|
|
1199
1206
|
*/
|
|
1200
1207
|
if (parameter.type === "header" && parameter.name.toLowerCase() === "accept") {
|
|
1201
|
-
const
|
|
1202
|
-
if (
|
|
1203
|
-
accept = { name: parameter.name, value:
|
|
1208
|
+
const declared = parameter.param.type;
|
|
1209
|
+
if (declared.kind === "String")
|
|
1210
|
+
accept = { name: parameter.name, value: declared.value };
|
|
1204
1211
|
}
|
|
1205
1212
|
else if (parameter.type === "header") {
|
|
1206
1213
|
(byTarget["header-without-accept"] ??= []).push(entry);
|
|
@@ -1231,6 +1238,19 @@ literalQueryNames) {
|
|
|
1231
1238
|
};
|
|
1232
1239
|
}
|
|
1233
1240
|
/** What a parameter's value decodes into once its expansion is undone. */
|
|
1241
|
+
/**
|
|
1242
|
+
* The route segment a path parameter's validator decodes, or `undefined` where it validates the
|
|
1243
|
+
* segment's text as the value. The one rule for both the validator and
|
|
1244
|
+
* {@link EmittedRoute.decodedPathParameters}, so a consumer reading the route cannot disagree with
|
|
1245
|
+
* the validator it describes.
|
|
1246
|
+
*/
|
|
1247
|
+
function decodedSegmentOf(parameter, segments) {
|
|
1248
|
+
const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
|
|
1249
|
+
return segment !== undefined &&
|
|
1250
|
+
needsExpansionDecoding(segment, expansionShapeOf(parameter.param.type))
|
|
1251
|
+
? segment
|
|
1252
|
+
: undefined;
|
|
1253
|
+
}
|
|
1234
1254
|
function expansionShapeOf(type) {
|
|
1235
1255
|
if (isArrayType(type))
|
|
1236
1256
|
return "list";
|
|
@@ -1486,6 +1506,9 @@ export function collectRoutes(program, registry, service) {
|
|
|
1486
1506
|
.filter((parameter) => parameter.type === "path" && parameter.allowReserved)
|
|
1487
1507
|
.map((parameter) => parameter.name),
|
|
1488
1508
|
pathSegments: template.segments,
|
|
1509
|
+
decodedPathParameters: operation.parameters.parameters
|
|
1510
|
+
.filter((parameter) => decodedSegmentOf(parameter, template.segments) !== undefined)
|
|
1511
|
+
.map((parameter) => parameter.name),
|
|
1489
1512
|
literalQuery: template.literalQuery,
|
|
1490
1513
|
responseContentTypes: statusCode === undefined ? [] : responseContentTypesOf(operation, statusCode),
|
|
1491
1514
|
requestContentTypes: [...(operation.parameters.body?.contentTypes ?? [])],
|
|
@@ -1581,9 +1604,121 @@ export function collectRoutes(program, registry, service) {
|
|
|
1581
1604
|
* Tokenising has neither, needs no escaping to stay correct, and is one pass rather than one pattern
|
|
1582
1605
|
* per name. `typespec-hono` arrived at it after the same defect went live there: a spec whose every
|
|
1583
1606
|
* identifier carried a `$` emitted a file that imported nothing and did not compile.
|
|
1607
|
+
*
|
|
1608
|
+
* **Only CODE is tokenised: string, template and regular-expression literals and comments are
|
|
1609
|
+
* skipped.** Spec-authored text reaches emitted source inside them - a `.describe("...")`, a
|
|
1610
|
+
* `.regex(/.../)` - and a word in that text is not a reference. Measured: a parameter documented as
|
|
1611
|
+
* "names uriExpansion and explodedQuery" declared both RFC 6570 decoders in a service that decodes
|
|
1612
|
+
* nothing, and the file failed `noUnusedLocals` - the lint a generated file has to pass like any
|
|
1613
|
+
* other. A literal is told from code by its opening character; a `/` opens a regular expression only
|
|
1614
|
+
* where an expression may start, which is the rule a JavaScript scanner uses, and this emitter
|
|
1615
|
+
* writes no division.
|
|
1584
1616
|
*/
|
|
1585
1617
|
function identifiersIn(source) {
|
|
1586
|
-
|
|
1618
|
+
const found = new Set();
|
|
1619
|
+
scanCode(source, 0, found, false);
|
|
1620
|
+
return found;
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Collect the identifiers of the code from `start`, returning where it stopped: at the end, or -
|
|
1624
|
+
* `nested`, for a template's `${...}` - just past the brace that closes it.
|
|
1625
|
+
*/
|
|
1626
|
+
function scanCode(source, start, found, nested) {
|
|
1627
|
+
const identifier = /[A-Za-z_$][A-Za-z0-9_$]*/y;
|
|
1628
|
+
let previous = "";
|
|
1629
|
+
let depth = 0;
|
|
1630
|
+
let index = start;
|
|
1631
|
+
while (index < source.length) {
|
|
1632
|
+
const char = source.charAt(index);
|
|
1633
|
+
const next = source.charAt(index + 1);
|
|
1634
|
+
if (char === "`") {
|
|
1635
|
+
index = scanTemplate(source, index, found);
|
|
1636
|
+
previous = char;
|
|
1637
|
+
}
|
|
1638
|
+
else if (char === '"' || char === "'") {
|
|
1639
|
+
index = quotedEnd(source, index, char);
|
|
1640
|
+
previous = char;
|
|
1641
|
+
}
|
|
1642
|
+
else if (char === "/" && next === "/") {
|
|
1643
|
+
const end = source.indexOf("\n", index);
|
|
1644
|
+
index = end === -1 ? source.length : end;
|
|
1645
|
+
}
|
|
1646
|
+
else if (char === "/" && next === "*") {
|
|
1647
|
+
const end = source.indexOf("*/", index + 2);
|
|
1648
|
+
index = end === -1 ? source.length : end + 2;
|
|
1649
|
+
}
|
|
1650
|
+
else if (char === "/" && (previous === "" || "(,=:[!&|?{};".includes(previous))) {
|
|
1651
|
+
index = regexEnd(source, index);
|
|
1652
|
+
previous = char;
|
|
1653
|
+
}
|
|
1654
|
+
else if (/[A-Za-z_$]/.test(char)) {
|
|
1655
|
+
identifier.lastIndex = index;
|
|
1656
|
+
const [name = char] = identifier.exec(source) ?? [];
|
|
1657
|
+
found.add(name);
|
|
1658
|
+
index += name.length;
|
|
1659
|
+
previous = "a";
|
|
1660
|
+
}
|
|
1661
|
+
else {
|
|
1662
|
+
if (char === "}" && nested && depth === 0)
|
|
1663
|
+
return index + 1;
|
|
1664
|
+
if (char === "{")
|
|
1665
|
+
depth += 1;
|
|
1666
|
+
if (char === "}")
|
|
1667
|
+
depth -= 1;
|
|
1668
|
+
if (!/\s/.test(char))
|
|
1669
|
+
previous = char;
|
|
1670
|
+
index += 1;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
return index;
|
|
1674
|
+
}
|
|
1675
|
+
/** Past a template literal opened at `start`, collecting the code in each `${...}` on the way. */
|
|
1676
|
+
function scanTemplate(source, start, found) {
|
|
1677
|
+
let index = start + 1;
|
|
1678
|
+
while (index < source.length) {
|
|
1679
|
+
const char = source.charAt(index);
|
|
1680
|
+
if (char === "\\")
|
|
1681
|
+
index += 2;
|
|
1682
|
+
else if (char === "`")
|
|
1683
|
+
return index + 1;
|
|
1684
|
+
else if (char === "$" && source.charAt(index + 1) === "{") {
|
|
1685
|
+
index = scanCode(source, index + 2, found, true);
|
|
1686
|
+
}
|
|
1687
|
+
else
|
|
1688
|
+
index += 1;
|
|
1689
|
+
}
|
|
1690
|
+
return index;
|
|
1691
|
+
}
|
|
1692
|
+
/** Past a quoted string opened at `start`, escapes honoured. */
|
|
1693
|
+
function quotedEnd(source, start, quote) {
|
|
1694
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
1695
|
+
const char = source.charAt(index);
|
|
1696
|
+
if (char === "\\")
|
|
1697
|
+
index += 1;
|
|
1698
|
+
else if (char === quote)
|
|
1699
|
+
return index + 1;
|
|
1700
|
+
}
|
|
1701
|
+
return source.length;
|
|
1702
|
+
}
|
|
1703
|
+
/** Past a regular-expression literal opened at `start`, flags included. */
|
|
1704
|
+
function regexEnd(source, start) {
|
|
1705
|
+
let inClass = false;
|
|
1706
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
1707
|
+
const char = source.charAt(index);
|
|
1708
|
+
if (char === "\\")
|
|
1709
|
+
index += 1;
|
|
1710
|
+
else if (char === "[")
|
|
1711
|
+
inClass = true;
|
|
1712
|
+
else if (char === "]")
|
|
1713
|
+
inClass = false;
|
|
1714
|
+
else if (char === "/" && !inClass) {
|
|
1715
|
+
let end = index + 1;
|
|
1716
|
+
while (/[a-z]/.test(source.charAt(end)))
|
|
1717
|
+
end += 1;
|
|
1718
|
+
return end;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
return source.length;
|
|
1587
1722
|
}
|
|
1588
1723
|
/** Whether emitted source references `name` as an identifier rather than as part of a longer one. */
|
|
1589
1724
|
export function referencedIn(source, name) {
|
|
@@ -1595,7 +1730,7 @@ function renderExternalImports(externals, ...bodies) {
|
|
|
1595
1730
|
for (const [module, names] of externals) {
|
|
1596
1731
|
const used = [...names].filter((name) => referencedIn(haystack, name));
|
|
1597
1732
|
if (used.length > 0) {
|
|
1598
|
-
lines.push(`import { ${used.toSorted().join(", ")} } from ${
|
|
1733
|
+
lines.push(`import { ${used.toSorted().join(", ")} } from ${literal(module)};\n`);
|
|
1599
1734
|
}
|
|
1600
1735
|
}
|
|
1601
1736
|
return lines.join("");
|
|
@@ -1944,11 +2079,11 @@ function responseArmsOf(route, arms) {
|
|
|
1944
2079
|
const schema = arms[index]?.schema ?? "undefined";
|
|
1945
2080
|
const contentTypes = response.contentTypes.length === 0
|
|
1946
2081
|
? ""
|
|
1947
|
-
: `, contentTypes: [${response.contentTypes.map((type) =>
|
|
2082
|
+
: `, contentTypes: [${response.contentTypes.map((type) => literal(type)).join(", ")}]`;
|
|
1948
2083
|
const headers = response.headers.length === 0
|
|
1949
2084
|
? ""
|
|
1950
|
-
: `, headers: [${response.headers.map((header) => `{ name: ${
|
|
1951
|
-
return `{ status: ${
|
|
2085
|
+
: `, headers: [${response.headers.map((header) => `{ name: ${literal(header.name)}, optional: ${header.optional} }`).join(", ")}]`;
|
|
2086
|
+
return `{ status: ${literal(response.status)}, schema: ${schema}${contentTypes}${headers} }`;
|
|
1952
2087
|
});
|
|
1953
2088
|
return `[${rendered.join(", ")}]`;
|
|
1954
2089
|
}
|
|
@@ -2528,7 +2663,7 @@ ${assertions.join("\n")}
|
|
|
2528
2663
|
function renderVocabularies(vocabularies) {
|
|
2529
2664
|
const entries = [...vocabularies.entries()]
|
|
2530
2665
|
.toSorted(([a], [b]) => a.localeCompare(b))
|
|
2531
|
-
.map(([name, values]) => `\t${name}: [${values.map((v) =>
|
|
2666
|
+
.map(([name, values]) => `\t${name}: [${values.map((v) => literal(v)).join(", ")}],`);
|
|
2532
2667
|
return `${generatedBanner()}
|
|
2533
2668
|
/**
|
|
2534
2669
|
* The spec's vocabularies - every enum the surface reaches, and what each one contains.
|
package/dist/src/constraints.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getEncode, getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
|
|
2
2
|
import { defaultMaxSteps, isSafePattern } from "redos-detector";
|
|
3
3
|
import { reportDiagnostic } from "./lib.js";
|
|
4
|
+
import { escapeNonAscii } from "./literal.js";
|
|
4
5
|
/**
|
|
5
6
|
* Constraint decorators -> the Zod modifiers that enforce them.
|
|
6
7
|
*
|
|
@@ -18,15 +19,16 @@ import { reportDiagnostic } from "./lib.js";
|
|
|
18
19
|
*/
|
|
19
20
|
export function applyConstraints(program, expression, target) {
|
|
20
21
|
let result = expression;
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
22
|
+
const read = (get) => declaredOn(program, target, get);
|
|
23
|
+
const minLength = read(getMinLength);
|
|
24
|
+
const maxLength = read(getMaxLength);
|
|
25
|
+
const minValue = read(getMinValue);
|
|
26
|
+
const maxValue = read(getMaxValue);
|
|
27
|
+
const minItems = read(getMinItems);
|
|
28
|
+
const maxItems = read(getMaxItems);
|
|
29
|
+
const pattern = read(getPattern);
|
|
30
|
+
const minExclusive = read(getMinValueExclusive);
|
|
31
|
+
const maxExclusive = read(getMaxValueExclusive);
|
|
30
32
|
/**
|
|
31
33
|
* **A numeric bound says nothing about a value carried as text.** `@encode(string)` is legal on a
|
|
32
34
|
* numeric property and makes the wire value a string, so the expression here is `z.string()` while
|
|
@@ -72,6 +74,30 @@ export function applyConstraints(program, expression, target) {
|
|
|
72
74
|
}
|
|
73
75
|
return result;
|
|
74
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* **A constraint on a scalar is read up its `extends` chain: the nearest scalar declaring it wins.**
|
|
79
|
+
*
|
|
80
|
+
* The compiler's getters read the decorators of the one type they are handed, and this read the
|
|
81
|
+
* scalar a property NAMED. So `scalar ShortWord extends Word` with `@pattern` on `Word` validated
|
|
82
|
+
* as a bare `z.string()`, and every value the pattern forbids was accepted - while the document
|
|
83
|
+
* published the pattern on `ShortWord`. `@typespec/openapi3`'s `#getSchemaForScalar` builds a
|
|
84
|
+
* derived scalar from its base's schema and then applies its own decorators over it, so a keyword
|
|
85
|
+
* the derived scalar declares REPLACES the base's rather than adding to it. Reading the nearest
|
|
86
|
+
* declaration per keyword is that rule exactly: `Overrides extends Word` with its own `@pattern`
|
|
87
|
+
* publishes only its own, and enforcing both would refuse what the document accepts.
|
|
88
|
+
*
|
|
89
|
+
* A property is not a chain: its type's constraints are applied where the type is rendered.
|
|
90
|
+
*/
|
|
91
|
+
function declaredOn(program, target, get) {
|
|
92
|
+
if (target.kind !== "Scalar")
|
|
93
|
+
return get(program, target);
|
|
94
|
+
for (let current = target; current !== undefined; current = current.baseScalar) {
|
|
95
|
+
const value = get(program, current);
|
|
96
|
+
if (value !== undefined)
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
75
101
|
/**
|
|
76
102
|
* Whether the value this type puts on the wire is text because `@encode` says so. Read from the
|
|
77
103
|
* property and then from its scalar, which is where an encoding declared once for a named scalar
|
|
@@ -210,7 +236,7 @@ export function patternIsProvablySafe(pattern) {
|
|
|
210
236
|
* instead of guessing at an equivalence.
|
|
211
237
|
*/
|
|
212
238
|
export function patternToRegex(pattern) {
|
|
213
|
-
return `/${escapeSlashes(pattern)}/${unicodeSafe(pattern) ? "u" : ""}`;
|
|
239
|
+
return `/${escapeNonAscii(escapeSlashes(pattern))}/${unicodeSafe(pattern) ? "u" : ""}`;
|
|
214
240
|
}
|
|
215
241
|
/**
|
|
216
242
|
* **Escapes the slashes a regex literal needs escaped, and only those.**
|
|
@@ -232,7 +258,15 @@ function escapeSlashes(pattern) {
|
|
|
232
258
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
233
259
|
const character = pattern[index];
|
|
234
260
|
if (character === "\\") {
|
|
235
|
-
|
|
261
|
+
const next = pattern[index + 1] ?? "";
|
|
262
|
+
/**
|
|
263
|
+
* **An identity escape of a character outside printable ASCII loses its backslash**, because
|
|
264
|
+
* the character is about to be written as `\uXXXX` and `\\uXXXX` would be a literal
|
|
265
|
+
* backslash followed by `u`. An accented letter matches the same thing escaped or not; under
|
|
266
|
+
* the `u` flag the escape is a syntax error, and `unicodeSafe` has already sent such a
|
|
267
|
+
* pattern to the bare form.
|
|
268
|
+
*/
|
|
269
|
+
escaped += /[^\x20-\x7e]/.test(next) ? next : character + next;
|
|
236
270
|
index += 1;
|
|
237
271
|
continue;
|
|
238
272
|
}
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -162,6 +162,12 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
162
162
|
"unenforceable-encoded-bound": {
|
|
163
163
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
164
164
|
};
|
|
165
|
+
"unenforceable-property-name": {
|
|
166
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
167
|
+
};
|
|
168
|
+
"optional-prototype-name": {
|
|
169
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
170
|
+
};
|
|
165
171
|
"duplicate-operation-id": {
|
|
166
172
|
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
167
173
|
};
|
|
@@ -172,7 +178,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
172
178
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
173
179
|
};
|
|
174
180
|
}, EmitterOptions, never>;
|
|
175
|
-
export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "inline-cycle" | "non-unicode-pattern" | "truncated-doc-comment" | "undeclared-discriminator" | "unenforceable-encoded-bound" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
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 {
|
|
176
182
|
"unsupported-type": {
|
|
177
183
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
178
184
|
};
|
|
@@ -206,6 +212,12 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
206
212
|
"unenforceable-encoded-bound": {
|
|
207
213
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
208
214
|
};
|
|
215
|
+
"unenforceable-property-name": {
|
|
216
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
217
|
+
};
|
|
218
|
+
"optional-prototype-name": {
|
|
219
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
220
|
+
};
|
|
209
221
|
"duplicate-operation-id": {
|
|
210
222
|
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
211
223
|
};
|
|
@@ -249,6 +261,12 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
249
261
|
"unenforceable-encoded-bound": {
|
|
250
262
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
251
263
|
};
|
|
264
|
+
"unenforceable-property-name": {
|
|
265
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
266
|
+
};
|
|
267
|
+
"optional-prototype-name": {
|
|
268
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
269
|
+
};
|
|
252
270
|
"duplicate-operation-id": {
|
|
253
271
|
readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
|
|
254
272
|
};
|
package/dist/src/lib.js
CHANGED
|
@@ -297,6 +297,48 @@ const diagnostics = {
|
|
|
297
297
|
default: paramMessage `The bound on '${"name"}' cannot be enforced, and is not emitted. '@encode(string)' makes the wire value text, so '@minValue'/'@maxValue' would become a check on how many CHARACTERS it has - '@minValue(10)' would refuse '"42"' - and the exclusive form has no string equivalent at all. The document is no help either: it publishes 'minimum' on a 'type: string' schema, which JSON Schema ignores, so a validator enforcing the bound would refuse what the published contract accepts. Declare the bound as a '@pattern', which the document publishes and every caller can read, or remove '@encode(string)' and let the value be a number.`,
|
|
298
298
|
},
|
|
299
299
|
},
|
|
300
|
+
/**
|
|
301
|
+
* **A property whose wire name is `__proto__` is published, and no Zod validator can check it.**
|
|
302
|
+
*
|
|
303
|
+
* Zod skips a declared `__proto__` key on purpose, so prototype pollution cannot reach through a
|
|
304
|
+
* parse (colinhacks/zod#6386): a value of any type, or none, is accepted for it and dropped from
|
|
305
|
+
* the result. The document publishes it like any other property, and nothing emitted can enforce
|
|
306
|
+
* it - which is the case `unenforceable-encoded-bound` reports for a bound, for the same reason:
|
|
307
|
+
* the spec is representable, so refusing it would make it emit under one emitter and not the
|
|
308
|
+
* other, and serving it silently would leave a validator looser than its contract unsaid.
|
|
309
|
+
*
|
|
310
|
+
* The key is still DECLARED, as a computed `["__proto__"]`, because a plain `__proto__:` in an
|
|
311
|
+
* object literal sets the shape's prototype instead. Measured: that dropped the property from the
|
|
312
|
+
* published JSON Schema, and under `seal-object-schemas` refused every body carrying it as an
|
|
313
|
+
* unrecognised key - the opposite of what the document says.
|
|
314
|
+
*/
|
|
315
|
+
"unenforceable-property-name": {
|
|
316
|
+
severity: "warning",
|
|
317
|
+
messages: {
|
|
318
|
+
default: paramMessage `'${"name"}' is published as a property and cannot be validated: Zod skips a key named '__proto__' on purpose (colinhacks/zod#6386), so any value, or none, is accepted for it and dropped from the parsed result. The published schema still states it. Give it another wire name if its value has to be checked.`,
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
/**
|
|
322
|
+
* **An optional property named after an `Object.prototype` member refuses every value that
|
|
323
|
+
* leaves it out.**
|
|
324
|
+
*
|
|
325
|
+
* Zod reads a property as `input[key]`, so an absent `constructor`, `toString`, `valueOf` or any
|
|
326
|
+
* other inherited member reads as the function every object inherits, and is refused as the wrong
|
|
327
|
+
* type (colinhacks/zod#6666, open). A required one is unaffected, because a value that leaves it
|
|
328
|
+
* out is refused either way. The emitted validators are right and inherit the defect - on
|
|
329
|
+
* requests and responses alike - and the only repair in emitted code, a `z.preprocess` onto a
|
|
330
|
+
* null-prototype copy, would cost the model `.shape` and `.extend`.
|
|
331
|
+
*
|
|
332
|
+
* Reported where the property is optional, which includes the PATCH position that makes a
|
|
333
|
+
* required property optional. When Zod ships the fix, the peer floor moves to it and this retires:
|
|
334
|
+
* `test/inheritednames` pins the defect so that release is noticed.
|
|
335
|
+
*/
|
|
336
|
+
"optional-prototype-name": {
|
|
337
|
+
severity: "warning",
|
|
338
|
+
messages: {
|
|
339
|
+
default: paramMessage `'${"name"}' is optional here, and the validator refuses every value that leaves it out: Zod reads an absent '${"name"}' as the member every object inherits from Object.prototype, a function, and rejects it (colinhacks/zod#6666). A value that carries '${"name"}' validates normally. Give it a wire name Object.prototype does not have, or declare it required, until Zod releases a fix.`,
|
|
340
|
+
},
|
|
341
|
+
},
|
|
300
342
|
"duplicate-operation-id": {
|
|
301
343
|
severity: "error",
|
|
302
344
|
messages: {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **A value as a JavaScript literal whose source is ASCII.**
|
|
3
|
+
*
|
|
4
|
+
* `JSON.stringify` is the right spelling for spec text that becomes code - a key, an enum value, a
|
|
5
|
+
* media type, a description - because it quotes and escapes exactly as a string literal needs. It
|
|
6
|
+
* leaves every other character as it is, and no file this package generates may carry one: a
|
|
7
|
+
* consumer commits the output, and an invisible character in it has held a block comment open past
|
|
8
|
+
* its terminator in the sibling package. `test/portability.test.ts` grades every emitted file.
|
|
9
|
+
*
|
|
10
|
+
* Measured once descriptions reached the output: `@typespec/http-specs`' `payload/xml` documents its
|
|
11
|
+
* models as `"\u00a71.1 \u2014 Contains fields of primitive types."`, and the section sign and the dash went
|
|
12
|
+
* straight into `schemas.gen.ts`. Every other site had the same exposure and was ASCII only because
|
|
13
|
+
* no spec in the corpus happened to put a non-ASCII character in a key or a value.
|
|
14
|
+
*
|
|
15
|
+
* So every character outside printable ASCII is written as a `\uXXXX` escape: the same string at
|
|
16
|
+
* run time, in either quote position, and in a TypeScript literal type. An astral character is two
|
|
17
|
+
* UTF-16 units, escaped one at a time, which is how JavaScript spells it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function literal(value: unknown): string;
|
|
20
|
+
/**
|
|
21
|
+
* Every UTF-16 unit outside printable ASCII as a `\uXXXX` escape. A string literal and a regular
|
|
22
|
+
* expression read the escape alike, with or without the `u` flag: under it, an escaped surrogate pair
|
|
23
|
+
* is the one code point the pair spells.
|
|
24
|
+
*/
|
|
25
|
+
export declare function escapeNonAscii(source: string): string;
|
|
26
|
+
/** One UTF-16 unit as `\uXXXX`. */
|
|
27
|
+
export declare function unicodeEscape(unit: string): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **A value as a JavaScript literal whose source is ASCII.**
|
|
3
|
+
*
|
|
4
|
+
* `JSON.stringify` is the right spelling for spec text that becomes code - a key, an enum value, a
|
|
5
|
+
* media type, a description - because it quotes and escapes exactly as a string literal needs. It
|
|
6
|
+
* leaves every other character as it is, and no file this package generates may carry one: a
|
|
7
|
+
* consumer commits the output, and an invisible character in it has held a block comment open past
|
|
8
|
+
* its terminator in the sibling package. `test/portability.test.ts` grades every emitted file.
|
|
9
|
+
*
|
|
10
|
+
* Measured once descriptions reached the output: `@typespec/http-specs`' `payload/xml` documents its
|
|
11
|
+
* models as `"\u00a71.1 \u2014 Contains fields of primitive types."`, and the section sign and the dash went
|
|
12
|
+
* straight into `schemas.gen.ts`. Every other site had the same exposure and was ASCII only because
|
|
13
|
+
* no spec in the corpus happened to put a non-ASCII character in a key or a value.
|
|
14
|
+
*
|
|
15
|
+
* So every character outside printable ASCII is written as a `\uXXXX` escape: the same string at
|
|
16
|
+
* run time, in either quote position, and in a TypeScript literal type. An astral character is two
|
|
17
|
+
* UTF-16 units, escaped one at a time, which is how JavaScript spells it.
|
|
18
|
+
*/
|
|
19
|
+
export function literal(value) {
|
|
20
|
+
return escapeNonAscii(JSON.stringify(value));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Every UTF-16 unit outside printable ASCII as a `\uXXXX` escape. A string literal and a regular
|
|
24
|
+
* expression read the escape alike, with or without the `u` flag: under it, an escaped surrogate pair
|
|
25
|
+
* is the one code point the pair spells.
|
|
26
|
+
*/
|
|
27
|
+
export function escapeNonAscii(source) {
|
|
28
|
+
return source.replace(/[^\x20-\x7e]/g, unicodeEscape);
|
|
29
|
+
}
|
|
30
|
+
/** One UTF-16 unit as `\uXXXX`. */
|
|
31
|
+
export function unicodeEscape(unit) {
|
|
32
|
+
return `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
33
|
+
}
|
package/dist/src/registry.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { getFriendlyName, } from "@typespec/compiler";
|
|
1
|
+
import { getDoc, getFriendlyName, } from "@typespec/compiler";
|
|
2
2
|
import { getVisibilitySuffix, Visibility } from "@typespec/http";
|
|
3
3
|
import { reportDiagnostic } from "./lib.js";
|
|
4
4
|
import { propertyToTs, propertyTypeToTs, typeToTsBody, withTsRefResolver } from "./types.js";
|
|
5
|
-
import { captureBackEdges, currentVisibility, isOptionalAt, isTransformedBy, noteBackEdge, propertyToZod, UNREPRESENTABLE, typeToZodBody, withDeferredAnnotator, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
|
|
5
|
+
import { captureBackEdges, currentVisibility, described, isOptionalAt, isTransformedBy, noteBackEdge, propertyToZod, UNREPRESENTABLE, typeToZodBody, withDeferredAnnotator, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
|
|
6
6
|
/**
|
|
7
7
|
* The name an ANONYMOUS payload model inherits from the single model it spreads.
|
|
8
8
|
*
|
|
@@ -394,7 +394,17 @@ export class SchemaRegistry {
|
|
|
394
394
|
* and the emitted `const` would read itself while initialising. `z.lazy()` defers the whole body
|
|
395
395
|
* instead, which is the only place left to put the deferral once no property can hold it.
|
|
396
396
|
*/
|
|
397
|
-
|
|
397
|
+
/**
|
|
398
|
+
* **The description goes on the declaration's own body, so the exported const IS the
|
|
399
|
+
* described schema.** Every reference then names that one instance, which is what keeps
|
|
400
|
+
* identity, `z.compile`'s clone (it inherits the description through its parent link) and
|
|
401
|
+
* Zod's `$ref` extraction all intact. Described at a reference instead, each use would be a
|
|
402
|
+
* fresh clone. Read from the canonical type, because an anonymous payload has its doc only on
|
|
403
|
+
* the model it was spread from, and a visibility projection carries the doc of the model it
|
|
404
|
+
* projects - as openapi3 publishes one component for both.
|
|
405
|
+
*/
|
|
406
|
+
const body = rendered.deferred ? `z.lazy(() => ${rendered.value})` : rendered.value;
|
|
407
|
+
const source = described(body, getDoc(this.#program, canonicalTypeFor(type)));
|
|
398
408
|
const declaration = {
|
|
399
409
|
identifier,
|
|
400
410
|
typeName: name,
|
|
@@ -523,7 +533,20 @@ export class SchemaRegistry {
|
|
|
523
533
|
* drift.
|
|
524
534
|
*/
|
|
525
535
|
#structuralBodyOf(type) {
|
|
526
|
-
return withTsRefResolver((candidate) =>
|
|
536
|
+
return withTsRefResolver((candidate) => this.#typeNameOf(candidate), () => typeToTsBody(this.#program, type));
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* **The TypeScript name a reference resolves to is the one {@link expressionFor} declares it
|
|
540
|
+
* under**, visibility suffix included. Resolved by the bare name, a cycle through a model a
|
|
541
|
+
* request projects typed the projection's back edges by the READ shape: `NodeCreate`'s
|
|
542
|
+
* `children` became `Node[]`, which carries a property the request never sends, and the declared
|
|
543
|
+
* validator did not satisfy its own annotation (`TS2375`).
|
|
544
|
+
*/
|
|
545
|
+
#typeNameOf(candidate) {
|
|
546
|
+
const bare = declaredNameOf(this.#program, candidate);
|
|
547
|
+
if (bare === undefined)
|
|
548
|
+
return undefined;
|
|
549
|
+
return nameAt(bare, visibilityFor(this.#program, candidate, currentVisibility()));
|
|
527
550
|
}
|
|
528
551
|
/**
|
|
529
552
|
* Render `type` without consulting its own declaration, but letting nested types use theirs.
|
|
@@ -567,7 +590,7 @@ export class SchemaRegistry {
|
|
|
567
590
|
#deferredAnnotationOf(property) {
|
|
568
591
|
if (property.defaultValue !== undefined)
|
|
569
592
|
return undefined;
|
|
570
|
-
const declared = withTsRefResolver((candidate) =>
|
|
593
|
+
const declared = withTsRefResolver((candidate) => this.#typeNameOf(candidate), () => propertyTypeToTs(this.#program, property));
|
|
571
594
|
const core = `z.ZodType<${declared}, ${declared}>`;
|
|
572
595
|
const optional = property.optional || isOptionalAt(this.#program, property);
|
|
573
596
|
return `: ${optional ? `z.ZodExactOptional<${core}>` : core}`;
|
package/dist/src/results.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
|
|
2
|
+
import { literal } from "./literal.js";
|
|
2
3
|
/**
|
|
3
4
|
* **A media type's essence: `type/subtype`, lowercased, with no parameters** (RFC 9110 section
|
|
4
5
|
* 8.3.1). Two media types are the same type exactly when their essences are equal, so every
|
|
@@ -41,7 +42,7 @@ export function isMediaRange(mediaType) {
|
|
|
41
42
|
*/
|
|
42
43
|
export function mediaTypeTypeOf(mediaType) {
|
|
43
44
|
if (!isMediaRange(mediaType))
|
|
44
|
-
return
|
|
45
|
+
return literal(mediaType);
|
|
45
46
|
if (mediaType === "*/*")
|
|
46
47
|
return "string";
|
|
47
48
|
return `\`${mediaType.slice(0, -1)}\${string}\``;
|
package/dist/src/types.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getDiscriminatedUnion, isNeverType, resolveEncodedName, } from "@typespec/compiler";
|
|
2
2
|
import { reportDiagnostic } from "./lib.js";
|
|
3
|
+
import { literal } from "./literal.js";
|
|
3
4
|
import { discriminatedSubtypes, effectiveIndexer, emitsDefault, encodedTypeOf, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
|
|
4
5
|
/**
|
|
5
6
|
* TypeSpec type -> plain TypeScript type text.
|
|
@@ -102,7 +103,7 @@ function scalarToTs(program, scalar) {
|
|
|
102
103
|
}
|
|
103
104
|
function enumToTs(target) {
|
|
104
105
|
const values = [...target.members.values()].map((member) => member.value ?? member.name);
|
|
105
|
-
return values.map((value) =>
|
|
106
|
+
return values.map((value) => literal(value)).join(" | ");
|
|
106
107
|
}
|
|
107
108
|
function unionToTs(program, union) {
|
|
108
109
|
/**
|
|
@@ -115,7 +116,7 @@ function unionToTs(program, union) {
|
|
|
115
116
|
if (discriminated !== undefined && discriminated.defaultVariant === undefined) {
|
|
116
117
|
const { envelope, discriminatorPropertyName, envelopePropertyName } = discriminated.options;
|
|
117
118
|
const arms = [...discriminated.variants.entries()].map(([name, variant]) => {
|
|
118
|
-
const tag = `${objectKey(discriminatorPropertyName)}: ${
|
|
119
|
+
const tag = `${objectKey(discriminatorPropertyName)}: ${literal(name)};`;
|
|
119
120
|
const inner = typeToTs(program, variant);
|
|
120
121
|
// No injection: with no envelope the variant declares the discriminator itself, or the union
|
|
121
122
|
// is refused in `zod.ts`. Nothing is added that the document does not publish.
|
|
@@ -369,13 +370,13 @@ export function typeToTsBody(program, type) {
|
|
|
369
370
|
case "Union":
|
|
370
371
|
return unionToTs(program, type);
|
|
371
372
|
case "String":
|
|
372
|
-
return
|
|
373
|
+
return literal(type.value);
|
|
373
374
|
case "Number":
|
|
374
375
|
return String(type.value);
|
|
375
376
|
case "Boolean":
|
|
376
377
|
return String(type.value);
|
|
377
378
|
case "EnumMember":
|
|
378
|
-
return
|
|
379
|
+
return literal(type.value ?? type.name);
|
|
379
380
|
// A named union variant is a referable type - `DogKind.Golden`. See the note in `zod.ts`.
|
|
380
381
|
case "UnionVariant":
|
|
381
382
|
return typeToTs(program, type.type);
|
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* RFC 6570 URI templates, read for what a server has to mount and what it has to decode.
|
|
3
|
-
*
|
|
4
|
-
* **`HttpOperation.uriTemplate`, never `HttpOperation.path`.** `path` drops every operator, so
|
|
5
|
-
* `array{.param*}`, `array{;param}` and `array{/param}` all become `array{param}`, and a route mounted
|
|
6
|
-
* from it answers 404 to the URL its own contract declares. Measured on `@typespec/http-specs`
|
|
7
|
-
* `routes`: 30 of its mock server's URIs, plus the query-continuation six.
|
|
8
|
-
*
|
|
9
|
-
* `parseUriTemplate` is copied from `@typespec/http` 1.16.0 (`dist/src/uri-template.js`, MIT), which
|
|
10
|
-
* implements it but does not export it, so the template is split exactly as the library that built
|
|
11
|
-
* it splits it.
|
|
12
|
-
*/
|
|
13
1
|
/**
|
|
14
2
|
* One `/`-delimited segment of a route, as a server has to mount it.
|
|
15
3
|
*
|
package/dist/src/uri-template.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { literal } from "./literal.js";
|
|
1
2
|
/**
|
|
2
3
|
* RFC 6570 URI templates, read for what a server has to mount and what it has to decode.
|
|
3
4
|
*
|
|
@@ -128,7 +129,7 @@ export function needsExpansionDecoding(segment, shape) {
|
|
|
128
129
|
* {@link URI_EXPANSION_SOURCE}, declared once in `schemas.gen.ts` when something calls it.
|
|
129
130
|
*/
|
|
130
131
|
export function uriExpansionCall(spec) {
|
|
131
|
-
return `(raw, ctx) => uriExpansion(raw, ctx, { prefix: ${
|
|
132
|
+
return `(raw, ctx) => uriExpansion(raw, ctx, { prefix: ${literal(spec.prefix)}, suffix: ${literal(spec.suffix)}, operator: ${literal(spec.operator)}, name: ${literal(spec.name)}, explode: ${spec.explode}, shape: ${literal(spec.shape)}, values: ${literal(spec.values)} })`;
|
|
132
133
|
}
|
|
133
134
|
/**
|
|
134
135
|
* **RFC 6570 section 3.2, run backwards.** A router hands over the text of the segment an expression
|
|
@@ -209,7 +210,7 @@ function uriExpansion(
|
|
|
209
210
|
* gatherer itself is {@link EXPLODED_QUERY_SOURCE}.
|
|
210
211
|
*/
|
|
211
212
|
export function explodedQueryCall(spec) {
|
|
212
|
-
return `(raw) => explodedQuery(raw, { name: ${
|
|
213
|
+
return `(raw) => explodedQuery(raw, { name: ${literal(spec.name)}, others: ${literal(spec.others)}, keys: ${literal(spec.keys ?? null)}, numbers: ${literal(spec.numbers)}, booleans: ${literal(spec.booleans)}, values: ${literal(spec.values)} })`;
|
|
213
214
|
}
|
|
214
215
|
/**
|
|
215
216
|
* **`{?param*}` over a record or a model expands to the object's own keys**, `?a=1&b=2` and
|
package/dist/src/zod.d.ts
CHANGED
|
@@ -75,6 +75,40 @@ export declare function withDeclarationSite<T>(site: Type | undefined, run: () =
|
|
|
75
75
|
* walked, which does.
|
|
76
76
|
*/
|
|
77
77
|
export declare function refusalTarget(type: Type): Type;
|
|
78
|
+
/**
|
|
79
|
+
* **A doc becomes `.describe()`, which is the one place Zod keeps a description.**
|
|
80
|
+
*
|
|
81
|
+
* `@typespec/openapi3` writes every `getDoc` as a JSON Schema `description`, and nothing carried it
|
|
82
|
+
* into the validators: a surface publishing its input schema FROM them - an MCP server's
|
|
83
|
+
* `tools/list` - gave a model calling a tool bare patterns and enums with no word on what a field is
|
|
84
|
+
* for. Measured on one consumer: a tool refused 369 calls and accepted 98, most refusals coming from
|
|
85
|
+
* the client checking that published schema before it called.
|
|
86
|
+
*
|
|
87
|
+
* `.describe()` records the text in `z.globalRegistry`, which is a `globalThis` singleton shared by
|
|
88
|
+
* every copy of Zod in a process, and both `z.toJSONSchema` and Zod's Standard Schema converter read
|
|
89
|
+
* it by default. It returns a clone and changes nothing a parse decides.
|
|
90
|
+
*
|
|
91
|
+
* **It is always the OUTERMOST call at a position, and never on a bare reference.** Inside
|
|
92
|
+
* `.nullable()` the text lands in `anyOf[0]` rather than on the property; on an identifier inside
|
|
93
|
+
* `z.array()` or a union it would clone a declaration that other code finds by identity. So it is
|
|
94
|
+
* applied where a declaration's body, a property's value or a scalar's schema is finished, and
|
|
95
|
+
* nowhere else.
|
|
96
|
+
*
|
|
97
|
+
* Gated on `undefined` rather than on truthiness, as openapi3's `applyConstraint` is: an empty
|
|
98
|
+
* `@doc("")` is published as `""`, and so is it here.
|
|
99
|
+
*/
|
|
100
|
+
export declare function described(expression: string, doc: string | undefined): string;
|
|
101
|
+
/**
|
|
102
|
+
* The description a declared scalar publishes: its own doc, else the nearest one up its `extends`
|
|
103
|
+
* chain, and never a standard type's.
|
|
104
|
+
*
|
|
105
|
+
* Both halves are openapi3's, from `#getSchemaForScalar`: a derived scalar starts from its base's
|
|
106
|
+
* schema, description included, and overwrites what it declares itself - so `scalar B extends A`
|
|
107
|
+
* with no doc publishes A's. A standard scalar's description is deleted, because "standard types are
|
|
108
|
+
* going to be inlined in the spec and we don't want the description of the scalar to show up".
|
|
109
|
+
* Without that, `string`'s own "A sequence of textual characters." would sit on every `z.string()`.
|
|
110
|
+
*/
|
|
111
|
+
export declare function scalarDocOf(program: Program, scalar: Scalar): string | undefined;
|
|
78
112
|
export declare function withSyntheticDeclarations<T>(hook: (name: string, build: () => string) => string, run: () => T): T;
|
|
79
113
|
/**
|
|
80
114
|
* Every property the model declares, **including the ones it inherits**.
|
package/dist/src/zod.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { getDiscriminatedUnion, getDiscriminator, getEncode, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
|
|
2
|
-
import { createMetadataInfo, isQueryParam, Visibility } from "@typespec/http";
|
|
1
|
+
import { getDiscriminatedUnion, getDiscriminator, getDoc, getEncode, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
|
|
2
|
+
import { createMetadataInfo, getCookieParamOptions, getHeaderFieldName, getPathParamName, getQueryParamName, isCookieParam, isHeader, isPathParam, isQueryParam, Visibility, } from "@typespec/http";
|
|
3
3
|
import { applyConstraints } from "./constraints.js";
|
|
4
|
+
import { literal } from "./literal.js";
|
|
4
5
|
import { reportDiagnostic } from "./lib.js";
|
|
5
6
|
/**
|
|
6
7
|
* What a walk returns after reporting a refusal.
|
|
@@ -406,10 +407,57 @@ const SCALARS = {
|
|
|
406
407
|
url: "z.url()",
|
|
407
408
|
};
|
|
408
409
|
function scalarToZod(program, scalar) {
|
|
410
|
+
return described(scalarSchemaOf(program, scalar), scalarDocOf(program, scalar));
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* **A doc becomes `.describe()`, which is the one place Zod keeps a description.**
|
|
414
|
+
*
|
|
415
|
+
* `@typespec/openapi3` writes every `getDoc` as a JSON Schema `description`, and nothing carried it
|
|
416
|
+
* into the validators: a surface publishing its input schema FROM them - an MCP server's
|
|
417
|
+
* `tools/list` - gave a model calling a tool bare patterns and enums with no word on what a field is
|
|
418
|
+
* for. Measured on one consumer: a tool refused 369 calls and accepted 98, most refusals coming from
|
|
419
|
+
* the client checking that published schema before it called.
|
|
420
|
+
*
|
|
421
|
+
* `.describe()` records the text in `z.globalRegistry`, which is a `globalThis` singleton shared by
|
|
422
|
+
* every copy of Zod in a process, and both `z.toJSONSchema` and Zod's Standard Schema converter read
|
|
423
|
+
* it by default. It returns a clone and changes nothing a parse decides.
|
|
424
|
+
*
|
|
425
|
+
* **It is always the OUTERMOST call at a position, and never on a bare reference.** Inside
|
|
426
|
+
* `.nullable()` the text lands in `anyOf[0]` rather than on the property; on an identifier inside
|
|
427
|
+
* `z.array()` or a union it would clone a declaration that other code finds by identity. So it is
|
|
428
|
+
* applied where a declaration's body, a property's value or a scalar's schema is finished, and
|
|
429
|
+
* nowhere else.
|
|
430
|
+
*
|
|
431
|
+
* Gated on `undefined` rather than on truthiness, as openapi3's `applyConstraint` is: an empty
|
|
432
|
+
* `@doc("")` is published as `""`, and so is it here.
|
|
433
|
+
*/
|
|
434
|
+
export function described(expression, doc) {
|
|
435
|
+
return doc === undefined ? expression : `${expression}.describe(${literal(doc)})`;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The description a declared scalar publishes: its own doc, else the nearest one up its `extends`
|
|
439
|
+
* chain, and never a standard type's.
|
|
440
|
+
*
|
|
441
|
+
* Both halves are openapi3's, from `#getSchemaForScalar`: a derived scalar starts from its base's
|
|
442
|
+
* schema, description included, and overwrites what it declares itself - so `scalar B extends A`
|
|
443
|
+
* with no doc publishes A's. A standard scalar's description is deleted, because "standard types are
|
|
444
|
+
* going to be inlined in the spec and we don't want the description of the scalar to show up".
|
|
445
|
+
* Without that, `string`'s own "A sequence of textual characters." would sit on every `z.string()`.
|
|
446
|
+
*/
|
|
447
|
+
export function scalarDocOf(program, scalar) {
|
|
448
|
+
for (let current = scalar; current !== undefined && !program.checker.isStdType(current); current = current.baseScalar) {
|
|
449
|
+
const doc = getDoc(program, current);
|
|
450
|
+
if (doc !== undefined)
|
|
451
|
+
return doc;
|
|
452
|
+
}
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
/** A scalar's schema without its description, for a position that publishes its own. */
|
|
456
|
+
function scalarSchemaOf(program, scalar) {
|
|
409
457
|
// `scalar unixTimestamp32 is utcDateTime;` with `@encode(int32)` on the SCALAR, not on each use.
|
|
410
458
|
const encoded = encodedTypeOf(program, scalar);
|
|
411
459
|
if (encoded !== undefined && encoded !== scalar) {
|
|
412
|
-
return applyConstraints(program,
|
|
460
|
+
return applyConstraints(program, scalarSchemaOf(program, encoded), scalar);
|
|
413
461
|
}
|
|
414
462
|
let current = scalar;
|
|
415
463
|
// Walk the `extends` chain so a named scalar (`scalar chNumber extends string`) resolves to the
|
|
@@ -454,7 +502,7 @@ function scalarToZod(program, scalar) {
|
|
|
454
502
|
function enumToZod(_program, target) {
|
|
455
503
|
const values = [...target.members.values()].map((member) => member.value ?? member.name);
|
|
456
504
|
if (values.some((value) => typeof value !== "string")) {
|
|
457
|
-
return `z.union([${values.map((value) => `z.literal(${
|
|
505
|
+
return `z.union([${values.map((value) => `z.literal(${literal(value)})`).join(", ")}])`;
|
|
458
506
|
}
|
|
459
507
|
/**
|
|
460
508
|
* Every string vocabulary resolves through the generated constant rather than being restated.
|
|
@@ -475,7 +523,7 @@ function enumToZod(_program, target) {
|
|
|
475
523
|
*/
|
|
476
524
|
const shared = contractsPackage();
|
|
477
525
|
if (shared === undefined) {
|
|
478
|
-
return `z.enum([${values.map((value) =>
|
|
526
|
+
return `z.enum([${values.map((value) => literal(value)).join(", ")}])`;
|
|
479
527
|
}
|
|
480
528
|
noteExternalImport(shared, "SPEC_VOCABULARIES");
|
|
481
529
|
return `z.enum(SPEC_VOCABULARIES.${target.name})`;
|
|
@@ -534,7 +582,7 @@ function declaresDiscriminator(program, variant, property) {
|
|
|
534
582
|
*/
|
|
535
583
|
function discriminatedVariant(program, union, variantName, variant, options) {
|
|
536
584
|
const inner = typeToZod(program, variant);
|
|
537
|
-
const tag = `${objectKey(options.discriminatorPropertyName)}: z.literal(${
|
|
585
|
+
const tag = `${objectKey(options.discriminatorPropertyName)}: z.literal(${literal(variantName)})`;
|
|
538
586
|
// The variant already carries the discriminator, so the document publishes it and there is
|
|
539
587
|
// nothing to add.
|
|
540
588
|
if (options.envelope === "none")
|
|
@@ -587,7 +635,7 @@ function unionToZod(program, union) {
|
|
|
587
635
|
}
|
|
588
636
|
const members = [...discriminated.variants.entries()].map(([name, variant]) => discriminatedVariant(program, union, name, variant, options));
|
|
589
637
|
const property = options.discriminatorPropertyName;
|
|
590
|
-
return `z.discriminatedUnion(${
|
|
638
|
+
return `z.discriminatedUnion(${literal(property)}, [${members.join(", ")}])`;
|
|
591
639
|
}
|
|
592
640
|
const variants = [...union.variants.values()].map((variant) => variant.type);
|
|
593
641
|
const nullable = variants.some((type) => type.kind === "Intrinsic" && type.name === "null");
|
|
@@ -602,7 +650,7 @@ function unionToZod(program, union) {
|
|
|
602
650
|
}
|
|
603
651
|
const suffix = nullable ? ".nullable()" : "";
|
|
604
652
|
if (real.every((type) => type.kind === "String")) {
|
|
605
|
-
const literals = real.map((type) =>
|
|
653
|
+
const literals = real.map((type) => literal(type.value));
|
|
606
654
|
return `z.enum([${literals.join(", ")}])${suffix}`;
|
|
607
655
|
}
|
|
608
656
|
if (real.length === 1)
|
|
@@ -698,9 +746,10 @@ function modelToZod(program, model) {
|
|
|
698
746
|
*/
|
|
699
747
|
const subtypes = discriminatedSubtypes(program, model);
|
|
700
748
|
if (subtypes.length > 0) {
|
|
701
|
-
|
|
749
|
+
// `discriminatedSubtypes` is empty unless the model has a discriminator.
|
|
750
|
+
const discriminator = getDiscriminator(program, model)?.propertyName ?? "";
|
|
702
751
|
const options = subtypes.map((derived) => typeToZod(program, derived));
|
|
703
|
-
return `z.discriminatedUnion(${
|
|
752
|
+
return `z.discriminatedUnion(${literal(discriminator)}, [${options.join(", ")}])`;
|
|
704
753
|
}
|
|
705
754
|
const indexer = effectiveIndexer(model);
|
|
706
755
|
const indexerKey = indexer?.key.name;
|
|
@@ -945,7 +994,16 @@ export function wireKindOf(program, type) {
|
|
|
945
994
|
* Exported so there is one answer to this question. There were two sites and only one of them knew.
|
|
946
995
|
*/
|
|
947
996
|
export function objectKey(name) {
|
|
948
|
-
|
|
997
|
+
/**
|
|
998
|
+
* **`__proto__` is the one name a key cannot spell plainly.** In an object literal, `__proto__:`
|
|
999
|
+
* - quoted or not - sets the object's prototype rather than declaring a key, so the emitted shape
|
|
1000
|
+
* had no such property: it vanished from the published JSON Schema, and under
|
|
1001
|
+
* `seal-object-schemas` every body carrying it was refused as an unrecognised key. A computed
|
|
1002
|
+
* key declares it, in a value and in a type alike. See `unenforceable-property-name`.
|
|
1003
|
+
*/
|
|
1004
|
+
if (name === "__proto__")
|
|
1005
|
+
return `[${literal(name)}]`;
|
|
1006
|
+
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : literal(name);
|
|
949
1007
|
}
|
|
950
1008
|
/**
|
|
951
1009
|
* A doc string as a JSDoc comment, indented, or `""` when there is none.
|
|
@@ -1063,9 +1121,56 @@ const PARAMETER_STYLE_ENCODINGS = new Set([
|
|
|
1063
1121
|
"ArrayEncoding.commaDelimited",
|
|
1064
1122
|
"ArrayEncoding.newlineDelimited",
|
|
1065
1123
|
]);
|
|
1124
|
+
/**
|
|
1125
|
+
* **A date-time header with no `@encode` is an RFC 7231 HTTP-date, as if it said
|
|
1126
|
+
* `@encode(DateTimeKnownEncoding.rfc7231)`.**
|
|
1127
|
+
*
|
|
1128
|
+
* That is TypeSpec's wire form for one, and `@typespec/openapi3` publishes it as
|
|
1129
|
+
* `format: http-date` from `applyEncoding`'s `isDateTimeHeader` branch, which this emitter did not
|
|
1130
|
+
* follow. The header fell through to the scalar's default, `z.iso.datetime({ offset: true })`, so a
|
|
1131
|
+
* request sending it as the document describes was refused with 400, and a handler setting it as an
|
|
1132
|
+
* HTTP-date failed its own response contract with 500 (issue #4). The explicit encoding already
|
|
1133
|
+
* emitted `z.string()`, and this is that.
|
|
1134
|
+
*
|
|
1135
|
+
* **The predicate is openapi3's: the header's own schema carries `format: date-time`.** That is
|
|
1136
|
+
* the standard `utcDateTime` or `offsetDateTime` itself. A declared scalar extending one is
|
|
1137
|
+
* published as a `$ref`, a list carries the format on its items, and `T | null` is an `anyOf`
|
|
1138
|
+
* under 3.1 and 3.2 - the versions this package grades against - so none of those is an HTTP-date
|
|
1139
|
+
* in the document, and none is here. (3.0 publishes the nullable one as `nullable: true` beside an
|
|
1140
|
+
* `http-date` format; the graded documents do not.)
|
|
1141
|
+
*/
|
|
1142
|
+
function isDateTimeHeader(program, property) {
|
|
1143
|
+
const type = property.type;
|
|
1144
|
+
return isHeader(program, property) && type.kind === "Scalar" && isStdDateTime(program, type);
|
|
1145
|
+
}
|
|
1146
|
+
function isStdDateTime(program, scalar) {
|
|
1147
|
+
const { checker } = program;
|
|
1148
|
+
return (scalar === checker.getStdType("utcDateTime") || scalar === checker.getStdType("offsetDateTime"));
|
|
1149
|
+
}
|
|
1150
|
+
/** Whether a property's value, or a scalar, is a date-time: one of the two, or built on one. */
|
|
1151
|
+
function isDateTime(program, target) {
|
|
1152
|
+
const type = target.kind === "ModelProperty" ? (nullableInnerOf(target.type) ?? target.type) : target;
|
|
1153
|
+
for (let current = type.kind === "Scalar" ? type : undefined; current !== undefined; current = current.baseScalar) {
|
|
1154
|
+
if (isStdDateTime(program, current))
|
|
1155
|
+
return true;
|
|
1156
|
+
}
|
|
1157
|
+
return false;
|
|
1158
|
+
}
|
|
1066
1159
|
export function encodedTypeOf(program, target) {
|
|
1067
1160
|
const encoded = getEncode(program, target);
|
|
1068
|
-
if (encoded === undefined)
|
|
1161
|
+
if (encoded === undefined) {
|
|
1162
|
+
return target.kind === "ModelProperty" && isDateTimeHeader(program, target)
|
|
1163
|
+
? program.checker.getStdType("string")
|
|
1164
|
+
: undefined;
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* **`rfc3339` on a date-time is the form it already has, so it changes nothing.** openapi3's
|
|
1168
|
+
* `mergeFormatAndEncoding` keeps `format: date-time` for it, and the type it reports, `string`,
|
|
1169
|
+
* emitted `z.string()`: the explicit spelling of the default dropped the date-time check the
|
|
1170
|
+
* document still states. Only the encodings that change the wire form - `rfc7231`,
|
|
1171
|
+
* `unixTimestamp` - change the type.
|
|
1172
|
+
*/
|
|
1173
|
+
if (encoded.encoding === "rfc3339" && isDateTime(program, target))
|
|
1069
1174
|
return undefined;
|
|
1070
1175
|
if (target.kind === "ModelProperty" &&
|
|
1071
1176
|
isQueryParam(program, target) &&
|
|
@@ -1091,9 +1196,26 @@ export function propertyToZod(program, property) {
|
|
|
1091
1196
|
// wrapper is kept: `@encode` changes what the value IS, not whether it may be absent.
|
|
1092
1197
|
const encoded = encodedTypeOf(program, property);
|
|
1093
1198
|
const declared = encoded ?? nullable ?? property.type;
|
|
1094
|
-
const
|
|
1095
|
-
|
|
1096
|
-
|
|
1199
|
+
const bare = nullable === undefined || encoded !== undefined;
|
|
1200
|
+
/**
|
|
1201
|
+
* **One description per position, and a property's own doc wins over its scalar's.**
|
|
1202
|
+
*
|
|
1203
|
+
* openapi3 publishes `subject: Subject` as `{ $ref: Subject, description: <the property's doc> }`,
|
|
1204
|
+
* and a reader takes the sibling over what the reference carries. A scalar here is inlined rather
|
|
1205
|
+
* than referenced, so where the property's value IS the scalar, its schema is taken without its
|
|
1206
|
+
* description and the one the position publishes is decided once: the property's doc, else the
|
|
1207
|
+
* scalar's. Otherwise a documented property of a documented scalar was written
|
|
1208
|
+
* `.describe(a).describe(b)`, which says the same thing in a way no one would write.
|
|
1209
|
+
*
|
|
1210
|
+
* Under `.nullable()` the scalar keeps its own, inside the wrapper: openapi3 publishes
|
|
1211
|
+
* `anyOf: [{ $ref: Subject }, { type: "null" }]` there, so the scalar's description belongs to the
|
|
1212
|
+
* branch and only the property's belongs to the property.
|
|
1213
|
+
*/
|
|
1214
|
+
const inlinedScalar = bare && declared.kind === "Scalar";
|
|
1215
|
+
const value = inlinedScalar ? scalarSchemaOf(program, declared) : typeToZod(program, declared);
|
|
1216
|
+
const constrained = applyConstraints(program, value, property);
|
|
1217
|
+
const base = bare ? constrained : `${constrained}.nullable()`;
|
|
1218
|
+
const description = getDoc(program, property) ?? (inlinedScalar ? scalarDocOf(program, declared) : undefined);
|
|
1097
1219
|
/**
|
|
1098
1220
|
* **Optionality is a function of the position too, not only of the `?` in the spec.**
|
|
1099
1221
|
*
|
|
@@ -1104,6 +1226,7 @@ export function propertyToZod(program, property) {
|
|
|
1104
1226
|
* predicate `@typespec/openapi3` uses to decide the `required` list.
|
|
1105
1227
|
*/
|
|
1106
1228
|
const optional = property.optional || isOptionalAt(program, property);
|
|
1229
|
+
reportInheritedName(program, property, optional);
|
|
1107
1230
|
/**
|
|
1108
1231
|
* **A default only applies where the document says the property may be absent.**
|
|
1109
1232
|
*
|
|
@@ -1125,7 +1248,7 @@ export function propertyToZod(program, property) {
|
|
|
1125
1248
|
target: property,
|
|
1126
1249
|
format: { name: property.name },
|
|
1127
1250
|
});
|
|
1128
|
-
return base;
|
|
1251
|
+
return described(base, description);
|
|
1129
1252
|
}
|
|
1130
1253
|
/**
|
|
1131
1254
|
* A TypeSpec default (`basis?: string = "s1159"`) becomes `.default(...)`, which in Zod also makes
|
|
@@ -1136,7 +1259,7 @@ export function propertyToZod(program, property) {
|
|
|
1136
1259
|
*/
|
|
1137
1260
|
const defaulted = defaultOf(program, property);
|
|
1138
1261
|
if (defaulted !== undefined)
|
|
1139
|
-
return `${base}.default(${defaulted})
|
|
1262
|
+
return described(`${base}.default(${defaulted})`, description);
|
|
1140
1263
|
/**
|
|
1141
1264
|
* **`.exactOptional()`, because the document says the KEY may be absent and nothing more.**
|
|
1142
1265
|
*
|
|
@@ -1155,7 +1278,66 @@ export function propertyToZod(program, property) {
|
|
|
1155
1278
|
* differential's serialisation axis is untouched, and `def.type` is still `"optional"`, so the
|
|
1156
1279
|
* shape describers read it unchanged.
|
|
1157
1280
|
*/
|
|
1158
|
-
return optional ? `${base}.exactOptional()` : base;
|
|
1281
|
+
return described(optional ? `${base}.exactOptional()` : base, description);
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* The names every JavaScript object inherits, which Zod reads in place of an absent property.
|
|
1285
|
+
* `__proto__` is among them and is the different case: Zod skips it outright.
|
|
1286
|
+
*/
|
|
1287
|
+
const INHERITED_NAMES = new Set(Object.getOwnPropertyNames(Object.prototype));
|
|
1288
|
+
/** Properties already reported, by declaration: one property renders once per visibility. */
|
|
1289
|
+
const reportedNames = new WeakMap();
|
|
1290
|
+
/**
|
|
1291
|
+
* **A wire name the validator cannot treat as an ordinary key is reported where it bites.** See
|
|
1292
|
+
* `unenforceable-property-name` and `optional-prototype-name` in `lib.ts` for why each is a
|
|
1293
|
+
* warning and what each costs.
|
|
1294
|
+
*
|
|
1295
|
+
* Reported here rather than from a walk over the program because `optional` is a fact about the
|
|
1296
|
+
* POSITION: a PATCH makes a required property optional, and only there does an inherited name
|
|
1297
|
+
* refuse its omission. Once per declaration, by its syntax node, because a visibility projection
|
|
1298
|
+
* hands this a different `ModelProperty` for the same one.
|
|
1299
|
+
*/
|
|
1300
|
+
function reportInheritedName(program, property, optional) {
|
|
1301
|
+
const name = wireNameOf(program, property);
|
|
1302
|
+
if (!INHERITED_NAMES.has(name))
|
|
1303
|
+
return;
|
|
1304
|
+
const unenforceable = name === "__proto__";
|
|
1305
|
+
if (!unenforceable && !optional)
|
|
1306
|
+
return;
|
|
1307
|
+
const declaration = property.node ?? property;
|
|
1308
|
+
let reported = reportedNames.get(program);
|
|
1309
|
+
if (reported === undefined)
|
|
1310
|
+
reportedNames.set(program, (reported = new WeakSet()));
|
|
1311
|
+
if (reported.has(declaration))
|
|
1312
|
+
return;
|
|
1313
|
+
reported.add(declaration);
|
|
1314
|
+
if (unenforceable) {
|
|
1315
|
+
reportDiagnostic(program, {
|
|
1316
|
+
code: "unenforceable-property-name",
|
|
1317
|
+
target: property,
|
|
1318
|
+
format: { name },
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
else {
|
|
1322
|
+
reportDiagnostic(program, {
|
|
1323
|
+
code: "optional-prototype-name",
|
|
1324
|
+
target: property,
|
|
1325
|
+
format: { name },
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
/** The key a property is validated under: its parameter name, or its name in a JSON body. */
|
|
1330
|
+
function wireNameOf(program, property) {
|
|
1331
|
+
if (isHeader(program, property))
|
|
1332
|
+
return getHeaderFieldName(program, property);
|
|
1333
|
+
if (isQueryParam(program, property))
|
|
1334
|
+
return getQueryParamName(program, property) ?? property.name;
|
|
1335
|
+
if (isPathParam(program, property))
|
|
1336
|
+
return getPathParamName(program, property) ?? property.name;
|
|
1337
|
+
if (isCookieParam(program, property)) {
|
|
1338
|
+
return getCookieParamOptions(program, property)?.name ?? property.name;
|
|
1339
|
+
}
|
|
1340
|
+
return rawPropertyKey(program, property);
|
|
1159
1341
|
}
|
|
1160
1342
|
/**
|
|
1161
1343
|
* A default value as a JS literal, or `undefined` where this emitter cannot render one.
|
|
@@ -1180,7 +1362,7 @@ export function propertyToZod(program, property) {
|
|
|
1180
1362
|
*/
|
|
1181
1363
|
function renderValue(value) {
|
|
1182
1364
|
if (value.valueKind === "StringValue")
|
|
1183
|
-
return
|
|
1365
|
+
return literal(value.value);
|
|
1184
1366
|
if (value.valueKind === "NumericValue")
|
|
1185
1367
|
return String(value.value.asNumber());
|
|
1186
1368
|
if (value.valueKind === "BooleanValue")
|
|
@@ -1189,7 +1371,7 @@ function renderValue(value) {
|
|
|
1189
1371
|
return "null";
|
|
1190
1372
|
if (value.valueKind === "EnumValue") {
|
|
1191
1373
|
const member = value.value;
|
|
1192
|
-
return
|
|
1374
|
+
return literal(member.value ?? member.name);
|
|
1193
1375
|
}
|
|
1194
1376
|
if (value.valueKind === "ArrayValue") {
|
|
1195
1377
|
const rendered = value.values.map(renderValue);
|
|
@@ -1203,7 +1385,7 @@ function renderValue(value) {
|
|
|
1203
1385
|
const rendered = renderValue(descriptor.value);
|
|
1204
1386
|
if (rendered === undefined)
|
|
1205
1387
|
return undefined;
|
|
1206
|
-
entries.push(`${
|
|
1388
|
+
entries.push(`${literal(name)}: ${rendered}`);
|
|
1207
1389
|
}
|
|
1208
1390
|
return `{${entries.join(", ")}}`;
|
|
1209
1391
|
}
|
|
@@ -1330,13 +1512,13 @@ export function typeToZodBody(program, type) {
|
|
|
1330
1512
|
case "Union":
|
|
1331
1513
|
return unionToZod(program, type);
|
|
1332
1514
|
case "String":
|
|
1333
|
-
return `z.literal(${
|
|
1515
|
+
return `z.literal(${literal(type.value)})`;
|
|
1334
1516
|
case "Number":
|
|
1335
1517
|
return `z.literal(${type.value})`;
|
|
1336
1518
|
case "Boolean":
|
|
1337
1519
|
return `z.literal(${String(type.value)})`;
|
|
1338
1520
|
case "EnumMember":
|
|
1339
|
-
return `z.literal(${
|
|
1521
|
+
return `z.literal(${literal(type.value ?? type.name)})`;
|
|
1340
1522
|
/**
|
|
1341
1523
|
* **A named union VARIANT is a type you can refer to, and it is not the union.**
|
|
1342
1524
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-http-zod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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",
|