typespec-http-zod 0.30.0 → 0.32.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.
@@ -14,7 +14,27 @@ import { type Program, type Type } from "@typespec/compiler";
14
14
  * publishes. `src/tsp-index.ts` exports `$lib` and no `$decorators`, and `vocabulary.test.ts`
15
15
  * asserts that no emitted file carries a `.refine(` outside the one multipart-file carve-out.
16
16
  */
17
- export declare function applyConstraints(program: Program, expression: string, target: Type): string;
17
+ export declare function applyConstraints(program: Program, expression: string, target: Type, options?: {
18
+ /**
19
+ * The JSON type of the value the expression is, where the caller knows it better - a member of
20
+ * a union, which the target's own type does not say.
21
+ */
22
+ readonly type?: string;
23
+ /** Drop an inapplicable keyword without reporting it: JSON Schema skips it for this member. */
24
+ readonly quiet?: boolean;
25
+ }): string;
26
+ /** Whether a property or scalar declares any constraint this module enforces. */
27
+ export declare function hasConstraints(program: Program, target: Type): boolean;
28
+ /**
29
+ * The constraint of a property or scalar that a string value breaks - `@maxLength(3)` - or
30
+ * `undefined` where it keeps every one. Read as JSON Schema reads them: lengths in code points, and a
31
+ * pattern that matches anywhere in the value unless it anchors itself.
32
+ */
33
+ export declare function brokenConstraintOf(program: Program, target: Type, value: string | number | boolean): string | undefined;
34
+ /** The `@minItems` or `@maxItems` of a property or a named list model that a list's length breaks. */
35
+ export declare function brokenItemsOf(program: Program, target: Type, count: number): string | undefined;
36
+ /** `unenforceable-encoded-constraint`, once per declaration however many positions apply it. */
37
+ export declare function reportUnenforceableEncoded(program: Program, target: Type, keywords: readonly string[], type: string): void;
18
38
  /**
19
39
  * **Whether this diagnostic has already been raised against this declaration.**
20
40
  *
@@ -28,8 +48,17 @@ export declare function applyConstraints(program: Program, expression: string, t
28
48
  * The de-duplication itself is per declaration, because emission reaches a scalar once per USE -
29
49
  * 153 times for a single scalar in the reference service - and repeating one warning that often
30
50
  * is noise a reader learns to skip, which is how a real one gets missed.
51
+ *
52
+ * **A declaration is its SOURCE, not the type object the walk holds.** The compiler hands out
53
+ * distinct objects for one declaration: `model B is A` copies `A`'s properties, a spread copies the
54
+ * spread model's, and each versioning projection clones the whole service - once per emitter that
55
+ * asks for it. Keyed on the object, one `@encode` was reported twice through `model B is A`, and
56
+ * every diagnostic twice when `typespec-hono` and this emitter projected one versioned service in one
57
+ * compile. The syntax node is shared by every copy, so it is the key where there is one. `detail`
58
+ * keeps two different findings at one node apart - a template property instantiated with two
59
+ * different types is one node and two declarations.
31
60
  */
32
- export declare function firstReportOf(program: Program, code: string, target: Type): boolean;
61
+ export declare function firstReportOf(program: Program, code: string, target: Type, detail?: string): boolean;
33
62
  /**
34
63
  * **Whether a `@pattern` can be proven safe against catastrophic backtracking.**
35
64
  *
@@ -1,7 +1,8 @@
1
- import { getEncode, getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
1
+ import { getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, getMaxValueForScalar, getMinValueForScalar, serializeValueAsJson, } from "@typespec/compiler";
2
2
  import { defaultMaxSteps, isSafePattern } from "redos-detector";
3
3
  import { reportDiagnostic } from "./lib.js";
4
4
  import { escapeNonAscii } from "./literal.js";
5
+ import { encodedValueOf, isArrayLiteral, jsonTypeOfScalar, scalarEncodingOf } from "./zod.js";
5
6
  /**
6
7
  * Constraint decorators -> the Zod modifiers that enforce them.
7
8
  *
@@ -17,63 +18,243 @@ import { escapeNonAscii } from "./literal.js";
17
18
  * publishes. `src/tsp-index.ts` exports `$lib` and no `$decorators`, and `vocabulary.test.ts`
18
19
  * asserts that no emitted file carries a `.refine(` outside the one multipart-file carve-out.
19
20
  */
20
- export function applyConstraints(program, expression, target) {
21
+ export function applyConstraints(program, expression, target, options = {}) {
22
+ // A refused value carries no method to constrain, and needs none: it accepts nothing already.
23
+ if (expression.startsWith("z.never()"))
24
+ return expression;
21
25
  let result = expression;
22
26
  const read = (get) => declaredOn(program, target, get);
23
27
  const minLength = read(getMinLength);
24
28
  const maxLength = read(getMaxLength);
25
- const minValue = read(getMinValue);
26
- const maxValue = read(getMaxValue);
29
+ const minValue = read(minimumOf);
30
+ const maxValue = read(maximumOf);
27
31
  const minItems = read(getMinItems);
28
32
  const maxItems = read(getMaxItems);
29
33
  const pattern = read(getPattern);
30
34
  const minExclusive = read(getMinValueExclusive);
31
35
  const maxExclusive = read(getMaxValueExclusive);
32
36
  /**
33
- * **A numeric bound says nothing about a value carried as text.** `@encode(string)` is legal on a
34
- * numeric property and makes the wire value a string, so the expression here is `z.string()` while
35
- * `@minValue` is still a statement about the number it spells. Folding the two together emitted a
36
- * LENGTH check: `@minValue(1)` accepted `"0"`, and `@minValue(100)` refused `"150"`. The exclusive
37
- * form was worse - `z.string().gt(0)` is not a function, so the module threw on import and took
38
- * the whole service down with it.
37
+ * **A keyword applies to one JSON type, and `@encode` can change the type under it.**
39
38
  *
40
- * It cannot be enforced either, and not for want of a mechanism: the document publishes `minimum`
41
- * on a `type: string` schema, which JSON Schema ignores, so a validator that checked it would
42
- * refuse payloads the published contract accepts. That is the same defect in the other direction,
43
- * and this package's rule is that the two artefacts agree. So the bound is dropped and reported.
39
+ * openapi3 applies a property's or a scalar's constraints and THEN lets the encoding replace
40
+ * `type`, so `@encode(string)` on a numeric property publishes `minimum` beside `type: string`,
41
+ * and `@encode("x", int32)` on a string publishes `pattern` beside `type: integer`. JSON Schema
42
+ * applies `minimum` to numbers, `minLength` and `pattern` to strings and `minItems` to arrays, and
43
+ * ignores each everywhere else - so the document asserts none of them there.
44
+ *
45
+ * Applied anyway, `@minValue(1)` on a string emitted a LENGTH check that accepted `"0"`, and
46
+ * `.gt()`, `.regex()` on an integer or `.min()` on a boolean is not a function at all: the module
47
+ * threw on import and took the whole service down. So each keyword the encoded value cannot carry
48
+ * is dropped, as the document drops it, and reported, because the author wrote a rule nothing
49
+ * enforces.
44
50
  */
45
- const encodedAsText = encodesAsString(program, target);
46
- const numeric = [minValue, maxValue, minExclusive, maxExclusive].some((bound) => bound !== undefined);
47
- if (encodedAsText && numeric) {
48
- reportDiagnostic(program, {
49
- code: "unenforceable-encoded-bound",
50
- format: {
51
- name: target.kind === "ModelProperty" || target.kind === "Scalar" ? target.name : "",
52
- },
53
- target,
54
- });
51
+ const type = options.type ?? valueJsonTypeOf(program, target);
52
+ const dropped = [];
53
+ const applies = (keyword, value, family) => {
54
+ if (value === undefined)
55
+ return false;
56
+ if (type === undefined || family.includes(type))
57
+ return true;
58
+ dropped.push(keyword);
59
+ return false;
60
+ };
61
+ const TEXT = ["string"];
62
+ const NUMBER = ["integer", "number"];
63
+ const LIST = ["array"];
64
+ const keep = {
65
+ minLength: applies("@minLength", minLength, TEXT),
66
+ maxLength: applies("@maxLength", maxLength, TEXT),
67
+ pattern: applies("@pattern", pattern, TEXT),
68
+ minValue: applies("@minValue", minValue, NUMBER),
69
+ maxValue: applies("@maxValue", maxValue, NUMBER),
70
+ minExclusive: applies("@minValueExclusive", minExclusive, NUMBER),
71
+ maxExclusive: applies("@maxValueExclusive", maxExclusive, NUMBER),
72
+ minItems: applies("@minItems", minItems, LIST),
73
+ maxItems: applies("@maxItems", maxItems, LIST),
74
+ };
75
+ if (dropped.length > 0 && type !== undefined && options.quiet !== true) {
76
+ reportUnenforceableEncoded(program, target, dropped, type);
55
77
  }
56
78
  // `@minLength`/`@minValue`/`@minItems` all land on Zod's `.min()`; they are distinguished by the
57
- // type they are legal on, which TypeSpec has already checked by the time we get here - except
58
- // under `@encode(string)`, where the type they are legal on is not the type emitted.
59
- const min = minLength ?? (encodedAsText ? undefined : minValue) ?? minItems;
60
- const max = maxLength ?? (encodedAsText ? undefined : maxValue) ?? maxItems;
79
+ // JSON type they apply to, and at most one of them applies to any one value.
80
+ const min = keep.minLength
81
+ ? minLength
82
+ : keep.minValue
83
+ ? minValue
84
+ : keep.minItems
85
+ ? minItems
86
+ : undefined;
87
+ const max = keep.maxLength
88
+ ? maxLength
89
+ : keep.maxValue
90
+ ? maxValue
91
+ : keep.maxItems
92
+ ? maxItems
93
+ : undefined;
61
94
  if (min !== undefined)
62
95
  result += `.min(${min})`;
63
96
  if (max !== undefined)
64
97
  result += `.max(${max})`;
65
98
  // Exclusive bounds are a distinct question from inclusive ones - `z.number().positive()` is
66
99
  // `.gt(0)`, and emitting `.min(0)` for it would accept a zero share count or a zero spot rate.
67
- if (!encodedAsText && minExclusive !== undefined)
100
+ if (keep.minExclusive)
68
101
  result += `.gt(${minExclusive})`;
69
- if (!encodedAsText && maxExclusive !== undefined)
102
+ if (keep.maxExclusive)
70
103
  result += `.lt(${maxExclusive})`;
71
- if (pattern !== undefined) {
104
+ if (keep.pattern && pattern !== undefined) {
72
105
  reportNonUnicodePattern(program, pattern, target);
73
106
  result += `.regex(${patternToRegex(pattern)})`;
74
107
  }
75
108
  return result;
76
109
  }
110
+ /** Whether a property or scalar declares any constraint this module enforces. */
111
+ export function hasConstraints(program, target) {
112
+ if (isListLiteral(program, target))
113
+ return false;
114
+ return [
115
+ getMinLength,
116
+ getMaxLength,
117
+ getMinValue,
118
+ getMaxValue,
119
+ getMinValueExclusive,
120
+ getMaxValueExclusive,
121
+ getMinItems,
122
+ getMaxItems,
123
+ getPattern,
124
+ getMinValueForScalar,
125
+ getMaxValueForScalar,
126
+ ].some((get) => get(program, target) !== undefined);
127
+ }
128
+ /**
129
+ * The constraint of a property or scalar that a string value breaks - `@maxLength(3)` - or
130
+ * `undefined` where it keeps every one. Read as JSON Schema reads them: lengths in code points, and a
131
+ * pattern that matches anywhere in the value unless it anchors itself.
132
+ */
133
+ export function brokenConstraintOf(program, target, value) {
134
+ const read = (get) => declaredOn(program, target, get);
135
+ if (typeof value === "boolean")
136
+ return undefined;
137
+ if (typeof value === "number") {
138
+ const minimum = read(minimumOf);
139
+ if (minimum !== undefined && value < minimum)
140
+ return `'@minValue(${minimum})'`;
141
+ const maximum = read(maximumOf);
142
+ if (maximum !== undefined && value > maximum)
143
+ return `'@maxValue(${maximum})'`;
144
+ const above = read(getMinValueExclusive);
145
+ if (above !== undefined && value <= above)
146
+ return `'@minValueExclusive(${above})'`;
147
+ const below = read(getMaxValueExclusive);
148
+ if (below !== undefined && value >= below)
149
+ return `'@maxValueExclusive(${below})'`;
150
+ return undefined;
151
+ }
152
+ const length = [...value].length;
153
+ const minLength = read(getMinLength);
154
+ if (minLength !== undefined && length < minLength)
155
+ return `'@minLength(${minLength})'`;
156
+ const maxLength = read(getMaxLength);
157
+ if (maxLength !== undefined && length > maxLength)
158
+ return `'@maxLength(${maxLength})'`;
159
+ const pattern = read(getPattern);
160
+ if (pattern !== undefined) {
161
+ let expression;
162
+ try {
163
+ expression = new RegExp(pattern, "u");
164
+ }
165
+ catch {
166
+ expression = new RegExp(pattern);
167
+ }
168
+ if (!expression.test(value))
169
+ return `'@pattern'`;
170
+ }
171
+ return undefined;
172
+ }
173
+ /** The `@minItems` or `@maxItems` of a property or a named list model that a list's length breaks. */
174
+ export function brokenItemsOf(program, target, count) {
175
+ const minItems = declaredOn(program, target, getMinItems);
176
+ if (minItems !== undefined && count < minItems)
177
+ return `'@minItems(${minItems})'`;
178
+ const maxItems = declaredOn(program, target, getMaxItems);
179
+ if (maxItems !== undefined && count > maxItems)
180
+ return `'@maxItems(${maxItems})'`;
181
+ return undefined;
182
+ }
183
+ /**
184
+ * **`@minValue` as the number the document publishes**: a numeric bound as written, and a bound on a
185
+ * date or duration as the value its `@encode` puts on the wire - `@minValue(utcDateTime.fromISO(...))`
186
+ * under `unixTimestamp` is published as `minimum: 1577836800`. This is openapi3's own
187
+ * `getMinValueAsJson`, which publishes nothing where the value is not a number, and so neither does
188
+ * this. Read through `getMinValue` alone, the date bound was dropped while the document enforced it.
189
+ */
190
+ function minimumOf(program, target) {
191
+ const numeric = getMinValue(program, target);
192
+ if (numeric !== undefined)
193
+ return numeric;
194
+ const value = getMinValueForScalar(program, target);
195
+ if (value === undefined)
196
+ return undefined;
197
+ const json = serializeValueAsJson(program, value, target);
198
+ return typeof json === "number" ? json : undefined;
199
+ }
200
+ /** `@maxValue` as the number the document publishes - see {@link minimumOf}. */
201
+ function maximumOf(program, target) {
202
+ const numeric = getMaxValue(program, target);
203
+ if (numeric !== undefined)
204
+ return numeric;
205
+ const value = getMaxValueForScalar(program, target);
206
+ if (value === undefined)
207
+ return undefined;
208
+ const json = serializeValueAsJson(program, value, target);
209
+ return typeof json === "number" ? json : undefined;
210
+ }
211
+ /**
212
+ * The JSON type of the value a property or scalar's constraints are applied to, once `@encode` has
213
+ * had its say - or `undefined` where nothing encodes it, and TypeSpec has already checked that each
214
+ * keyword is legal on the type.
215
+ */
216
+ function valueJsonTypeOf(program, target) {
217
+ if (target.kind === "Scalar") {
218
+ return scalarEncodingOf(program, target) === undefined
219
+ ? undefined
220
+ : jsonTypeOfScalar(program, target);
221
+ }
222
+ if (target.kind !== "ModelProperty")
223
+ return undefined;
224
+ const encoding = encodedValueOf(program, target);
225
+ if (encoding.kind === "replaced" || encoding.kind === "both") {
226
+ return jsonTypeOfScalar(program, encoding.scalar);
227
+ }
228
+ if (encoding.kind === "unsatisfiable")
229
+ return undefined;
230
+ const declared = nullableInnerOfType(target.type) ?? target.type;
231
+ return declared.kind === "Scalar" && scalarEncodingOf(program, declared) !== undefined
232
+ ? jsonTypeOfScalar(program, declared)
233
+ : undefined;
234
+ }
235
+ /** The non-null half of a two-variant `T | null` union. */
236
+ function nullableInnerOfType(type) {
237
+ if (type.kind !== "Union")
238
+ return undefined;
239
+ const variants = [...type.variants.values()].map((variant) => variant.type);
240
+ const real = variants.filter((v) => !(v.kind === "Intrinsic" && v.name === "null"));
241
+ return real.length === variants.length - 1 && real.length === 1 ? real[0] : undefined;
242
+ }
243
+ /** `unenforceable-encoded-constraint`, once per declaration however many positions apply it. */
244
+ export function reportUnenforceableEncoded(program, target, keywords, type) {
245
+ const detail = `${keywords.join(",")} ${type}`;
246
+ if (!firstReportOf(program, "unenforceable-encoded-constraint", target, detail))
247
+ return;
248
+ reportDiagnostic(program, {
249
+ code: "unenforceable-encoded-constraint",
250
+ target,
251
+ format: {
252
+ name: target.kind === "ModelProperty" || target.kind === "Scalar" ? target.name : "",
253
+ keywords: keywords.map((keyword) => `'${keyword}'`).join(", "),
254
+ type,
255
+ },
256
+ });
257
+ }
77
258
  /**
78
259
  * **A constraint on a scalar is read up its `extends` chain: the nearest scalar declaring it wins.**
79
260
  *
@@ -89,6 +270,8 @@ export function applyConstraints(program, expression, target) {
89
270
  * A property is not a chain: its type's constraints are applied where the type is rendered.
90
271
  */
91
272
  function declaredOn(program, target, get) {
273
+ if (isListLiteral(program, target))
274
+ return undefined;
92
275
  if (target.kind !== "Scalar")
93
276
  return get(program, target);
94
277
  for (let current = target; current !== undefined; current = current.baseScalar) {
@@ -99,18 +282,16 @@ function declaredOn(program, target, get) {
99
282
  return undefined;
100
283
  }
101
284
  /**
102
- * Whether the value this type puts on the wire is text because `@encode` says so. Read from the
103
- * property and then from its scalar, which is where an encoding declared once for a named scalar
104
- * lives.
285
+ * **A list openapi3 takes for a literal publishes none of its own keywords**, so none is enforced. A
286
+ * list model the spec names `Array` is one - see `isArrayLiteral` - and openapi3 writes it out where
287
+ * it is used as `{ type: "array", items }` and nothing else, its `@minItems`, `@maxItems`, `@doc` and
288
+ * `@summary` dropped. Measured on `@minItems(1) model Array is int32[]` as `a?: Array`: the document
289
+ * accepts `{ a: [] }`, and the validator, `z.array(z.int32()).min(1)`, refused it - in a body, a
290
+ * response, a query and a header alike. A keyword on the property holding it is the property's own,
291
+ * and is published.
105
292
  */
106
- function encodesAsString(program, target) {
107
- if (target.kind !== "ModelProperty" && target.kind !== "Scalar")
108
- return false;
109
- const own = getEncode(program, target);
110
- const scalar = own === undefined && target.kind === "ModelProperty" && target.type.kind === "Scalar"
111
- ? getEncode(program, target.type)
112
- : undefined;
113
- return (own ?? scalar)?.type.name === "string";
293
+ function isListLiteral(program, target) {
294
+ return target.kind === "Model" && isArrayLiteral(program, target);
114
295
  }
115
296
  /**
116
297
  * How much analysis one pattern gets before the answer is "could not prove it": a number of steps.
@@ -159,8 +340,17 @@ const reported = new WeakMap();
159
340
  * The de-duplication itself is per declaration, because emission reaches a scalar once per USE -
160
341
  * 153 times for a single scalar in the reference service - and repeating one warning that often
161
342
  * is noise a reader learns to skip, which is how a real one gets missed.
343
+ *
344
+ * **A declaration is its SOURCE, not the type object the walk holds.** The compiler hands out
345
+ * distinct objects for one declaration: `model B is A` copies `A`'s properties, a spread copies the
346
+ * spread model's, and each versioning projection clones the whole service - once per emitter that
347
+ * asks for it. Keyed on the object, one `@encode` was reported twice through `model B is A`, and
348
+ * every diagnostic twice when `typespec-hono` and this emitter projected one versioned service in one
349
+ * compile. The syntax node is shared by every copy, so it is the key where there is one. `detail`
350
+ * keeps two different findings at one node apart - a template property instantiated with two
351
+ * different types is one node and two declarations.
162
352
  */
163
- export function firstReportOf(program, code, target) {
353
+ export function firstReportOf(program, code, target, detail = "") {
164
354
  let byCode = reported.get(program);
165
355
  if (byCode === undefined) {
166
356
  byCode = new Map();
@@ -168,12 +358,18 @@ export function firstReportOf(program, code, target) {
168
358
  }
169
359
  let already = byCode.get(code);
170
360
  if (already === undefined) {
171
- already = new WeakSet();
361
+ already = new WeakMap();
172
362
  byCode.set(code, already);
173
363
  }
174
- if (already.has(target))
364
+ const source = target.node ?? target;
365
+ let details = already.get(source);
366
+ if (details === undefined) {
367
+ details = new Set();
368
+ already.set(source, details);
369
+ }
370
+ if (details.has(detail))
175
371
  return false;
176
- already.add(target);
372
+ details.add(detail);
177
373
  return true;
178
374
  }
179
375
  /**