typespec-http-zod 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/dist/src/api.d.ts +55 -0
- package/dist/src/api.js +1602 -369
- package/dist/src/constraints.d.ts +45 -1
- package/dist/src/constraints.js +242 -46
- package/dist/src/lib.d.ts +148 -7
- package/dist/src/lib.js +193 -6
- package/dist/src/published.d.ts +139 -0
- package/dist/src/published.js +798 -0
- package/dist/src/registry.d.ts +77 -45
- package/dist/src/registry.js +759 -198
- package/dist/src/types.d.ts +12 -2
- package/dist/src/types.js +276 -78
- package/dist/src/uri-template.d.ts +110 -8
- package/dist/src/uri-template.js +251 -46
- package/dist/src/zod.d.ts +732 -12
- package/dist/src/zod.js +4042 -411
- package/package.json +1 -1
package/dist/src/zod.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
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
|
-
import {
|
|
1
|
+
import { getDiscriminatedUnion, getDiscriminatedUnionFromInheritance, getDiscriminator, getDoc, getEncode, getTypeName, ignoreDiagnostics, resolveEncodedName, serializeValueAsJson, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
|
|
2
|
+
import { createMetadataInfo, getCookieParamOptions, getHeaderFieldName, getVisibilitySuffix, getPathParamName, getQueryParamName, isCookieParam, isHeader, isPathParam, isQueryParam, Visibility, } from "@typespec/http";
|
|
3
|
+
import { $ } from "@typespec/compiler/typekit";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { isReadonlyProperty, shouldInline } from "@typespec/openapi";
|
|
6
|
+
import { applyConstraints, firstReportOf, brokenConstraintOf, brokenItemsOf, hasConstraints, } from "./constraints.js";
|
|
4
7
|
import { literal } from "./literal.js";
|
|
8
|
+
import { isPublishedSplit, isSharedRouteOptional, isSuffixedByUsage, keptVisibilityOf, readVisibilityOf, } from "./published.js";
|
|
5
9
|
import { reportDiagnostic } from "./lib.js";
|
|
6
10
|
/**
|
|
7
11
|
* What a walk returns after reporting a refusal.
|
|
@@ -22,14 +26,19 @@ export const UNREPRESENTABLE = "z.never()";
|
|
|
22
26
|
* in rather than computed here.
|
|
23
27
|
*/
|
|
24
28
|
let annotateDeferred = () => undefined;
|
|
25
|
-
|
|
29
|
+
/** The same, for a value no property declares: an envelope's `value`, typed as its variant. */
|
|
30
|
+
let annotateDeferredType = () => undefined;
|
|
31
|
+
export function withDeferredAnnotator(annotator, typeAnnotator, run) {
|
|
26
32
|
const previous = annotateDeferred;
|
|
33
|
+
const previousType = annotateDeferredType;
|
|
27
34
|
annotateDeferred = annotator;
|
|
35
|
+
annotateDeferredType = typeAnnotator;
|
|
28
36
|
try {
|
|
29
37
|
return run();
|
|
30
38
|
}
|
|
31
39
|
finally {
|
|
32
40
|
annotateDeferred = previous;
|
|
41
|
+
annotateDeferredType = previousType;
|
|
33
42
|
}
|
|
34
43
|
}
|
|
35
44
|
/**
|
|
@@ -127,52 +136,255 @@ export function withSealedObjects(sealed, run) {
|
|
|
127
136
|
* from answering differently. Measured on `type/model/visibility`: openapi3 emits five components
|
|
128
137
|
* for one model, and before this we emitted one carrying every lifecycle property at once.
|
|
129
138
|
*
|
|
130
|
-
* **Position is a SECOND axis
|
|
131
|
-
*
|
|
132
|
-
* argument
|
|
133
|
-
* (`schema-emitter.js:210`) and this does not pass. A metadata-carrying model used in **both**
|
|
134
|
-
* positions therefore still loses the header property where the document keeps it.
|
|
135
|
-
*
|
|
136
|
-
* That shape is rare rather than theoretical: it raises `metadata-ignored` from `@typespec/http`,
|
|
137
|
-
* which fires **zero** times across all 65 `@typespec/http-specs` scenarios,
|
|
138
|
-
* and one variant of it makes openapi3 itself fail with `duplicate-type-name`. It is a real gap with
|
|
139
|
-
* no known occurrence - recorded here rather than described as an incident, because it has not been
|
|
140
|
-
* one.
|
|
139
|
+
* **Position is a SECOND axis**: `@header` is metadata at a payload root and ordinary data inside
|
|
140
|
+
* an explicit `@body` - see {@link withMetadataAnnotationsIgnored}, which carries openapi3's third
|
|
141
|
+
* argument to `isPayloadProperty`.
|
|
141
142
|
*
|
|
142
143
|
* Scoped rather than threaded through six signatures, matching the idiom the rest of this module
|
|
143
144
|
* already uses - the walk is deeply recursive and a parameter would have to be carried by every
|
|
144
145
|
* function whether it cared or not.
|
|
145
146
|
*/
|
|
146
147
|
let visibility = Visibility.Read;
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
/**
|
|
149
|
+
* **One metadata view per program**, because the compiler's state lives on the program. Kept in one
|
|
150
|
+
* module variable, a view built for one compile was read by the next one in the same process -
|
|
151
|
+
* whatever first asked outside a walk left it there - and that program's `@visibility` and `@header`
|
|
152
|
+
* were invisible to it: a create-only or `@header` declaration counted as payload in every compile
|
|
153
|
+
* after the first.
|
|
154
|
+
*/
|
|
155
|
+
const metadataInfos = new WeakMap();
|
|
156
|
+
/**
|
|
157
|
+
* **openapi3's own metadata view: `Read` is canonical, and a read-only property is shareable.**
|
|
158
|
+
*
|
|
159
|
+
* Built with `@typespec/http`'s defaults, the canonical visibility was `All`, so a model whose
|
|
160
|
+
* create-only property is visible at `All` was not transformed at `Create`: its create body
|
|
161
|
+
* collapsed onto the bare declaration, and every response was then validated as the CREATE view -
|
|
162
|
+
* `{name}` refused for want of the `secret` no response carries. And a read-only `id` made a model
|
|
163
|
+
* transformed at `Create`, where openapi3 shares the canonical schema, so the create body was
|
|
164
|
+
* validated without the `id` the document requires. Both are openapi3's arguments in
|
|
165
|
+
* `openapi.js`, passed as it passes them.
|
|
166
|
+
*/
|
|
167
|
+
export function metadataInfoOf(program) {
|
|
168
|
+
let info = metadataInfos.get(program);
|
|
169
|
+
if (info === undefined) {
|
|
170
|
+
info = createMetadataInfo(program, {
|
|
171
|
+
canonicalVisibility: Visibility.Read,
|
|
172
|
+
canShareProperty: (property) => isReadonlyProperty(program, property),
|
|
173
|
+
});
|
|
174
|
+
metadataInfos.set(program, info);
|
|
175
|
+
}
|
|
176
|
+
return info;
|
|
177
|
+
}
|
|
178
|
+
export function withVisibility(next, run) {
|
|
149
179
|
const previousVisibility = visibility;
|
|
150
|
-
const previousInfo = metadataInfo;
|
|
151
180
|
visibility = next;
|
|
152
|
-
metadataInfo ??= createMetadataInfo(program);
|
|
153
181
|
try {
|
|
154
182
|
return run();
|
|
155
183
|
}
|
|
156
184
|
finally {
|
|
157
185
|
visibility = previousVisibility;
|
|
158
|
-
metadataInfo = previousInfo;
|
|
159
186
|
}
|
|
160
187
|
}
|
|
161
188
|
/** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */
|
|
162
189
|
export function isOptionalAt(program, property) {
|
|
163
|
-
|
|
164
|
-
return metadataInfo.isOptional(property, visibility);
|
|
190
|
+
return (metadataInfoOf(program).isOptional(property, visibility) || isSharedRouteOptional(property));
|
|
165
191
|
}
|
|
166
192
|
/** Whether a property belongs in the BODY at the current position, rather than being metadata. */
|
|
167
193
|
export function isPayloadProperty(program, property) {
|
|
168
|
-
|
|
169
|
-
|
|
194
|
+
return metadataInfoOf(program).isPayloadProperty(property, visibility, metadataIgnored);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* **Whether the walk is inside an explicit `@body` whose type carries metadata annotations.**
|
|
198
|
+
*
|
|
199
|
+
* `@header` is metadata at a payload root and ordinary data inside a `@body`: `@typespec/http`
|
|
200
|
+
* ignores the annotation there, and openapi3 renders such a body - and every type under it - with
|
|
201
|
+
* `ignoreMetadataAnnotations`, so `@header("x-code") @maxLength(3) code` is published as a body
|
|
202
|
+
* property with its constraint. Not passed, the property was dropped from the body the document
|
|
203
|
+
* requires it in; and a model's `@header` read as data everywhere enforced its `@maxLength` on a
|
|
204
|
+
* response that never carries it. Both are openapi3's one rule: `body.isExplicit &&
|
|
205
|
+
* body.containsMetadataAnnotations`.
|
|
206
|
+
*/
|
|
207
|
+
let metadataIgnored = false;
|
|
208
|
+
export function withMetadataAnnotationsIgnored(ignored, run) {
|
|
209
|
+
const previous = metadataIgnored;
|
|
210
|
+
metadataIgnored = ignored;
|
|
211
|
+
try {
|
|
212
|
+
return run();
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
metadataIgnored = previous;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* **Whether the walk is inside a multipart part whose union holds raw `bytes`**: openapi3 renders
|
|
220
|
+
* such a part in the form's own content type, where a `bytes` member of a union is the raw binary
|
|
221
|
+
* `{}` - any value - rather than a base64 string. `HttpPart<bytes | string>` is `anyOf: [{}, string]`,
|
|
222
|
+
* and validated with `bytes` as a string it refused every file the document accepts. Keyed on by the
|
|
223
|
+
* registries, as a named union published there is a schema of its own.
|
|
224
|
+
*/
|
|
225
|
+
let multipartBinary = false;
|
|
226
|
+
export function withMultipartBinary(binary, run) {
|
|
227
|
+
const previous = multipartBinary;
|
|
228
|
+
multipartBinary = binary;
|
|
229
|
+
try {
|
|
230
|
+
return run();
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
multipartBinary = previous;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export function multipartBinaryContext() {
|
|
237
|
+
return multipartBinary;
|
|
238
|
+
}
|
|
239
|
+
/** `bytes` kept raw, as openapi3's `isBytesKeptRaw` reads it: the standard scalar, not encoded. */
|
|
240
|
+
export function isRawBytes(program, type) {
|
|
241
|
+
return type.kind === "Scalar" && type.name === "bytes" && getEncode(program, type) === undefined;
|
|
242
|
+
}
|
|
243
|
+
/** A union one of whose own members is raw `bytes` - see `withMultipartBinary`. */
|
|
244
|
+
export function holdsRawBytes(program, type) {
|
|
245
|
+
return (type.kind === "Union" &&
|
|
246
|
+
[...type.variants.values()].some((variant) => isRawBytes(program, variant.type)));
|
|
247
|
+
}
|
|
248
|
+
/** Whether metadata annotations are ignored where the walk is - the registry keys declarations on it. */
|
|
249
|
+
export function metadataAnnotationsIgnored() {
|
|
250
|
+
return metadataIgnored;
|
|
251
|
+
}
|
|
252
|
+
/** Whether this model or union is shaped differently at `visibility` than at the canonical `Read`. */
|
|
253
|
+
export function isTransformedBy(program, type, at) {
|
|
254
|
+
return metadataInfoOf(program).isTransformed(type, at);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* **The visibility a type is rendered at where another type refers to it: openapi3's
|
|
258
|
+
* `reduceContext`.** A type that is not transformed at the requested visibility renders at `Read`, so
|
|
259
|
+
* a model with nothing create-specific inside a create body IS the canonical schema - a read-only
|
|
260
|
+
* property shared into it, as `canShareProperty` says. Rendered at the requested visibility under the
|
|
261
|
+
* canonical name, the body dropped that property while the document requires it.
|
|
262
|
+
*
|
|
263
|
+
* **Only where openapi3 reduces.** Its asset emitter asks for a reference context by what it emits:
|
|
264
|
+
* a named model, a model expression, a template instance, a named union, a scalar or an enum. A list -
|
|
265
|
+
* `T[]` or `model Tags is Tag[]` - and a union expression or instance have none, so they keep the
|
|
266
|
+
* visibility they were reached at, and so does everything inside them. Reduced here as well, a
|
|
267
|
+
* `model Tags is Tag[]` sent in a create body and returned was one declaration where the document
|
|
268
|
+
* publishes `TagsCreate` and `Tags`, and a model expression inside a union expression in a create
|
|
269
|
+
* body was judged at `Read`, where openapi3 reads its properties at `Create`.
|
|
270
|
+
*/
|
|
271
|
+
export function renderedVisibilityOf(program, type, requested) {
|
|
272
|
+
if (requested === Visibility.Read)
|
|
273
|
+
return Visibility.Read;
|
|
274
|
+
switch (type.kind) {
|
|
275
|
+
// A reference to a variant, `U.a`, is its type's - see `publicationOf`.
|
|
276
|
+
case "UnionVariant":
|
|
277
|
+
return renderedVisibilityOf(program, type.type, requested);
|
|
278
|
+
case "Model":
|
|
279
|
+
if (isArrayLiteral(program, type) || isNamedList(type))
|
|
280
|
+
return requested;
|
|
281
|
+
return isTransformedBy(program, type, requested) ? requested : Visibility.Read;
|
|
282
|
+
case "Union":
|
|
283
|
+
if (type.name === undefined || type.templateMapper !== undefined)
|
|
284
|
+
return requested;
|
|
285
|
+
return isTransformedBy(program, type, requested) ? requested : Visibility.Read;
|
|
286
|
+
// A tuple is written out where it is used, each element at the visibility around it.
|
|
287
|
+
case "Tuple":
|
|
288
|
+
return requested;
|
|
289
|
+
default:
|
|
290
|
+
return Visibility.Read;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* **The visibility an operation's body, parameter or response is rendered at: openapi3's
|
|
295
|
+
* `emitTypeWithSchemaEmitter`**, which renders a type at `Read` unless it is transformed at the
|
|
296
|
+
* requested visibility - whatever kind of type it is, a list included.
|
|
297
|
+
*/
|
|
298
|
+
export function rootVisibilityOf(program, type, requested) {
|
|
299
|
+
if (type.kind === "UnionVariant")
|
|
300
|
+
return rootVisibilityOf(program, type.type, requested);
|
|
301
|
+
return requested !== Visibility.Read &&
|
|
302
|
+
(type.kind === "Model" || type.kind === "Union") &&
|
|
303
|
+
isTransformedBy(program, type, requested)
|
|
304
|
+
? requested
|
|
305
|
+
: Visibility.Read;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* **Whether openapi3 can name a template instance**: its asset emitter's `declarationName`, which
|
|
309
|
+
* spells each argument's own name after the template's and gives up where one has none - a model
|
|
310
|
+
* expression, a literal, an instance of no nameable arguments. Such an instance is written out where
|
|
311
|
+
* it is used: `Maybe<{ x: string }>` is no component, and declared as `Maybe` it was one name for two
|
|
312
|
+
* schemas wherever a second argument appeared.
|
|
313
|
+
*/
|
|
314
|
+
export function isSpeakableInstance(type) {
|
|
315
|
+
if (!("templateMapper" in type) || type.templateMapper === undefined)
|
|
316
|
+
return true;
|
|
317
|
+
return type.templateMapper.args.every((argument) => {
|
|
318
|
+
const each = "entityKind" in argument && argument.entityKind === "Indeterminate"
|
|
319
|
+
? argument.type
|
|
320
|
+
: argument;
|
|
321
|
+
if (!("kind" in each))
|
|
322
|
+
return false;
|
|
323
|
+
switch (each.kind) {
|
|
324
|
+
case "Model":
|
|
325
|
+
case "Scalar":
|
|
326
|
+
case "Interface":
|
|
327
|
+
case "Operation":
|
|
328
|
+
case "Enum":
|
|
329
|
+
case "Union":
|
|
330
|
+
case "Intrinsic":
|
|
331
|
+
return typeof each.name === "string" && each.name !== "" && isSpeakableInstance(each);
|
|
332
|
+
default:
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/** A named list model, `model Tags is Tag[]`, which openapi3 emits as an `arrayDeclaration`. */
|
|
338
|
+
export function isNamedList(type) {
|
|
339
|
+
return (type.name !== "" && type.templateMapper === undefined && type.indexer?.key.name === "integer");
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* **An array literal's elements are rendered at `Item` visibility**, as openapi3's
|
|
343
|
+
* `arrayLiteralReferenceContext` renders them: PATCH leaves a list's items required, and metadata on
|
|
344
|
+
* an element is data, since no element carries a header. Read at the list's own visibility, a PATCH
|
|
345
|
+
* made every item's properties optional, a `@header` on an element dropped out of the list the
|
|
346
|
+
* document requires it in, and one such model in a request list and a response list was two shapes
|
|
347
|
+
* under one name - `TaggedCreateItem` and `TaggedItem` in the document. A named list model is a
|
|
348
|
+
* declaration, rendered at its own visibility.
|
|
349
|
+
*/
|
|
350
|
+
export function atItem(program, list, run) {
|
|
351
|
+
return isArrayLiteral(program, list) ? withVisibility(visibility | Visibility.Item, run) : run();
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* **An array literal, `T[]`, rather than a named list model** - as openapi3's asset emitter tells
|
|
355
|
+
* them apart, by name: a list called `Array` with no properties of its own. A visibility template's
|
|
356
|
+
* copy of `Pet[]` is no standard type, and read as a named list it was declared as `Array`, one
|
|
357
|
+
* name for every such copy.
|
|
358
|
+
*/
|
|
359
|
+
export function isArrayLiteral(program, list) {
|
|
360
|
+
return $(program).array.is(list) && list.name === "Array";
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* **Whether the walk is rendering an operation's parameter or response header itself**, outside any
|
|
364
|
+
* declaration it refers to. A tuple there is refused: openapi3 publishes `prefixItems` over the
|
|
365
|
+
* values the parameter carries, and nothing here reads a parameter's text as positions. A model a
|
|
366
|
+
* parameter refers to is a component the document shares with every other use, and a tuple in it is
|
|
367
|
+
* validated as the document publishes it.
|
|
368
|
+
*/
|
|
369
|
+
let parameterPosition = false;
|
|
370
|
+
export function withParameterPosition(inside, run) {
|
|
371
|
+
const previous = parameterPosition;
|
|
372
|
+
parameterPosition = inside;
|
|
373
|
+
try {
|
|
374
|
+
return run();
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
parameterPosition = previous;
|
|
378
|
+
}
|
|
170
379
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
metadataInfo ??= createMetadataInfo(program);
|
|
174
|
-
return metadataInfo.isTransformed(model, at);
|
|
380
|
+
export function inParameterPosition() {
|
|
381
|
+
return parameterPosition;
|
|
175
382
|
}
|
|
383
|
+
/** Why a tuple parameter is refused, and what to write instead - both walks say the same. */
|
|
384
|
+
export const TUPLE_PARAMETER = {
|
|
385
|
+
why: "a parameter typed as a tuple, which openapi3 publishes as `prefixItems` over the values it carries and this emitter does not read from the wire",
|
|
386
|
+
remedy: "Declare the parameter as a list of one element type, or carry the value in the body.",
|
|
387
|
+
};
|
|
176
388
|
/** The visibility the current walk is emitting for - the registry keys declarations on it. */
|
|
177
389
|
export function currentVisibility() {
|
|
178
390
|
return visibility;
|
|
@@ -183,7 +395,7 @@ export function currentVisibility() {
|
|
|
183
395
|
* A template declaration is not a real subtype, and neither is an uninstantiated template; both
|
|
184
396
|
* would otherwise stop a base from being sealed for no reason a reader could see.
|
|
185
397
|
*/
|
|
186
|
-
function includeDerivedModel(model) {
|
|
398
|
+
export function includeDerivedModel(model) {
|
|
187
399
|
return (!isTemplateDeclaration(model) &&
|
|
188
400
|
(model.templateMapper?.args === undefined ||
|
|
189
401
|
model.templateMapper.args.length === 0 ||
|
|
@@ -233,7 +445,7 @@ export function noteKeyVocabularies(program, models, wanted) {
|
|
|
233
445
|
if (!wanted.includes(model.name))
|
|
234
446
|
continue;
|
|
235
447
|
found.push(model.name);
|
|
236
|
-
noteVocabulary(model.name, inheritedAndOwnProperties(model).map((property) => rawPropertyKey(program, property)));
|
|
448
|
+
noteVocabulary(model.name, inheritedAndOwnProperties(program, model).map((property) => rawPropertyKey(program, property)));
|
|
237
449
|
}
|
|
238
450
|
return found;
|
|
239
451
|
}
|
|
@@ -326,15 +538,76 @@ export function withDeclarationSite(site, run) {
|
|
|
326
538
|
export function refusalTarget(type) {
|
|
327
539
|
return type.node === undefined ? (declarationSite ?? type) : type;
|
|
328
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* **The external document a type is published as a reference to**, by `@useRef`: openapi3 writes
|
|
543
|
+
* `$ref: <url>` wherever it is used and no component, so the document states no schema this emitter
|
|
544
|
+
* can read. Read off the decorator applied, as `@typespec/openapi3`, which declares it, is no
|
|
545
|
+
* dependency of this package.
|
|
546
|
+
*/
|
|
547
|
+
export function externalReferenceOf(type) {
|
|
548
|
+
if (!("decorators" in type))
|
|
549
|
+
return undefined;
|
|
550
|
+
const applied = type.decorators.find((each) => each.decorator.name === "$useRef");
|
|
551
|
+
if (applied === undefined)
|
|
552
|
+
return undefined;
|
|
553
|
+
const [argument] = applied.args;
|
|
554
|
+
return typeof argument?.jsValue === "string" ? argument.jsValue : "an external document";
|
|
555
|
+
}
|
|
556
|
+
/** Refuse a type the document publishes only as a reference to another document. */
|
|
557
|
+
export function refuseExternalReference(program, type, url) {
|
|
558
|
+
return refuse(program, type, `it is published as a reference to ${url}, whose schema the document does not state`, "Remove '@useRef' to have the document and this validator state the model, or validate the value yourself where it is used.");
|
|
559
|
+
}
|
|
329
560
|
/** Refuse `type`, pointing at its declaration, and carry on so the rest is reported too. */
|
|
330
561
|
function refuse(program, type, why, remedy = "") {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
562
|
+
// Once per declaration, whichever walk reaches it first: both walk every type.
|
|
563
|
+
const target = refusalTarget(type);
|
|
564
|
+
if (firstReportOf(program, "unsupported-type", target, `${type.kind} ${why}`)) {
|
|
565
|
+
reportDiagnostic(program, {
|
|
566
|
+
code: "unsupported-type",
|
|
567
|
+
target,
|
|
568
|
+
format: { artefact: "Zod", kind: type.kind, why, remedy },
|
|
569
|
+
});
|
|
570
|
+
}
|
|
336
571
|
return UNREPRESENTABLE;
|
|
337
572
|
}
|
|
573
|
+
/**
|
|
574
|
+
* Whether a JSON value passes the check a standard scalar emits, asked of that very check: the
|
|
575
|
+
* table below is code this module writes, so it is compiled once here with the same Zod and run,
|
|
576
|
+
* rather than restated as a second set of rules that could drift from it. `undefined` where the
|
|
577
|
+
* scalar has no standard base or no entry.
|
|
578
|
+
*/
|
|
579
|
+
const scalarChecks = new Map();
|
|
580
|
+
function scalarAccepts(program, scalar, json) {
|
|
581
|
+
/**
|
|
582
|
+
* **A scalar `@encode`d where it is declared is checked as the scalar it is encoded as**, as
|
|
583
|
+
* `scalarSchemaOf` validates it: `@encode(unixTimestamp, int32) scalar Ts` holds `1577836800`, and
|
|
584
|
+
* asked of `utcDateTime`'s ISO check the default `Ts.fromISO(...)` publishes was refused.
|
|
585
|
+
*/
|
|
586
|
+
const encoding = scalarEncodingOf(program, scalar);
|
|
587
|
+
if (encoding !== undefined) {
|
|
588
|
+
return program.checker.isStdType(encoding.encoded)
|
|
589
|
+
? scalarAccepts(program, encoding.encoded, json)
|
|
590
|
+
: undefined;
|
|
591
|
+
}
|
|
592
|
+
const base = standardBaseOf(program, scalar);
|
|
593
|
+
const source = base === undefined ? undefined : SCALARS[base.name];
|
|
594
|
+
if (base === undefined || source === undefined)
|
|
595
|
+
return undefined;
|
|
596
|
+
let check = scalarChecks.get(base.name);
|
|
597
|
+
if (check === undefined) {
|
|
598
|
+
check = new Function("z", `return ${source};`)(z);
|
|
599
|
+
scalarChecks.set(base.name, check);
|
|
600
|
+
}
|
|
601
|
+
return check.safeParse(json).success;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* **The Zod a standard scalar becomes, or `undefined` for any other scalar** - looked up only for
|
|
605
|
+
* the standard library's own: a spec's `scalar uint8 extends string` is a string, and found in this
|
|
606
|
+
* table by its name it was validated as a byte, refusing every string the document accepts.
|
|
607
|
+
*/
|
|
608
|
+
export function standardScalarSchemaOf(program, scalar) {
|
|
609
|
+
return program.checker.isStdType(scalar) ? SCALARS[scalar.name] : undefined;
|
|
610
|
+
}
|
|
338
611
|
/** Scalars we accept, and the Zod they become. Keyed by the TypeSpec scalar's declared name. */
|
|
339
612
|
const SCALARS = {
|
|
340
613
|
string: "z.string()",
|
|
@@ -452,18 +725,60 @@ export function scalarDocOf(program, scalar) {
|
|
|
452
725
|
}
|
|
453
726
|
return undefined;
|
|
454
727
|
}
|
|
728
|
+
/**
|
|
729
|
+
* **The `@encode` a scalar is published under: its own, or the nearest one up its `extends` chain.**
|
|
730
|
+
*
|
|
731
|
+
* openapi3 builds a scalar that is not standard from its base's schema - encoding included - and
|
|
732
|
+
* then applies its own decorators over it, so `scalar TsChild extends Ts` with
|
|
733
|
+
* `@encode(unixTimestamp, int32)` on `Ts` is published as an integer, exactly as `Ts` is. This read
|
|
734
|
+
* the scalar's own `@encode` alone, so `TsChild` was validated as an ISO date-time string, refusing
|
|
735
|
+
* every value the document accepts and accepting every value it refuses. `link` is the scalar that
|
|
736
|
+
* declares the encoding.
|
|
737
|
+
*/
|
|
738
|
+
export function scalarEncodingOf(program, scalar) {
|
|
739
|
+
for (let link = scalar; link !== undefined; link = link.baseScalar) {
|
|
740
|
+
const encoded = encodedTypeOf(program, link);
|
|
741
|
+
if (encoded !== undefined)
|
|
742
|
+
return { link, encoded };
|
|
743
|
+
}
|
|
744
|
+
return undefined;
|
|
745
|
+
}
|
|
455
746
|
/** A scalar's schema without its description, for a position that publishes its own. */
|
|
456
747
|
function scalarSchemaOf(program, scalar) {
|
|
457
748
|
// `scalar unixTimestamp32 is utcDateTime;` with `@encode(int32)` on the SCALAR, not on each use.
|
|
458
|
-
const
|
|
459
|
-
if (
|
|
749
|
+
const encoding = scalarEncodingOf(program, scalar);
|
|
750
|
+
if (encoding !== undefined) {
|
|
751
|
+
const { link, encoded } = encoding;
|
|
752
|
+
/**
|
|
753
|
+
* **An encoding to a scalar that is not standard publishes no type**, so there is no wire form
|
|
754
|
+
* to validate - including a scalar encoded as itself, which openapi3 publishes as
|
|
755
|
+
* `{ format: "y" }` alone. A scalar from the standard library carries no `@encode` of its own,
|
|
756
|
+
* so refusing this at the first link is also what ends every chain: `Ping -> Pong -> Ping` is
|
|
757
|
+
* refused at `Ping`, where it used to be followed until the stack overflowed.
|
|
758
|
+
*/
|
|
759
|
+
if (!program.checker.isStdType(encoded)) {
|
|
760
|
+
reportUnsatisfiableEncoding(program, link, {
|
|
761
|
+
name: link.name,
|
|
762
|
+
declared: link.baseScalar?.name ?? link.name,
|
|
763
|
+
encoded: encoded.name,
|
|
764
|
+
why: "typeless",
|
|
765
|
+
});
|
|
766
|
+
return UNREPRESENTABLE;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* The constraints are read from the scalar named, up its chain, and each one the encoded type
|
|
770
|
+
* cannot carry is dropped and reported by `applyConstraints`: openapi3 applies them BEFORE the
|
|
771
|
+
* encoding replaces `type`, so a `@pattern` on a string encoded as `int32` is published beside
|
|
772
|
+
* `type: integer`, where JSON Schema ignores it. Applied, it emitted `z.int32().regex(...)`,
|
|
773
|
+
* and the module threw on import.
|
|
774
|
+
*/
|
|
460
775
|
return applyConstraints(program, scalarSchemaOf(program, encoded), scalar);
|
|
461
776
|
}
|
|
462
777
|
let current = scalar;
|
|
463
778
|
// Walk the `extends` chain so a named scalar (`scalar chNumber extends string`) resolves to the
|
|
464
779
|
// primitive it is built on rather than being rejected for having a project-specific name.
|
|
465
780
|
while (current !== undefined) {
|
|
466
|
-
const mapped =
|
|
781
|
+
const mapped = standardScalarSchemaOf(program, current);
|
|
467
782
|
// Constraints are read from the scalar the property NAMED, not from the primitive it resolves
|
|
468
783
|
// to, so `@pattern(...) scalar TrimmedString extends string` carries its rules to every use.
|
|
469
784
|
if (mapped !== undefined)
|
|
@@ -556,12 +871,55 @@ export function withSyntheticDeclarations(hook, run) {
|
|
|
556
871
|
}
|
|
557
872
|
}
|
|
558
873
|
const capitalise = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
559
|
-
/**
|
|
560
|
-
|
|
874
|
+
/**
|
|
875
|
+
* **Why a variant of an envelope-less `@discriminated` union cannot be dispatched to by its wire
|
|
876
|
+
* discriminator, or `undefined` where it can.** The variant must carry the discriminator under its
|
|
877
|
+
* WIRE name, as one fixed value. Matched on the TypeSpec name as well, a variant whose `kind` was
|
|
878
|
+
* renamed `t` passed, and the module threw "Invalid discriminated union option" on import; one
|
|
879
|
+
* inheriting an open `type: string` threw on every parse.
|
|
880
|
+
*/
|
|
881
|
+
export function undeclaredVariantOf(program, variant, property) {
|
|
561
882
|
if (variant.kind !== "Model")
|
|
562
|
-
return
|
|
563
|
-
|
|
564
|
-
|
|
883
|
+
return "missing";
|
|
884
|
+
const entry = publishedPropertiesOf(program, variant).find((candidate) => rawPropertyKey(program, candidate.property) === property);
|
|
885
|
+
if (entry === undefined)
|
|
886
|
+
return "missing";
|
|
887
|
+
if (fixedValuesOf(entry.property.type) === undefined)
|
|
888
|
+
return "open";
|
|
889
|
+
// A value a declaration above the variant refuses: no value is one.
|
|
890
|
+
return positionOf(program, variant, entry).never ? "refused" : undefined;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* **The variants a `@discriminated` union dispatches to: each but one no value can be.**
|
|
894
|
+
*
|
|
895
|
+
* A variant without an envelope that accepts nothing - its fixed discriminator refused by a
|
|
896
|
+
* declaration above it, `kind: "cobra"` under `@maxLength(3) kind: string`, or a key it requires
|
|
897
|
+
* refused by a base openapi3 seals - is published by the document as a member no value satisfies, and
|
|
898
|
+
* a `oneOf` with such a member accepts what the others accept. Refusing the union for it refused every
|
|
899
|
+
* `asp` the document accepts, and dispatching to it put a `z.never()` where Zod reads a literal: the
|
|
900
|
+
* first parse threw "Invalid discriminated union option".
|
|
901
|
+
*/
|
|
902
|
+
export function dispatchedVariantsOf(program, union) {
|
|
903
|
+
const [discriminated] = getDiscriminatedUnion(program, union);
|
|
904
|
+
if (discriminated === undefined)
|
|
905
|
+
return [];
|
|
906
|
+
const { envelope, discriminatorPropertyName } = discriminated.options;
|
|
907
|
+
return [...discriminated.variants.entries()]
|
|
908
|
+
.map(([name, variant]) => [String(name), variant])
|
|
909
|
+
.filter(([, variant]) => envelope !== "none" ||
|
|
910
|
+
/**
|
|
911
|
+
* A refused discriminator leaves values where it may be left out: under a PATCH,
|
|
912
|
+
* `NCUpdate` accepts `{}`, which matches `NAUpdate` too, so the document's `DNUpdate`
|
|
913
|
+
* refuses it. Dropped, `DNUpdate` was `NAUpdate` and accepted it.
|
|
914
|
+
*/
|
|
915
|
+
((undeclaredVariantOf(program, variant, discriminatorPropertyName) !== "refused" ||
|
|
916
|
+
optionalDiscriminator(program, union, variant, discriminated.options)) &&
|
|
917
|
+
!(variant.kind === "Model" && acceptsNothing(program, variant)) &&
|
|
918
|
+
!isPublishedSplit(program, variant, renderedVisibilityOf(program, variant, visibility))));
|
|
919
|
+
}
|
|
920
|
+
/** A model no value is, refused on its own - `invalid-subtype-discriminator`, `sealed-base-extended`. */
|
|
921
|
+
function acceptsNothing(program, model) {
|
|
922
|
+
return (unsatisfiableSubtype(program, model) || sealedBaseExtensionOf(program, model)?.required === true);
|
|
565
923
|
}
|
|
566
924
|
/**
|
|
567
925
|
* One variant of a `@discriminated` union, as it appears ON THE WIRE.
|
|
@@ -580,18 +938,84 @@ function declaresDiscriminator(program, variant, property) {
|
|
|
580
938
|
* emitted **only when the variant declares that property itself**. Otherwise the document does not
|
|
581
939
|
* publish it and the union is refused; see `undeclared-discriminator`.
|
|
582
940
|
*/
|
|
941
|
+
/**
|
|
942
|
+
* **Whether a required property is optional at `at`**: a PATCH with implicit optionality, outside a
|
|
943
|
+
* list's elements - `@typespec/http`'s `isOptional`, for a property with no declaration of its own
|
|
944
|
+
* to ask, as an envelope's are.
|
|
945
|
+
*/
|
|
946
|
+
export function implicitlyOptional(at) {
|
|
947
|
+
return ((at & Visibility.Update) !== 0 && (at & Visibility.Patch) !== 0 && (at & Visibility.Item) === 0);
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* **The visibility an envelope is published at, within a union rendered at the current one.**
|
|
951
|
+
* openapi3 synthesises the envelope as a model of two properties - the discriminator and the variant
|
|
952
|
+
* - and emits it from the union's context, which reduces it to `Read` unless it is transformed
|
|
953
|
+
* there: where the variant is, or where a PATCH makes both its properties optional. Read as one
|
|
954
|
+
* envelope for every visibility, a PATCH body was held to the read wrapper's required `kind` and
|
|
955
|
+
* `value`, and a response to the create wrapper's variant.
|
|
956
|
+
*/
|
|
957
|
+
export function envelopeVisibilityOf(program, union, variant) {
|
|
958
|
+
/**
|
|
959
|
+
* **The union is reduced first**, as openapi3 reduces any reference to it: a union not
|
|
960
|
+
* transformed where it is reached is emitted at `Read`, its envelopes with it. Spelled out inside a
|
|
961
|
+
* merge under a PATCH, `DU { a: A, b: B }` of all-optional variants had its envelopes' `kind` and
|
|
962
|
+
* `value` made optional, where the document's `DUA` requires both.
|
|
963
|
+
*/
|
|
964
|
+
const at = renderedVisibilityOf(program, union, visibility);
|
|
965
|
+
if (at === Visibility.Read)
|
|
966
|
+
return Visibility.Read;
|
|
967
|
+
const reshaped = (variant.kind === "Model" || variant.kind === "Union") && isTransformedBy(program, variant, at);
|
|
968
|
+
return reshaped || implicitlyOptional(at) ? at : Visibility.Read;
|
|
969
|
+
}
|
|
970
|
+
/** Whether a variant's discriminator may be absent where the union is rendered - see `unionToZod`. */
|
|
971
|
+
export function optionalDiscriminator(program, union, variant, options) {
|
|
972
|
+
if (options.envelope !== "none")
|
|
973
|
+
return implicitlyOptional(envelopeVisibilityOf(program, union, variant));
|
|
974
|
+
if (variant.kind !== "Model")
|
|
975
|
+
return false;
|
|
976
|
+
const at = renderedVisibilityOf(program, variant, visibility);
|
|
977
|
+
const property = inheritedAndOwnProperties(program, variant).find((each) => each.name === options.discriminatorPropertyName);
|
|
978
|
+
return property !== undefined && (property.optional || implicitlyOptional(at));
|
|
979
|
+
}
|
|
583
980
|
function discriminatedVariant(program, union, variantName, variant, options) {
|
|
584
|
-
const inner = typeToZod(program, variant);
|
|
585
|
-
const
|
|
981
|
+
const { value: inner, deferred } = captureBackEdges(() => typeToZod(program, variant));
|
|
982
|
+
const envelopeAt = envelopeVisibilityOf(program, union, variant);
|
|
983
|
+
const optional = implicitlyOptional(envelopeAt) ? ".exactOptional()" : "";
|
|
984
|
+
const tag = `${objectKey(options.discriminatorPropertyName)}: z.literal(${literal(variantName)})${optional}`;
|
|
586
985
|
// The variant already carries the discriminator, so the document publishes it and there is
|
|
587
986
|
// nothing to add.
|
|
588
|
-
if (options.envelope === "none")
|
|
987
|
+
if (options.envelope === "none") {
|
|
988
|
+
if (deferred)
|
|
989
|
+
noteBackEdge();
|
|
589
990
|
return inner;
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* **A variant still being declared is reached through a getter**, as an object's member is: the
|
|
994
|
+
* wrapper is a declaration of its own, and read eagerly it met `catSchema` before its `const` -
|
|
995
|
+
* "Cannot access 'catSchema' before initialization" on import, for a `Cat` that refers back to the
|
|
996
|
+
* union through its own properties.
|
|
997
|
+
*/
|
|
998
|
+
const key = objectKey(options.envelopePropertyName);
|
|
999
|
+
const value = deferred
|
|
1000
|
+
? `get ${key}()${optional === "" ? (annotateDeferredType(variant) ?? "") : ""} { return ${inner}${optional}; }`
|
|
1001
|
+
: `${key}: ${inner}${optional}`;
|
|
1002
|
+
const wrapper = `${sealObjectSchemas ? "z.strictObject" : "z.object"}({ ${tag}, ${value} })`;
|
|
1003
|
+
const name = envelopeNameOf(program, union, variantName, variant);
|
|
1004
|
+
if (name === undefined)
|
|
593
1005
|
return wrapper;
|
|
594
|
-
return declareSynthetic?.(name, () => wrapper) ?? wrapper;
|
|
1006
|
+
return declareSynthetic?.(name, () => wrapper, union) ?? wrapper;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* **The component openapi3 publishes an envelope as**: the union's name and the variant's, suffixed
|
|
1010
|
+
* with the envelope's visibility where the union - whose usage openapi3 gives the envelope - is used
|
|
1011
|
+
* at more than one. None for a union with no name, whose envelopes are written out where it is.
|
|
1012
|
+
*/
|
|
1013
|
+
export function envelopeNameOf(program, union, variantName, variant) {
|
|
1014
|
+
if (union.name === undefined)
|
|
1015
|
+
return undefined;
|
|
1016
|
+
const envelopeAt = envelopeVisibilityOf(program, union, variant);
|
|
1017
|
+
const suffixed = envelopeAt !== Visibility.Read && isSuffixedByUsage(union);
|
|
1018
|
+
return `${union.name}${capitalise(variantName)}${suffixed ? getVisibilitySuffix(envelopeAt, Visibility.Read) : ""}`;
|
|
595
1019
|
}
|
|
596
1020
|
function unionToZod(program, union) {
|
|
597
1021
|
/**
|
|
@@ -619,43 +1043,151 @@ function unionToZod(program, union) {
|
|
|
619
1043
|
* reader. A validator may only enforce what the document says.
|
|
620
1044
|
*/
|
|
621
1045
|
if (options.envelope === "none") {
|
|
622
|
-
const undeclared = [...discriminated.variants.values()]
|
|
623
|
-
|
|
1046
|
+
const undeclared = [...discriminated.variants.values()]
|
|
1047
|
+
.map((variant) => ({
|
|
1048
|
+
variant,
|
|
1049
|
+
why: undeclaredVariantOf(program, variant, options.discriminatorPropertyName),
|
|
1050
|
+
}))
|
|
1051
|
+
.filter(({ why }) => why === "missing" || why === "open");
|
|
1052
|
+
const [first] = undeclared;
|
|
1053
|
+
if (first !== undefined) {
|
|
624
1054
|
reportDiagnostic(program, {
|
|
625
1055
|
code: "undeclared-discriminator",
|
|
1056
|
+
messageId: first.why === "open" ? "open" : "default",
|
|
626
1057
|
target: union,
|
|
627
1058
|
format: {
|
|
628
1059
|
name: union.name ?? "an anonymous union",
|
|
629
1060
|
property: options.discriminatorPropertyName,
|
|
630
|
-
variant:
|
|
1061
|
+
variant: first.variant.name ?? "one of its variants",
|
|
631
1062
|
},
|
|
632
1063
|
});
|
|
633
1064
|
return UNREPRESENTABLE;
|
|
634
1065
|
}
|
|
635
1066
|
}
|
|
636
|
-
const
|
|
1067
|
+
const dispatched = dispatchedVariantsOf(program, union);
|
|
1068
|
+
// A variant left out for a key its sealed base refuses is refused as itself, as anywhere else.
|
|
1069
|
+
for (const variant of discriminated.variants.values()) {
|
|
1070
|
+
if (variant.kind === "Model")
|
|
1071
|
+
reportSealedBaseExtended(program, variant);
|
|
1072
|
+
}
|
|
1073
|
+
const kept = dispatched
|
|
1074
|
+
.map(([name, variant]) => ({
|
|
1075
|
+
variant,
|
|
1076
|
+
member: discriminatedVariant(program, union, name, variant, options),
|
|
1077
|
+
}))
|
|
1078
|
+
.filter(({ member }) => !held.refused(member));
|
|
1079
|
+
const members = kept.map(({ member }) => member);
|
|
1080
|
+
// Every variant refused by a declaration above it: the document accepts no value either.
|
|
1081
|
+
if (members.length === 0)
|
|
1082
|
+
return UNREPRESENTABLE;
|
|
637
1083
|
const property = options.discriminatorPropertyName;
|
|
638
|
-
|
|
1084
|
+
/**
|
|
1085
|
+
* **A discriminator a PATCH makes optional dispatches nothing**: `z.discriminatedUnion` reads
|
|
1086
|
+
* one fixed value per member, and the document publishes `oneOf` - exactly one member - which
|
|
1087
|
+
* with the tag absent is what `z.xor` checks. As a dispatch, `{ value: {...} }` was refused.
|
|
1088
|
+
*/
|
|
1089
|
+
if (dispatched.some(([, variant]) => optionalDiscriminator(program, union, variant, options))) {
|
|
1090
|
+
return members.length === 1 ? members[0] : `z.xor([${members.join(", ")}])`;
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* **A variant no dispatch can hold sits beside it**, as a nested base does beside a base's own
|
|
1094
|
+
* dispatch: one with no envelope that is a `@discriminator` base itself, a `z.union` with no
|
|
1095
|
+
* literal to read, and one annotated for its recursion. In the dispatch, every parse threw
|
|
1096
|
+
* "Invalid discriminated union option". Each fixes a value of its own, so at most one matches.
|
|
1097
|
+
*/
|
|
1098
|
+
const beside = kept
|
|
1099
|
+
.filter(({ variant, member }) => held.annotated(member) ||
|
|
1100
|
+
(options.envelope === "none" &&
|
|
1101
|
+
variant.kind === "Model" &&
|
|
1102
|
+
discriminatedBaseOf(program, variant) !== undefined))
|
|
1103
|
+
.map(({ member }) => member);
|
|
1104
|
+
const dispatch = members.filter((member) => !beside.includes(member));
|
|
1105
|
+
if (beside.length === 0) {
|
|
1106
|
+
return `z.discriminatedUnion(${literal(property)}, [${members.join(", ")}])`;
|
|
1107
|
+
}
|
|
1108
|
+
const arms = [
|
|
1109
|
+
...(dispatch.length === 0
|
|
1110
|
+
? []
|
|
1111
|
+
: [`z.discriminatedUnion(${literal(property)}, [${dispatch.join(", ")}])`]),
|
|
1112
|
+
...beside,
|
|
1113
|
+
];
|
|
1114
|
+
return arms.length === 1 ? arms[0] : `z.xor([${arms.join(", ")}])`;
|
|
639
1115
|
}
|
|
640
1116
|
const variants = [...union.variants.values()].map((variant) => variant.type);
|
|
641
|
-
const
|
|
642
|
-
const real = variants.filter((type) => !(type
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
1117
|
+
const nulls = variants.filter(isNull).length;
|
|
1118
|
+
const real = variants.filter((type) => !isNull(type));
|
|
1119
|
+
const exclusive = isExclusive(program, union) || exclusiveSite === union;
|
|
1120
|
+
/**
|
|
1121
|
+
* **Asked once the members are written**: writing one declares it, and a declaration openapi3
|
|
1122
|
+
* publishes split is refused only as it is written. Asked first, `@oneOf { v0: float, v1: M10, v2:
|
|
1123
|
+
* M11 }` held the `M10` refused while it was written, and the contract type, walked after every
|
|
1124
|
+
* refusal, was `never` - `wire-contract.gen.ts` did not compile (`TS2344`).
|
|
1125
|
+
*/
|
|
1126
|
+
const written = (() => {
|
|
1127
|
+
// A raw `bytes` member of a multipart part's union is any value - see `withMultipartBinary`.
|
|
1128
|
+
const memberToZod = (type) => multipartBinary && isRawBytes(program, type) ? "z.unknown()" : typeToZod(program, type);
|
|
1129
|
+
/**
|
|
1130
|
+
* **A `@oneOf` refuses the `null` more than one of its members matches**: openapi3 publishes
|
|
1131
|
+
* each `null` variant as a `{ type: "null" }` of its own, beside members that may admit it too -
|
|
1132
|
+
* `unknown | null` is `oneOf [{}, null]`, which no `null` passes. Each is a `z.null()` member of
|
|
1133
|
+
* the `z.xor`, which counts as the document does; `.nullable()` passed `null` before either.
|
|
1134
|
+
*/
|
|
1135
|
+
if (exclusive &&
|
|
1136
|
+
nulls > 0 &&
|
|
1137
|
+
(nulls > 1 || real.some((type) => admitsNullValue(program, type)))) {
|
|
1138
|
+
const members = [...literalGroupsOf(real, memberToZod), ...Array(nulls).fill("z.null()")];
|
|
1139
|
+
return `z.xor([${members.join(", ")}])`;
|
|
1140
|
+
}
|
|
1141
|
+
// `null | null` is `null`, as the document reads it - and `q: null` was never refused.
|
|
1142
|
+
if (real.length === 0)
|
|
1143
|
+
return "z.null()";
|
|
1144
|
+
const suffix = nulls > 0 ? ".nullable()" : "";
|
|
1145
|
+
if (real.every((type) => type.kind === "String")) {
|
|
1146
|
+
const literals = real.map((type) => literal(type.value));
|
|
1147
|
+
return `z.enum([${literals.join(", ")}])${suffix}`;
|
|
1148
|
+
}
|
|
1149
|
+
if (real.length === 1)
|
|
1150
|
+
return `${memberToZod(real[0])}${suffix}`;
|
|
1151
|
+
/**
|
|
1152
|
+
* **Every literal of one JSON kind is one member of a `@oneOf`**, as openapi3 publishes it - one
|
|
1153
|
+
* `enum` per kind: `1` repeated is matched once, where two `z.literal(1)` matched it twice.
|
|
1154
|
+
*/
|
|
1155
|
+
const members = exclusive ? literalGroupsOf(real, memberToZod) : real.map(memberToZod);
|
|
1156
|
+
if (members.length === 1)
|
|
1157
|
+
return `${members[0]}${suffix}`;
|
|
1158
|
+
return `${exclusive ? "z.xor" : "z.union"}([${members.join(", ")}])${suffix}`;
|
|
1159
|
+
})();
|
|
1160
|
+
return holdsRefusedMember(program, union, exclusive) ? UNREPRESENTABLE : written;
|
|
1161
|
+
}
|
|
1162
|
+
/** A union's members as Zod, its literals of each JSON kind as one member where the first one stands. */
|
|
1163
|
+
function literalGroupsOf(types, memberToZod) {
|
|
1164
|
+
const groups = new Map();
|
|
1165
|
+
const members = [];
|
|
1166
|
+
for (const type of types) {
|
|
1167
|
+
const value = type.kind === "EnumMember" ? undefined : literalValueOf(type);
|
|
1168
|
+
if (value === undefined) {
|
|
1169
|
+
members.push(memberToZod(type));
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
const kind = typeof value;
|
|
1173
|
+
const group = groups.get(kind);
|
|
1174
|
+
if (group === undefined) {
|
|
1175
|
+
groups.set(kind, [value]);
|
|
1176
|
+
members.push({ group: kind });
|
|
1177
|
+
}
|
|
1178
|
+
else if (!group.includes(value))
|
|
1179
|
+
group.push(value);
|
|
655
1180
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1181
|
+
return members.map((member) => {
|
|
1182
|
+
if (typeof member === "string")
|
|
1183
|
+
return member;
|
|
1184
|
+
const values = groups.get(member.group) ?? [];
|
|
1185
|
+
if (values.every((each) => typeof each === "string")) {
|
|
1186
|
+
return `z.enum([${values.map((each) => literal(each)).join(", ")}])`;
|
|
1187
|
+
}
|
|
1188
|
+
const literals = values.map((each) => `z.literal(${literal(each)})`);
|
|
1189
|
+
return literals.length === 1 ? literals[0] : `z.union([${literals.join(", ")}])`;
|
|
1190
|
+
});
|
|
659
1191
|
}
|
|
660
1192
|
/**
|
|
661
1193
|
* TypeSpec models arrays and dictionaries as a `Model` carrying an **indexer**.
|
|
@@ -694,230 +1226,1858 @@ function isUnknownType(type) {
|
|
|
694
1226
|
* refers back to the derived model - `type/model/inheritance/recursive`, the exact case - would throw
|
|
695
1227
|
* during module initialisation. Flattening asks nothing of the base but its properties.
|
|
696
1228
|
*/
|
|
697
|
-
export function inheritedAndOwnProperties(model) {
|
|
1229
|
+
export function inheritedAndOwnProperties(program, model) {
|
|
1230
|
+
return flattenedPropertiesOf(program, model).map((entry) => entry.property);
|
|
1231
|
+
}
|
|
1232
|
+
export function flattenedPropertiesOf(program, model) {
|
|
698
1233
|
const chain = [];
|
|
699
1234
|
for (let link = model; link !== undefined; link = link.baseModel) {
|
|
700
1235
|
chain.unshift(link);
|
|
701
1236
|
}
|
|
702
|
-
// Base first, so a derived model REDECLARING a
|
|
703
|
-
const
|
|
1237
|
+
// Base first, so a derived model REDECLARING a key overrides rather than duplicates it.
|
|
1238
|
+
const byKey = new Map();
|
|
704
1239
|
for (const link of chain) {
|
|
705
|
-
for (const property of link.properties.values())
|
|
706
|
-
|
|
1240
|
+
for (const property of link.properties.values()) {
|
|
1241
|
+
const key = rawPropertyKey(program, property);
|
|
1242
|
+
const declarations = byKey.get(key) ?? [];
|
|
1243
|
+
// Two keys of one name in one model: openapi3 writes the later over the earlier.
|
|
1244
|
+
if (declarations.at(-1)?.model === link)
|
|
1245
|
+
declarations.pop();
|
|
1246
|
+
declarations.push({ model: link, property });
|
|
1247
|
+
byKey.set(key, declarations);
|
|
1248
|
+
}
|
|
707
1249
|
}
|
|
708
|
-
return [...
|
|
1250
|
+
return [...byKey.values()].map((declarations) => ({
|
|
1251
|
+
property: declarations.at(-1).property,
|
|
1252
|
+
overridden: declarations
|
|
1253
|
+
.slice(0, -1)
|
|
1254
|
+
.reverse()
|
|
1255
|
+
.map((declaration) => declaration.property),
|
|
1256
|
+
}));
|
|
709
1257
|
}
|
|
710
|
-
/**
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1258
|
+
/**
|
|
1259
|
+
* The keys a model publishes at the current position, each with every declaration of it the
|
|
1260
|
+
* document holds the value to - its most derived published declaration first.
|
|
1261
|
+
*
|
|
1262
|
+
* **A declaration counts where openapi3 publishes it, and only there.** Each model in the chain is
|
|
1263
|
+
* published as `allOf` the one above, and each judges its own properties at the position: a
|
|
1264
|
+
* `@header` or a create-only property above a response is in no schema the response is held to, so
|
|
1265
|
+
* its `@maxLength` bounds nothing there; a `never` or an invisible redeclaration drops out of its own
|
|
1266
|
+
* model's schema and leaves the key to the ones above it. Judged on the most derived declaration
|
|
1267
|
+
* alone, the first was enforced where the document does not, and the second dropped a key the base
|
|
1268
|
+
* still requires.
|
|
1269
|
+
*
|
|
1270
|
+
* **HTTP metadata is not payload.** A `@header` or `@statusCode` property describes the envelope, so
|
|
1271
|
+
* it never appears in the body: RFC 9457 puts `@header("content-type") contentType:
|
|
1272
|
+
* "application/problem+json"` on the problem model, the document described a body without it, and
|
|
1273
|
+
* this emitter required `contentType` as a member. **A `never` property is dropped, as the document
|
|
1274
|
+
* drops it**: `model N { value: never; other: string }` is published as `{other}`, and a validator
|
|
1275
|
+
* keeping `value: z.never()` refused `{"other": "x"}`, the exact body the document describes.
|
|
1276
|
+
*/
|
|
1277
|
+
export function publishedPropertiesOf(program, model) {
|
|
1278
|
+
return declarationsWhere(program, model, (declaration) => atPublication(program, declaration, () => isPayloadProperty(program, declaration)));
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* **The keys a model's `required` is read from: each declaration at the visibility its model is
|
|
1282
|
+
* rendered at**, where openapi3 reduces the reference - see `contentMisreadingsOf`. Wider than
|
|
1283
|
+
* {@link publishedPropertiesOf} where a read-only declaration drops out of the properties openapi3
|
|
1284
|
+
* reads at a create body's visibility, and stays required at `Read`.
|
|
1285
|
+
*/
|
|
1286
|
+
function renderedPropertiesOf(program, model) {
|
|
1287
|
+
return declarationsWhere(program, model, (declaration) => atDeclaration(program, declaration, () => isPayloadProperty(program, declaration)));
|
|
1288
|
+
}
|
|
1289
|
+
function declarationsWhere(program, model, present) {
|
|
1290
|
+
const published = [];
|
|
1291
|
+
for (const entry of flattenedPropertiesOf(program, model)) {
|
|
1292
|
+
const [property, ...overridden] = [entry.property, ...entry.overridden].filter((declaration) => present(declaration) && !isNeverType(declaration.type));
|
|
1293
|
+
if (property !== undefined)
|
|
1294
|
+
published.push({ property, overridden });
|
|
715
1295
|
}
|
|
716
|
-
return
|
|
1296
|
+
return published;
|
|
717
1297
|
}
|
|
718
1298
|
/**
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
1299
|
+
* **A declaration is in its model's schema where openapi3 reads that model's properties: at the
|
|
1300
|
+
* visibility the reference it writes the model from arrived at** - see `readVisibilityOf` - not the
|
|
1301
|
+
* one it reduces that to. `X { @visibility(Read) @maxLength(2) id: string; ...Record<string> }`
|
|
1302
|
+
* first sent in a create body is published without `id` in its properties, so its indexer holds the
|
|
1303
|
+
* `id` its `required` names; validated by the read-only declaration, `"abcdef"` was refused. Likewise
|
|
1304
|
+
* a base's `id`, or the `string` openapi3 adds for a discriminator, holds a key whose read-only
|
|
1305
|
+
* declaration drops out.
|
|
723
1306
|
*/
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1307
|
+
function atPublication(program, property, run) {
|
|
1308
|
+
const model = property.model;
|
|
1309
|
+
if (model === undefined)
|
|
1310
|
+
return run();
|
|
1311
|
+
// Of several views under one name, the one the document keeps - see `keptVisibilityOf`.
|
|
1312
|
+
const rendered = keptVisibilityOf(model, renderedVisibilityOf(program, model, visibility));
|
|
1313
|
+
return withVisibility(readVisibilityOf(program, model, rendered, metadataIgnored), run);
|
|
728
1314
|
}
|
|
729
|
-
|
|
1315
|
+
/**
|
|
1316
|
+
* The discriminators a model is published under: its own and every one up its chain, by wire name.
|
|
1317
|
+
* openapi3 lists each in its base's `required`, and the base applies through `allOf`.
|
|
1318
|
+
*/
|
|
1319
|
+
export function discriminatorsOf(program, model) {
|
|
1320
|
+
const names = [];
|
|
1321
|
+
for (let link = model; link !== undefined; link = link.baseModel) {
|
|
1322
|
+
const discriminator = getDiscriminator(program, link);
|
|
1323
|
+
if (discriminator !== undefined && !names.includes(discriminator.propertyName)) {
|
|
1324
|
+
names.push(discriminator.propertyName);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
return names;
|
|
1328
|
+
}
|
|
1329
|
+
export const OWN_POSITION = {
|
|
1330
|
+
constrainedBy: [],
|
|
1331
|
+
required: false,
|
|
1332
|
+
stringOnly: false,
|
|
1333
|
+
never: false,
|
|
1334
|
+
nullOnly: false,
|
|
1335
|
+
nonNull: false,
|
|
1336
|
+
value: undefined,
|
|
1337
|
+
ancestors: [],
|
|
1338
|
+
refused: undefined,
|
|
1339
|
+
};
|
|
1340
|
+
export function positionOf(program, model, entry) {
|
|
1341
|
+
const key = rawPropertyKey(program, entry.property);
|
|
1342
|
+
return positionOfDeclarations(program, entry.property, entry.overridden, {
|
|
1343
|
+
required: discriminatorsOf(program, model).includes(key),
|
|
1344
|
+
injected: injectedDiscriminatorsOf(program, model).includes(key),
|
|
1345
|
+
beside: [],
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* The position of `own` beneath `ancestors`, the declarations of one key the document holds its
|
|
1350
|
+
* value to, plus `beside`: what no declaration states - a typed indexer of a model merged with it, an
|
|
1351
|
+
* envelope's members.
|
|
1352
|
+
*/
|
|
1353
|
+
function positionOfDeclarations(program, own, ancestors, options) {
|
|
1354
|
+
const position = {
|
|
1355
|
+
...OWN_POSITION,
|
|
1356
|
+
ancestors,
|
|
1357
|
+
required: options.required || ancestors.some((ancestor) => isRequiredAt(program, ancestor)),
|
|
1358
|
+
...(options.at?.[0] === undefined ? {} : { at: options.at[0] }),
|
|
1359
|
+
};
|
|
1360
|
+
const beside = options.beside.filter((part) => !isTypelessPart(program, part) || part.keywords.length > 0);
|
|
1361
|
+
const atOf = (index) => options.at?.[index];
|
|
1362
|
+
const ownAt = atOf(0) ?? visibility;
|
|
730
1363
|
/**
|
|
731
|
-
* **A
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
* it reaches OpenAPI as a component carrying `discriminator: {propertyName, mapping}`, and the
|
|
735
|
-
* mapping is an instruction: validate the body against whichever subtype the discriminator names.
|
|
736
|
-
* Emitting the base's own properties instead produced `z.object({kind: z.string(), wingspan})`,
|
|
737
|
-
* which accepts `{kind: "eagle"}` while ignoring everything `Eagle` actually declares - a
|
|
738
|
-
* polymorphic endpoint validating none of its polymorphism.
|
|
739
|
-
*
|
|
740
|
-
* `z.discriminatedUnion` is the faithful encoding and composes both ways this corpus needs.
|
|
741
|
-
* Measured on Zod 4.4.3: a union may be an OPTION of another union, which is how
|
|
742
|
-
* `Fish -> Shark -> SawShark` works; and an option carrying a getter that refers back to the union
|
|
743
|
-
* being defined does **not** throw, because the discriminator is read without enumerating the rest
|
|
744
|
-
* of the shape. `Eagle.friends?: Bird[]` is exactly that, and it decides whether polymorphism and
|
|
745
|
-
* recursion can coexist at all.
|
|
1364
|
+
* **A key one declaration states is still published at that declaration's visibility**: `M5`'s
|
|
1365
|
+
* `e: M6`, merged at `Read` beside `M4Update`, is `M6`, and rendered at the merge's own `Update`
|
|
1366
|
+
* it was `M6Update`, which accepts the `{}` that `M6` refuses.
|
|
746
1367
|
*/
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
return `z.discriminatedUnion(${literal(discriminator)}, [${options.join(", ")}])`;
|
|
1368
|
+
const apart = ownAt !== visibility &&
|
|
1369
|
+
renderedVisibilityOf(program, nullableInnerOf(own.type) ?? own.type, ownAt) !==
|
|
1370
|
+
renderedVisibilityOf(program, nullableInnerOf(own.type) ?? own.type, visibility);
|
|
1371
|
+
if (ancestors.length === 0 && !options.injected && beside.length === 0 && !apart) {
|
|
1372
|
+
return position;
|
|
753
1373
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1374
|
+
// Reported and refused by its own walk - see `unsatisfiable-encoding`.
|
|
1375
|
+
if (encodedValueOf(program, own).kind === "unsatisfiable")
|
|
1376
|
+
return position;
|
|
1377
|
+
const constrainedBy = [];
|
|
1378
|
+
let never = false;
|
|
1379
|
+
let nullOnly = false;
|
|
1380
|
+
let nonNull = false;
|
|
1381
|
+
let merge = beside.length > 0 || apart;
|
|
1382
|
+
for (const [index, ancestor] of ancestors.entries()) {
|
|
1383
|
+
/**
|
|
1384
|
+
* **A declaration published at another visibility is merged, unless its type is one schema at
|
|
1385
|
+
* both**: `In` in a list literal's element is published at `Update | Item` and in a named list's
|
|
1386
|
+
* at `Update`, and taken as narrowed by itself it was `InUpdate` alone, `x` optional where the
|
|
1387
|
+
* element requires it.
|
|
1388
|
+
*/
|
|
1389
|
+
const ancestorAt = atOf(index + 1) ?? visibility;
|
|
1390
|
+
const declared = nullableInnerOf(ancestor.type) ?? ancestor.type;
|
|
1391
|
+
if (ancestorAt !== ownAt &&
|
|
1392
|
+
renderedVisibilityOf(program, declared, ancestorAt) !==
|
|
1393
|
+
renderedVisibilityOf(program, declared, ownAt)) {
|
|
1394
|
+
merge = true;
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
if (elementsPublishedApart(program, nullableInnerOf(own.type) ?? own.type, ownAt, declared, ancestorAt)) {
|
|
1398
|
+
merge = true;
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
const verdict = overrideVerdictOf(program, own, ancestor);
|
|
1402
|
+
if (verdict.kind === "never")
|
|
1403
|
+
never = true;
|
|
1404
|
+
else if (verdict.kind === "null")
|
|
1405
|
+
nullOnly = true;
|
|
1406
|
+
else if (verdict.kind === "merge")
|
|
1407
|
+
merge = true;
|
|
1408
|
+
else {
|
|
1409
|
+
constrainedBy.push(...verdict.constrainedBy);
|
|
1410
|
+
nonNull ||= verdict.nonNull;
|
|
1411
|
+
}
|
|
759
1412
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1413
|
+
if (never)
|
|
1414
|
+
return { ...position, never: true };
|
|
1415
|
+
if (merge) {
|
|
1416
|
+
const withAt = (declaration, index) => {
|
|
1417
|
+
const part = partOfProperty(program, declaration);
|
|
1418
|
+
const at = atOf(index);
|
|
1419
|
+
return part === undefined || at === undefined ? part : { ...part, at };
|
|
1420
|
+
};
|
|
1421
|
+
const value = mergeParts(program, [
|
|
1422
|
+
...[own, ...ancestors].map(withAt),
|
|
1423
|
+
...(options.injected ? [stringPart(program)] : []),
|
|
1424
|
+
...beside,
|
|
1425
|
+
]);
|
|
1426
|
+
const refusal = refusalIn(value);
|
|
1427
|
+
return {
|
|
1428
|
+
...position,
|
|
1429
|
+
value,
|
|
1430
|
+
never: value.kind === "never",
|
|
1431
|
+
refused: refusal === undefined
|
|
1432
|
+
? undefined
|
|
1433
|
+
: {
|
|
1434
|
+
ancestor: ancestors[0] ?? own,
|
|
1435
|
+
why: refusal.why,
|
|
1436
|
+
recursive: refusal.recursive === true,
|
|
1437
|
+
},
|
|
1438
|
+
};
|
|
772
1439
|
}
|
|
1440
|
+
let stringOnly = false;
|
|
1441
|
+
if (options.injected) {
|
|
1442
|
+
const types = valueJsonTypesOf(program, own);
|
|
1443
|
+
if (types === undefined)
|
|
1444
|
+
stringOnly = true;
|
|
1445
|
+
else if (!types.has("string"))
|
|
1446
|
+
never = true;
|
|
1447
|
+
else
|
|
1448
|
+
stringOnly = [...types].some((type) => type !== "string");
|
|
1449
|
+
nonNull ||= admitsNull(program, own);
|
|
1450
|
+
}
|
|
1451
|
+
return { ...position, constrainedBy, stringOnly, never, nullOnly, nonNull };
|
|
1452
|
+
}
|
|
1453
|
+
/** Whether a declaration requires its key at the current position. */
|
|
1454
|
+
function isRequiredAt(program, property) {
|
|
1455
|
+
return !(property.optional || atDeclaration(program, property, () => isOptionalAt(program, property)));
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* **A declaration is judged at the visibility its own model is rendered at**, as openapi3 renders
|
|
1459
|
+
* each model of a chain: a subtype's create body is `allOf` its base, and a base with nothing
|
|
1460
|
+
* create-specific is the canonical schema - its read-only `id` shared in, `readOnly` and required.
|
|
1461
|
+
* Judged at the subtype's visibility, the base's `id` dropped out of the create body the document
|
|
1462
|
+
* requires it in. See `renderedVisibilityOf`.
|
|
1463
|
+
*/
|
|
1464
|
+
/** At the visibility a merged member's declaration is published at, where it is not the merge's. */
|
|
1465
|
+
export function atPosition(position, run) {
|
|
1466
|
+
return position.at === undefined ? run() : withVisibility(position.at, run);
|
|
1467
|
+
}
|
|
1468
|
+
export function atDeclaration(program, property, run) {
|
|
1469
|
+
const model = property.model;
|
|
1470
|
+
// Of several views under one name, the one the document keeps - see `keptVisibilityOf`.
|
|
1471
|
+
return model === undefined
|
|
1472
|
+
? run()
|
|
1473
|
+
: withVisibility(keptVisibilityOf(model, renderedVisibilityOf(program, model, visibility)), run);
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* **A property's type is referred to at the visibility openapi3 reads its declaration's properties
|
|
1477
|
+
* at** - see `readVisibilityOf` - where the declaration is rendered at the current one. Its
|
|
1478
|
+
* optionality and its position stay where the model is published; only what the type is named, a
|
|
1479
|
+
* list or a union instance openapi3 does not reduce, can differ.
|
|
1480
|
+
*/
|
|
1481
|
+
export function atReading(program, property, run) {
|
|
1482
|
+
const model = property.model;
|
|
1483
|
+
if (model === undefined)
|
|
1484
|
+
return run();
|
|
1485
|
+
const read = readVisibilityOf(program, model, visibility, metadataIgnored);
|
|
1486
|
+
return read === visibility ? run() : withVisibility(read, run);
|
|
1487
|
+
}
|
|
1488
|
+
/** Whether a declaration's value may be `null`, as the document publishes it. */
|
|
1489
|
+
function admitsNull(program, property) {
|
|
1490
|
+
const encoding = encodedValueOf(program, property);
|
|
1491
|
+
if (encoding.kind === "replaced" || encoding.kind === "both")
|
|
1492
|
+
return encoding.nullable;
|
|
1493
|
+
if (encoding.kind === "unsatisfiable")
|
|
1494
|
+
return false;
|
|
1495
|
+
return admitsNullValue(program, property.type, exclusiveAt(program, property));
|
|
1496
|
+
}
|
|
1497
|
+
/** Whether a property's value is a `@oneOf`: its union's own, or its own on its inline union. */
|
|
1498
|
+
function exclusiveAt(program, property) {
|
|
1499
|
+
const type = property.type;
|
|
1500
|
+
return (isExclusive(program, type) ||
|
|
1501
|
+
(isExclusive(program, property) && type.kind === "Union" && type.name === undefined));
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* **`T | null` read as `T` beside its `null`** - unless it is a `@oneOf` whose `T` takes `null`
|
|
1505
|
+
* too, which then refuses it: `@oneOf unknown | null` is `oneOf [{}, null]`, and no `null` passes.
|
|
1506
|
+
*/
|
|
1507
|
+
function peeledNullOf(program, property) {
|
|
1508
|
+
const inner = nullableInnerOf(property.type);
|
|
1509
|
+
return inner !== undefined && exclusiveAt(program, property) && admitsNullValue(program, inner)
|
|
1510
|
+
? undefined
|
|
1511
|
+
: inner;
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* **Whether two lists' elements are published at visibilities their types differ at**: a list
|
|
1515
|
+
* literal's element at `Item`, a named list's at the list's own. Under a PATCH, `u: El[]` over
|
|
1516
|
+
* `u: Els` holds each element to `El` and to `ElUpdate`, and taken as narrowed by the element type
|
|
1517
|
+
* alone it was `El`, sealed against the `c` that `InnerUpdate` refuses.
|
|
1518
|
+
*/
|
|
1519
|
+
function elementsPublishedApart(program, own, ownAt, ancestor, ancestorAt) {
|
|
1520
|
+
const elementOf = (list, at) => {
|
|
1521
|
+
if (list.kind !== "Model" || effectiveIndexer(list)?.key.name !== "integer")
|
|
1522
|
+
return undefined;
|
|
1523
|
+
const element = effectiveIndexer(list).value;
|
|
1524
|
+
return { element, at: isArrayLiteral(program, list) ? at | Visibility.Item : at };
|
|
1525
|
+
};
|
|
1526
|
+
const one = elementOf(own, ownAt);
|
|
1527
|
+
const other = elementOf(ancestor, ancestorAt);
|
|
1528
|
+
if (one === undefined || other === undefined || one.at === other.at)
|
|
1529
|
+
return false;
|
|
1530
|
+
return [one.element, other.element].some((element) => renderedVisibilityOf(program, element, one.at) !==
|
|
1531
|
+
renderedVisibilityOf(program, element, other.at));
|
|
1532
|
+
}
|
|
1533
|
+
/** How one declaration above an overriding one bears on its value - see {@link PropertyPosition}. */
|
|
1534
|
+
function overrideVerdictOf(program, own, ancestor) {
|
|
1535
|
+
if (disjoint(program, own, ancestor))
|
|
1536
|
+
return { kind: "never" };
|
|
1537
|
+
const ancestorEncoding = encodedValueOf(program, ancestor);
|
|
1538
|
+
if (ancestorEncoding.kind === "unsatisfiable")
|
|
1539
|
+
return { kind: "never" };
|
|
1540
|
+
const ownEncoding = encodedValueOf(program, own);
|
|
1541
|
+
// An encoding that keeps its declared type beside another holds the value to both, whole.
|
|
1542
|
+
if (ownEncoding.kind === "both" || ancestorEncoding.kind === "both")
|
|
1543
|
+
return { kind: "merge" };
|
|
773
1544
|
/**
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
* `...Record<unknown>` is the standard TypeSpec construct and the one `@typespec/openapi3` can
|
|
777
|
-
* see, so it is the only source. There was a second - `@loose`, a decorator of ours, invisible to
|
|
778
|
-
* the document - and it is gone: a model that accepted unknown properties while the published
|
|
779
|
-
* schema said otherwise is a runtime disagreeing with its own contract.
|
|
780
|
-
*
|
|
781
|
-
* An indexer over something other than `unknown` is a typed catchall, where `.loose()` would drop
|
|
782
|
-
* that value type on the floor.
|
|
783
|
-
*/
|
|
784
|
-
/**
|
|
785
|
-
* **`Record<never>` is how a spec says "and nothing else", not a value type to walk.**
|
|
786
|
-
*
|
|
787
|
-
* `model A { ...Record<never>; name: string }` gives an indexer whose value is the `never`
|
|
788
|
-
* intrinsic. Read as an ordinary typed catchall it became `.catchall(z.never())`, which reached
|
|
789
|
-
* `typeToZod` and refused the whole compile - while `@typespec/openapi3` publishes the same model
|
|
790
|
-
* cleanly as `additionalProperties: {not: {}}`, with no diagnostic at all. `isSealed` already reads
|
|
791
|
-
* a `never` indexer as sealed; this stops the catchall branch consuming it first.
|
|
792
|
-
*/
|
|
793
|
-
const indexerValue = indexerKey === "string" && indexer !== undefined && !isNeverType(indexer.value)
|
|
794
|
-
? indexer.value
|
|
795
|
-
: undefined;
|
|
796
|
-
const suffix = indexerValue !== undefined && isUnknownType(indexerValue)
|
|
797
|
-
? ".loose()"
|
|
798
|
-
: indexerValue === undefined
|
|
799
|
-
? ""
|
|
800
|
-
: `.catchall(${withDeclarationSite(model, () => typeToZod(program, indexerValue))})`;
|
|
801
|
-
/**
|
|
802
|
-
* A request model REJECTS an unrecognised field rather than stripping it.
|
|
803
|
-
*
|
|
804
|
-
* Silently dropping something a caller sent is the same failure this whole surface exists to
|
|
805
|
-
* remove, one layer out: the request succeeds and does less than it was asked to, with nothing
|
|
806
|
-
* anywhere reporting it. `appointedOn` went missing for exactly that reason and surfaced as a
|
|
807
|
-
* confusing downstream 400 instead of a loud one naming the key.
|
|
1545
|
+
* `null` is a value of its own: no keyword applies to it, and it is valid on both sides only
|
|
1546
|
+
* where both admit it. An override of `null` alone narrows an ancestor admitting it.
|
|
808
1547
|
*/
|
|
809
|
-
const
|
|
1548
|
+
const ownNull = admitsNull(program, own);
|
|
1549
|
+
const ancestorNull = admitsNull(program, ancestor);
|
|
1550
|
+
const nonNull = ownNull && !ancestorNull;
|
|
1551
|
+
const implied = { kind: "implied", constrainedBy: [], nonNull };
|
|
1552
|
+
if (isNull(own.type))
|
|
1553
|
+
return ancestorNull ? implied : { kind: "never" };
|
|
810
1554
|
/**
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
* `@header("content-type") contentType: "application/problem+json"` on the problem model, the
|
|
817
|
-
* document correctly described a body without it, and this emitter produced
|
|
818
|
-
* `contentType: z.literal(...)` as a required member. Two artefacts from one source, disagreeing
|
|
819
|
-
* about the shape of a body - the exact contradiction ADR 0005 exists to make impossible.
|
|
1555
|
+
* A literal is judged now, value by value: against the ancestor's constraints and its declared
|
|
1556
|
+
* type, as JSON Schema would. Every value admitted leaves nothing to apply; none admitted leaves
|
|
1557
|
+
* `null` where both sides allow it, and no value otherwise. Values some of which the ancestor's
|
|
1558
|
+
* own keywords refuse are narrowed member by member - see `constrainedMembersOf` - and values its
|
|
1559
|
+
* TYPE refuses are merged out.
|
|
820
1560
|
*/
|
|
1561
|
+
const literals = literalValuesOfType(own.type);
|
|
1562
|
+
if (literals !== undefined) {
|
|
1563
|
+
const ancestorType = ancestorEncoding.kind === "replaced" ? ancestorEncoding.scalar : ancestor.type;
|
|
1564
|
+
const byType = literals.filter((value) => admitsValue(program, ancestorType, value));
|
|
1565
|
+
const admitted = byType.filter((value) => brokenConstraintOf(program, ancestor, value) === undefined);
|
|
1566
|
+
if (admitted.length === literals.length)
|
|
1567
|
+
return implied;
|
|
1568
|
+
if (admitted.length === 0) {
|
|
1569
|
+
return ownNull && ancestorNull ? { kind: "null" } : { kind: "never" };
|
|
1570
|
+
}
|
|
1571
|
+
if (byType.length === literals.length) {
|
|
1572
|
+
return { kind: "implied", constrainedBy: [ancestor], nonNull };
|
|
1573
|
+
}
|
|
1574
|
+
return { kind: "merge" };
|
|
1575
|
+
}
|
|
1576
|
+
const ownType = ownEncoding.kind === "replaced" ? ownEncoding.scalar : (peeledNullOf(program, own) ?? own.type);
|
|
1577
|
+
const ancestorType = ancestorEncoding.kind === "replaced"
|
|
1578
|
+
? ancestorEncoding.scalar
|
|
1579
|
+
: (peeledNullOf(program, ancestor) ?? ancestor.type);
|
|
1580
|
+
const narrowed = narrowsTo(program, ownType, ancestorType);
|
|
1581
|
+
if (narrowed === undefined)
|
|
1582
|
+
return { kind: "merge" };
|
|
1583
|
+
return {
|
|
1584
|
+
kind: "implied",
|
|
1585
|
+
constrainedBy: [...(hasConstraints(program, ancestor) ? [ancestor] : []), ...narrowed],
|
|
1586
|
+
nonNull,
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* **Whether every value of `own` is a value of `ancestor`**, and the declared scalars whose rules
|
|
1591
|
+
* are left to apply - or `undefined` where neither one validator can say. A scalar narrows by
|
|
1592
|
+
* TypeSpec's own assignability, plus the rules of any declared scalar above it; a model only by
|
|
1593
|
+
* extending; a list by its element; a union where some member does.
|
|
1594
|
+
*/
|
|
1595
|
+
function narrowsTo(program, own, ancestor) {
|
|
1596
|
+
if (own === ancestor)
|
|
1597
|
+
return [];
|
|
1598
|
+
if (own.kind === "UnionVariant")
|
|
1599
|
+
return narrowsTo(program, own.type, ancestor);
|
|
1600
|
+
if (ancestor.kind === "UnionVariant")
|
|
1601
|
+
return narrowsTo(program, own, ancestor.type);
|
|
1602
|
+
// A union with an envelope is published as its wrappers, which no variant type is.
|
|
1603
|
+
if (isEnveloped(program, own) || isEnveloped(program, ancestor))
|
|
1604
|
+
return undefined;
|
|
1605
|
+
// A literal narrows whatever admits its value, read statically.
|
|
1606
|
+
const value = literalValueOf(own);
|
|
1607
|
+
if (value !== undefined)
|
|
1608
|
+
return admitsValue(program, ancestor, value) ? [] : undefined;
|
|
1609
|
+
if (own.kind === "Union") {
|
|
1610
|
+
const scalars = [];
|
|
1611
|
+
for (const member of membersOf(own)) {
|
|
1612
|
+
// A `null` the ancestor refuses - a list's element, a record's value - is merged out.
|
|
1613
|
+
if (isNull(member)) {
|
|
1614
|
+
if (hasNull(ancestor) || isTypelessMember(program, ancestor))
|
|
1615
|
+
continue;
|
|
1616
|
+
return undefined;
|
|
1617
|
+
}
|
|
1618
|
+
const each = narrowsTo(program, member, ancestor);
|
|
1619
|
+
if (each === undefined)
|
|
1620
|
+
return undefined;
|
|
1621
|
+
scalars.push(...each);
|
|
1622
|
+
}
|
|
1623
|
+
return scalars;
|
|
1624
|
+
}
|
|
1625
|
+
switch (ancestor.kind) {
|
|
1626
|
+
case "Scalar": {
|
|
1627
|
+
if (own.kind !== "Scalar")
|
|
1628
|
+
return undefined;
|
|
1629
|
+
if (!isAssignable(program, own, ancestor) && !checksNoMoreThan(program, own, ancestor)) {
|
|
1630
|
+
return undefined;
|
|
1631
|
+
}
|
|
1632
|
+
return program.checker.isStdType(ancestor) ? [] : [ancestor];
|
|
1633
|
+
}
|
|
1634
|
+
case "Tuple": {
|
|
1635
|
+
if (own.kind !== "Tuple" || own.values.length !== ancestor.values.length)
|
|
1636
|
+
return undefined;
|
|
1637
|
+
const each = own.values.map((value, index) => narrowsTo(program, value, ancestor.values[index]));
|
|
1638
|
+
return each.every((value) => value !== undefined && value.length === 0) ? [] : undefined;
|
|
1639
|
+
}
|
|
1640
|
+
case "Model": {
|
|
1641
|
+
// A record of what narrows the ancestor's record's values, with no rules of its own to add.
|
|
1642
|
+
if (ancestor.indexer?.key.name === "string" &&
|
|
1643
|
+
ancestor.properties.size === 0 &&
|
|
1644
|
+
own.kind === "Model" &&
|
|
1645
|
+
own.indexer?.key.name === "string" &&
|
|
1646
|
+
own.properties.size === 0) {
|
|
1647
|
+
const value = narrowsTo(program, own.indexer.value, ancestor.indexer.value);
|
|
1648
|
+
return value !== undefined && value.length === 0 ? [] : undefined;
|
|
1649
|
+
}
|
|
1650
|
+
const ancestorElement = ancestor.indexer?.key.name === "integer" ? ancestor.indexer.value : undefined;
|
|
1651
|
+
if (ancestorElement !== undefined) {
|
|
1652
|
+
const ownElement = own.kind === "Model" && own.indexer?.key.name === "integer"
|
|
1653
|
+
? own.indexer.value
|
|
1654
|
+
: undefined;
|
|
1655
|
+
if (ownElement === undefined)
|
|
1656
|
+
return undefined;
|
|
1657
|
+
// An element's own rules cannot be added to a list's: only a list of what it already is.
|
|
1658
|
+
const element = narrowsTo(program, ownElement, ancestorElement);
|
|
1659
|
+
if (element === undefined || element.length > 0)
|
|
1660
|
+
return undefined;
|
|
1661
|
+
// A named list model's own `@minItems` and `@maxItems` still hold - `b: Duo` under `Pair`.
|
|
1662
|
+
return hasConstraints(program, ancestor) ? [ancestor] : [];
|
|
1663
|
+
}
|
|
1664
|
+
for (let link = own.kind === "Model" ? own : undefined; link; link = link.baseModel) {
|
|
1665
|
+
if (link === ancestor)
|
|
1666
|
+
return [];
|
|
1667
|
+
}
|
|
1668
|
+
return undefined;
|
|
1669
|
+
}
|
|
1670
|
+
/**
|
|
1671
|
+
* **Into a union, only where the member it narrows says everything left to apply.** A member it
|
|
1672
|
+
* narrows wholly - itself, or a standard scalar - leaves nothing; ONE member it narrows with
|
|
1673
|
+
* rules of its own leaves those. Two such members are two alternatives - `Digits2` under
|
|
1674
|
+
* `Lower | Digits` is a `Digits2` that is a `Lower` OR a `Digits` - which no list of rules says,
|
|
1675
|
+
* so the declarations are merged member by member instead. Taking the first, `Digits2` was held
|
|
1676
|
+
* to `Lower`'s pattern and `Digits`' at once, and accepted nothing.
|
|
1677
|
+
*/
|
|
1678
|
+
case "Union":
|
|
1679
|
+
case "Enum": {
|
|
1680
|
+
// Into `@oneOf`, a value of the override may match two members, which the document refuses.
|
|
1681
|
+
if (isExclusive(program, ancestor))
|
|
1682
|
+
return undefined;
|
|
1683
|
+
/**
|
|
1684
|
+
* **Into a `@discriminated` union - a `oneOf` too - only where its discriminator tells every
|
|
1685
|
+
* variant apart.** Where one may leave it out - `kind?`, or any under a PATCH - `{}` is an `NA`
|
|
1686
|
+
* and an `NB`, which the document refuses: narrowed to `NA` under `DN`, it was accepted.
|
|
1687
|
+
*/
|
|
1688
|
+
if (ancestor.kind === "Union") {
|
|
1689
|
+
const [discriminated] = getDiscriminatedUnion(program, ancestor);
|
|
1690
|
+
if (discriminated !== undefined &&
|
|
1691
|
+
[...discriminated.variants.values()].some((variant) => optionalDiscriminator(program, ancestor, variant, discriminated.options))) {
|
|
1692
|
+
return undefined;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
const members = alikeMembersOf(program, ancestor);
|
|
1696
|
+
const narrowed = members.map((member) => narrowsTo(program, own, member));
|
|
1697
|
+
if (narrowed.some((each) => each !== undefined && each.length === 0))
|
|
1698
|
+
return [];
|
|
1699
|
+
const narrowing = narrowed.flatMap((each, index) => (each === undefined ? [] : [index]));
|
|
1700
|
+
if (narrowing.length !== 1)
|
|
1701
|
+
return undefined;
|
|
1702
|
+
const [index] = narrowing;
|
|
1703
|
+
/**
|
|
1704
|
+
* The one member's rules say everything only where no other member takes a value of it too:
|
|
1705
|
+
* `string` under `"green" | Short` is `"green"` OR a `Short`, and held to `Short`'s
|
|
1706
|
+
* `@maxLength` alone it refused the `"green"` both declarations accept.
|
|
1707
|
+
*/
|
|
1708
|
+
return members.some((member, other) => other !== index && overlaps(program, own, member))
|
|
1709
|
+
? undefined
|
|
1710
|
+
: narrowed[index];
|
|
1711
|
+
}
|
|
1712
|
+
case "Intrinsic":
|
|
1713
|
+
return ancestor.name === "unknown" ? [] : undefined;
|
|
1714
|
+
default:
|
|
1715
|
+
return undefined;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Whether a value of `own` may be a value of `member` too, as far as their JSON types and a literal
|
|
1720
|
+
* tell - taken as so where either has no type to tell by.
|
|
1721
|
+
*/
|
|
1722
|
+
function overlaps(program, own, member) {
|
|
1723
|
+
const value = literalValueOf(member);
|
|
1724
|
+
if (value !== undefined)
|
|
1725
|
+
return admitsValue(program, own, value);
|
|
1726
|
+
const ownTypes = jsonTypesOf(program, own);
|
|
1727
|
+
const memberTypes = jsonTypesOf(program, member);
|
|
1728
|
+
if (ownTypes === undefined || memberTypes === undefined)
|
|
1729
|
+
return true;
|
|
1730
|
+
const numeric = (type) => type === "integer" || type === "number" || type === "fraction";
|
|
1731
|
+
return [...ownTypes].some((one) => [...memberTypes].some((other) => one === other || (numeric(one) && numeric(other))));
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* **Whether a scalar's own check says everything another's does**, where TypeSpec's assignability
|
|
1735
|
+
* does not: `int32` under `float`, whose schema is only `type: number`, and `offsetDateTime` under
|
|
1736
|
+
* `utcDateTime`, which are checked alike. The other's own keywords are still applied, as the rules
|
|
1737
|
+
* of a declared scalar above it.
|
|
1738
|
+
*/
|
|
1739
|
+
function checksNoMoreThan(program, own, ancestor) {
|
|
1740
|
+
if (scalarEncodingOf(program, own) !== undefined)
|
|
1741
|
+
return false;
|
|
1742
|
+
if (scalarEncodingOf(program, ancestor) !== undefined)
|
|
1743
|
+
return false;
|
|
1744
|
+
const ownBase = standardBaseOf(program, own);
|
|
1745
|
+
const ancestorBase = standardBaseOf(program, ancestor);
|
|
1746
|
+
if (ownBase === undefined || ancestorBase === undefined)
|
|
1747
|
+
return false;
|
|
1748
|
+
const ownCheck = SCALARS[ownBase.name];
|
|
1749
|
+
const ancestorCheck = SCALARS[ancestorBase.name];
|
|
1750
|
+
if (ownCheck === undefined || ancestorCheck === undefined)
|
|
1751
|
+
return false;
|
|
1752
|
+
if (ownCheck === ancestorCheck)
|
|
1753
|
+
return true;
|
|
1754
|
+
const json = jsonTypeOfScalar(program, ancestorBase);
|
|
1755
|
+
return (json !== undefined && ancestorCheck === BARE_CHECKS[json] && isWithin(program, ownBase, json));
|
|
1756
|
+
}
|
|
1757
|
+
/** The schema that checks a JSON type and nothing more. */
|
|
1758
|
+
const BARE_CHECKS = {
|
|
1759
|
+
string: "z.string()",
|
|
1760
|
+
number: "z.number()",
|
|
1761
|
+
boolean: "z.boolean()",
|
|
1762
|
+
};
|
|
1763
|
+
/** The standard scalar a scalar is built on. */
|
|
1764
|
+
function standardBaseOf(program, scalar) {
|
|
1765
|
+
for (let link = scalar; link !== undefined; link = link.baseScalar) {
|
|
1766
|
+
if (program.checker.isStdType(link))
|
|
1767
|
+
return link;
|
|
1768
|
+
}
|
|
1769
|
+
return undefined;
|
|
1770
|
+
}
|
|
1771
|
+
/** A `@discriminated` union with an envelope, whose members the document publishes as wrappers. */
|
|
1772
|
+
function isEnveloped(program, type) {
|
|
1773
|
+
if (type.kind !== "Union")
|
|
1774
|
+
return false;
|
|
1775
|
+
const [discriminated] = getDiscriminatedUnion(program, type);
|
|
1776
|
+
return discriminated !== undefined && discriminated.options.envelope !== "none";
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* **Whether a union reaches itself through union members alone**: `union string { int32, string }`
|
|
1780
|
+
* in a namespace, where the member `string` names the union and not the scalar, or `A { int32, B }`
|
|
1781
|
+
* beside `B { string, A }`. No property or list element stands between the union and its own
|
|
1782
|
+
* reference, so the schema openapi3 publishes, `anyOf: [int32, { $ref: string }]`, refers to itself
|
|
1783
|
+
* with no value passed through on the way, and JSON Schema leaves that undefined - measured, Ajv
|
|
1784
|
+
* overflows its stack on `5`, `"abc"` and `null` alike. TypeScript refuses the alias it would be,
|
|
1785
|
+
* and the walks here that flatten a union recursed until the compiler reported "Emitter crashed!
|
|
1786
|
+
* This is a bug." A union with an envelope wraps each member in an object, which a value does pass
|
|
1787
|
+
* through, so the walk stops there.
|
|
1788
|
+
*/
|
|
1789
|
+
export function reachesItselfThroughUnions(program, union) {
|
|
1790
|
+
const seen = new Set();
|
|
1791
|
+
const visit = (each) => {
|
|
1792
|
+
if (isEnveloped(program, each) || seen.has(each))
|
|
1793
|
+
return false;
|
|
1794
|
+
seen.add(each);
|
|
1795
|
+
return [...each.variants.values()].some(({ type }) => {
|
|
1796
|
+
let member = type;
|
|
1797
|
+
while (member.kind === "UnionVariant")
|
|
1798
|
+
member = member.type;
|
|
1799
|
+
return member === union || (member.kind === "Union" && visit(member));
|
|
1800
|
+
});
|
|
1801
|
+
};
|
|
1802
|
+
return visit(union);
|
|
1803
|
+
}
|
|
1804
|
+
/** Refuse a union that reaches itself through union members alone - see `reachesItselfThroughUnions`. */
|
|
1805
|
+
export function refuseSelfReachingUnion(program, union) {
|
|
1806
|
+
refuse(program, union, `its members reach '${getTypeName(union)}' again through unions alone, so the schema '@typespec/openapi3' publishes for it refers to itself with no value passed through on the way, which JSON Schema leaves undefined`, "If a member is meant to name another type, qualify it, as 'TypeSpec.string' names the scalar; if the recursion is meant, hold it in a model property or a list.");
|
|
1807
|
+
}
|
|
1808
|
+
/** TypeSpec's own assignability, which an override is checked against and a rename is not. */
|
|
1809
|
+
export function isAssignable(program, source, target) {
|
|
1810
|
+
return $(program).type.isAssignableTo(source, target);
|
|
1811
|
+
}
|
|
1812
|
+
function isNull(type) {
|
|
1813
|
+
return type.kind === "Intrinsic" && type.name === "null";
|
|
1814
|
+
}
|
|
1815
|
+
/** Whether a type admits `null`: it is `null`, or a union with `null` among its members. */
|
|
1816
|
+
function hasNull(type) {
|
|
1817
|
+
return membersOf(type).some(isNull);
|
|
1818
|
+
}
|
|
1819
|
+
/** The fixed values of a type that is literals and nothing else, `null` aside; else `undefined`. */
|
|
1820
|
+
function literalValuesOfType(type) {
|
|
1821
|
+
const members = membersOf(type).filter((member) => !(member.kind === "Intrinsic" && member.name === "null"));
|
|
1822
|
+
const values = members.map(literalValueOf);
|
|
1823
|
+
return values.length > 0 && values.every((value) => value !== undefined)
|
|
1824
|
+
? values
|
|
1825
|
+
: undefined;
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Whether a declared type admits a literal value.
|
|
1829
|
+
*
|
|
1830
|
+
* **A scalar admits what the check it emits admits** - see `scalarAccepts` - not every value of its
|
|
1831
|
+
* JSON type: `"red"` is a string and no `utcDateTime`. Read by type alone, `@oneOf { Color, string }`
|
|
1832
|
+
* merged with `k: utcDateTime` kept `"red"` and `"green"` as members of their own, and a value the
|
|
1833
|
+
* document finds in two members was accepted as one.
|
|
1834
|
+
*/
|
|
1835
|
+
function admitsValue(program, type, value) {
|
|
1836
|
+
switch (type.kind) {
|
|
1837
|
+
case "UnionVariant":
|
|
1838
|
+
return admitsValue(program, type.type, value);
|
|
1839
|
+
case "Scalar": {
|
|
1840
|
+
const json = jsonTypeOfScalar(program, type);
|
|
1841
|
+
const kind = typeof value === "number"
|
|
1842
|
+
? Number.isInteger(value)
|
|
1843
|
+
? "integer"
|
|
1844
|
+
: "fraction"
|
|
1845
|
+
: typeof value;
|
|
1846
|
+
const fits = json === undefined ||
|
|
1847
|
+
json === kind ||
|
|
1848
|
+
(json === "number" && (kind === "integer" || kind === "fraction"));
|
|
1849
|
+
return (fits &&
|
|
1850
|
+
brokenConstraintOf(program, type, value) === undefined &&
|
|
1851
|
+
scalarAccepts(program, type, value) !== false);
|
|
1852
|
+
}
|
|
1853
|
+
case "String":
|
|
1854
|
+
case "Number":
|
|
1855
|
+
case "Boolean":
|
|
1856
|
+
return type.value === value;
|
|
1857
|
+
case "EnumMember":
|
|
1858
|
+
return (type.value ?? type.name) === value;
|
|
1859
|
+
case "Enum":
|
|
1860
|
+
return membersOf(type).some((member) => admitsValue(program, member, value));
|
|
1861
|
+
case "Union": {
|
|
1862
|
+
// Under `@oneOf`, a value two members admit is no value of the union.
|
|
1863
|
+
const admitting = alikeMembersOf(program, type).filter((member) => admitsValue(program, member, value));
|
|
1864
|
+
return isExclusive(program, type) ? admitting.length === 1 : admitting.length > 0;
|
|
1865
|
+
}
|
|
1866
|
+
case "Intrinsic":
|
|
1867
|
+
return type.name === "unknown";
|
|
1868
|
+
default:
|
|
1869
|
+
return false;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
/** Whether two declarations of one key share no JSON type, so no value is both. */
|
|
1873
|
+
function disjoint(program, one, other) {
|
|
1874
|
+
const a = valueJsonTypesOf(program, one);
|
|
1875
|
+
const b = valueJsonTypesOf(program, other);
|
|
1876
|
+
if (a === undefined || b === undefined)
|
|
1877
|
+
return false;
|
|
1878
|
+
const numeric = (type) => type === "integer" || type === "number" || type === "fraction";
|
|
1879
|
+
return ![...a].some((x) => [...b].some((y) => x === y || (numeric(x) && numeric(y))));
|
|
1880
|
+
}
|
|
1881
|
+
/** The JSON types a property's value is published as, `@encode` included; `undefined` for any. */
|
|
1882
|
+
function valueJsonTypesOf(program, property) {
|
|
1883
|
+
const encoding = encodedValueOf(program, property);
|
|
1884
|
+
if (encoding.kind === "replaced" || encoding.kind === "both") {
|
|
1885
|
+
const json = jsonTypeOfScalar(program, encoding.scalar);
|
|
1886
|
+
if (json === undefined)
|
|
1887
|
+
return undefined;
|
|
1888
|
+
return new Set(encoding.nullable ? [json, "null"] : [json]);
|
|
1889
|
+
}
|
|
1890
|
+
return jsonTypesOf(program, property.type);
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* The discriminators openapi3 adds to a model as a required `string`: each `@discriminator` in the
|
|
1894
|
+
* chain whose model's OWN payload properties lack it.
|
|
1895
|
+
*/
|
|
1896
|
+
function injectedDiscriminatorsOf(program, model) {
|
|
1897
|
+
const names = [];
|
|
1898
|
+
for (let link = model; link !== undefined; link = link.baseModel) {
|
|
1899
|
+
const discriminator = getDiscriminator(program, link);
|
|
1900
|
+
if (discriminator === undefined)
|
|
1901
|
+
continue;
|
|
1902
|
+
// Read where openapi3 reads the model's properties: a read-only `kind` sent in a create body is not.
|
|
1903
|
+
const declared = [...link.properties.values()].some((property) => atPublication(program, property, () => isPayloadProperty(program, property)) &&
|
|
1904
|
+
!isNeverType(property.type) &&
|
|
1905
|
+
rawPropertyKey(program, property) === discriminator.propertyName);
|
|
1906
|
+
if (!declared)
|
|
1907
|
+
names.push(discriminator.propertyName);
|
|
1908
|
+
}
|
|
1909
|
+
return names;
|
|
1910
|
+
}
|
|
1911
|
+
const NOTHING = { kind: "never" };
|
|
1912
|
+
/** No value because a declaration is refused - see `HeldDeclarations.refusedAt`. */
|
|
1913
|
+
const WITHHELD = { kind: "never", withheld: true };
|
|
1914
|
+
/** The part a property publishes: its declared type, or its encoded one, with its keywords. */
|
|
1915
|
+
function partOfProperty(program, property) {
|
|
1916
|
+
const encoding = encodedValueOf(program, property);
|
|
1917
|
+
switch (encoding.kind) {
|
|
1918
|
+
case "unsatisfiable":
|
|
1919
|
+
return undefined;
|
|
1920
|
+
case "both":
|
|
1921
|
+
return { members: [], nullable: false, keywords: [], whole: property };
|
|
1922
|
+
case "replaced":
|
|
1923
|
+
return {
|
|
1924
|
+
members: [encoding.scalar],
|
|
1925
|
+
nullable: encoding.nullable,
|
|
1926
|
+
keywords: [property],
|
|
1927
|
+
type: encoding.scalar,
|
|
1928
|
+
};
|
|
1929
|
+
case "declared":
|
|
1930
|
+
return partOfType(program, property.type, [property], exclusiveSiteOf(program, property, property.type) !== undefined);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* The part a declared type is, its members spelled out. `exclusiveHere` where the property declaring
|
|
1935
|
+
* it makes its inline union a `oneOf` - see `exclusiveSiteOf`: merged under a redeclaration, `@oneOf
|
|
1936
|
+
* u: X1 | X2` was an `anyOf`, and `{ u: {} }`, which both members match, was accepted.
|
|
1937
|
+
*/
|
|
1938
|
+
function partOfType(program, type, keywords, exclusiveHere = false) {
|
|
821
1939
|
/**
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
* `model InnerModel { children?: InnerModel[] }` is a plain recursive tree, and openapi3 publishes
|
|
825
|
-
* it as a self-`$ref`. Emitting `children: z.array(innerModelSchema)` reads the `const` inside its
|
|
826
|
-
* own initialiser, so the module throws on import; deferring the reference behind a getter is the
|
|
827
|
-
* idiom Zod documents for exactly this, and it is the only one that keeps `z.infer` inferring the
|
|
828
|
-
* recursive type rather than collapsing to `any`. `z.lazy(() => innerModelSchema)` runs correctly
|
|
829
|
-
* but does not typecheck - measured: `TS7022`, "implicitly has type 'any' because it does not have
|
|
830
|
-
* a type annotation and is referenced directly or indirectly in its own initializer".
|
|
1940
|
+
* A `@discriminated` union is published as `oneOf` too: its tag makes the members disjoint, until
|
|
1941
|
+
* a PATCH makes the tag optional, and `{ value: { a: "s" } }` then matches two open envelopes.
|
|
831
1942
|
*/
|
|
832
|
-
const
|
|
833
|
-
|
|
1943
|
+
const exclusive = exclusiveHere ||
|
|
1944
|
+
isExclusive(program, type) ||
|
|
1945
|
+
(type.kind === "Union" && getDiscriminatedUnion(program, type)[0] !== undefined);
|
|
1946
|
+
const members = [];
|
|
1947
|
+
/**
|
|
1948
|
+
* **How many members a `null` matches**, a nested union's included: kept whole, its `null` set
|
|
1949
|
+
* nothing, and `@oneOf { v0: float | null, v1: string }` refused the `null` it accepts. Under
|
|
1950
|
+
* `@oneOf` it is a value where exactly one member matches it.
|
|
1951
|
+
*/
|
|
1952
|
+
let nullMatches = 0;
|
|
1953
|
+
let refused;
|
|
1954
|
+
const visit = (each) => {
|
|
1955
|
+
if (each.kind === "UnionVariant")
|
|
1956
|
+
return visit(each.type);
|
|
1957
|
+
if (isNull(each)) {
|
|
1958
|
+
nullMatches += 1;
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
if (each.kind === "Union") {
|
|
1962
|
+
// A nested union is one member, whole, unless both are `anyOf` - see `alikeMembersOf`.
|
|
1963
|
+
if (each !== type && (exclusive || isPublishedOneOf(program, each))) {
|
|
1964
|
+
if (admitsNullValue(program, each))
|
|
1965
|
+
nullMatches += 1;
|
|
1966
|
+
members.push(each);
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
const [discriminated] = getDiscriminatedUnion(program, each);
|
|
1970
|
+
if (discriminated !== undefined && discriminated.defaultVariant !== undefined) {
|
|
1971
|
+
refused = "it is a discriminated union with a default variant";
|
|
1972
|
+
}
|
|
1973
|
+
if (discriminated !== undefined && discriminated.options.envelope !== "none") {
|
|
1974
|
+
for (const [name, variant] of discriminated.variants) {
|
|
1975
|
+
members.push({
|
|
1976
|
+
kind: "Envelope",
|
|
1977
|
+
union: each,
|
|
1978
|
+
name: String(name),
|
|
1979
|
+
variant,
|
|
1980
|
+
discriminator: discriminated.options.discriminatorPropertyName,
|
|
1981
|
+
property: discriminated.options.envelopePropertyName,
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
for (const variant of each.variants.values())
|
|
1987
|
+
visit(variant.type);
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
if (each.kind === "Enum") {
|
|
1991
|
+
members.push(...each.members.values());
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
if (isTypelessMember(program, each))
|
|
1995
|
+
nullMatches += 1;
|
|
1996
|
+
members.push(each);
|
|
1997
|
+
};
|
|
1998
|
+
visit(type);
|
|
1999
|
+
return {
|
|
2000
|
+
members: exclusive ? withoutRepeatedLiterals(members) : members,
|
|
2001
|
+
nullable: exclusive ? nullMatches === 1 : nullMatches > 0,
|
|
2002
|
+
keywords,
|
|
2003
|
+
type,
|
|
2004
|
+
...(refused === undefined ? {} : { refused }),
|
|
2005
|
+
...(exclusive ? { exclusive: true } : {}),
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
/** The `string` openapi3 adds as a discriminator where the model declaring it does not. */
|
|
2009
|
+
function stringPart(program) {
|
|
2010
|
+
const string = program.checker.getStdType("string");
|
|
2011
|
+
return { members: [string], nullable: false, keywords: [], type: string };
|
|
2012
|
+
}
|
|
2013
|
+
function isEnvelope(member) {
|
|
2014
|
+
return member.kind === "Envelope";
|
|
2015
|
+
}
|
|
2016
|
+
/** A part with no JSON type of its own, which asserts only its keywords on whatever the value is. */
|
|
2017
|
+
function isTypelessPart(program, part) {
|
|
2018
|
+
return (part.whole === undefined &&
|
|
2019
|
+
part.members.length > 0 &&
|
|
2020
|
+
// `@oneOf { a: unknown, b: unknown }` is two members, which every value matches: none passes.
|
|
2021
|
+
!(part.exclusive === true && part.members.length > 1) &&
|
|
2022
|
+
part.members.every((member) => !isEnvelope(member) && isTypelessMember(program, member)));
|
|
2023
|
+
}
|
|
2024
|
+
/** Merges in flight, so one that reaches itself again is refused rather than followed forever. */
|
|
2025
|
+
const mergesInFlight = [];
|
|
2026
|
+
export function mergeParts(program, given) {
|
|
2027
|
+
if (given.some((part) => part === undefined))
|
|
2028
|
+
return NOTHING;
|
|
2029
|
+
const parts = given;
|
|
2030
|
+
const refused = parts.find((part) => part.refused !== undefined)?.refused;
|
|
2031
|
+
if (refused !== undefined)
|
|
2032
|
+
return { kind: "refused", why: refused };
|
|
2033
|
+
const wholes = parts.filter((part) => part.whole !== undefined);
|
|
2034
|
+
if (wholes.length > 0) {
|
|
2035
|
+
const others = parts.filter((part) => part.whole === undefined);
|
|
2036
|
+
return {
|
|
2037
|
+
kind: "all",
|
|
2038
|
+
parts: [
|
|
2039
|
+
...wholes.map((part) => ({ kind: "whole", property: part.whole })),
|
|
2040
|
+
...(others.length === 0 ? [] : [mergeParts(program, others)]),
|
|
2041
|
+
],
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
const keywordsOnly = parts.filter((part) => isTypelessPart(program, part));
|
|
2045
|
+
const typed = parts.filter((part) => !isTypelessPart(program, part));
|
|
2046
|
+
const loose = keywordsOnly.flatMap((part) => part.keywords);
|
|
2047
|
+
if (typed.length === 0) {
|
|
834
2048
|
/**
|
|
835
|
-
* **A `
|
|
836
|
-
*
|
|
837
|
-
* `required: ["other"]` - `value` is absent entirely, because a property that can hold no value
|
|
838
|
-
* is not part of the payload.
|
|
839
|
-
*
|
|
840
|
-
* This emitter kept it and wrote `value: z.never()`, which made the model unsatisfiable: measured,
|
|
841
|
-
* `{"other":"x"}` - the exact body the document describes - was REJECTED with "expected never,
|
|
842
|
-
* received undefined". So the validator generated from a document rejected every request that
|
|
843
|
-
* document calls valid, which is the divergence class this package exists to prevent.
|
|
844
|
-
*
|
|
845
|
-
* Dropping it makes the two artefacts agree and leaves nothing to refuse. `never` in a position
|
|
846
|
-
* that is not a property - a body, a variant - is still refused by `typeToZodBody`, because there
|
|
847
|
-
* it is not something the document quietly omits.
|
|
2049
|
+
* **A `@oneOf` of a typeless member and `null` refuses the `null` both match**, and is its own
|
|
2050
|
+
* union, whole: `@oneOf { a: unknown, b: null }` beside `unknown` accepted `null` as keywords.
|
|
848
2051
|
*/
|
|
849
|
-
.
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
2052
|
+
const nonNull = keywordsOnly.find((part) => !part.nullable && part.type !== undefined);
|
|
2053
|
+
if (nonNull !== undefined) {
|
|
2054
|
+
return { kind: "type", type: nonNull.type, keywords: loose };
|
|
2055
|
+
}
|
|
2056
|
+
const first = keywordsOnly[0]?.members[0] ?? $(program).intrinsic.any;
|
|
2057
|
+
return { kind: "type", type: first, keywords: loose };
|
|
2058
|
+
}
|
|
2059
|
+
// A typeless part admits `null` unless it is such a `@oneOf`, whose refusal stands.
|
|
2060
|
+
const nullable = parts.every((part) => part.nullable);
|
|
2061
|
+
if (typed.some((part) => part.members.length === 0)) {
|
|
2062
|
+
return nullable ? nullValue(program) : NOTHING;
|
|
2063
|
+
}
|
|
2064
|
+
const [first, ...rest] = typed;
|
|
2065
|
+
const value = mergeNonNull(program, [
|
|
2066
|
+
{ ...first, keywords: [...first.keywords, ...loose] },
|
|
2067
|
+
...rest,
|
|
2068
|
+
]);
|
|
2069
|
+
return nullable ? orNullMerged(program, value) : value;
|
|
2070
|
+
}
|
|
2071
|
+
function nullValue(program) {
|
|
2072
|
+
return { kind: "type", type: $(program).intrinsic.null, keywords: [] };
|
|
2073
|
+
}
|
|
2074
|
+
function orNullMerged(program, value) {
|
|
2075
|
+
if (value.kind === "never")
|
|
2076
|
+
return nullValue(program);
|
|
2077
|
+
if (value.kind === "type" && hasNull(value.type))
|
|
2078
|
+
return value;
|
|
2079
|
+
return { kind: "nullable", inner: value };
|
|
2080
|
+
}
|
|
2081
|
+
/** Parts that each admit no `null`, and are each one or more members. */
|
|
2082
|
+
function mergeNonNull(program, parts) {
|
|
2083
|
+
/**
|
|
2084
|
+
* **A member with no type asserts only its keywords**, here as at the top of a merge: split out of
|
|
2085
|
+
* `@oneOf { v0: unknown, v1: string }` beside an envelope, `unknown` reached the intersection of two
|
|
2086
|
+
* scalars and was refused as an unknown kind, `Envelope`, where the value is the envelope.
|
|
2087
|
+
*/
|
|
2088
|
+
const typeless = parts.filter((part) => isTypelessPart(program, part));
|
|
2089
|
+
if (typeless.length > 0 && typeless.length < parts.length) {
|
|
2090
|
+
const [first, ...rest] = parts.filter((part) => !isTypelessPart(program, part));
|
|
2091
|
+
const loose = typeless.flatMap((part) => part.keywords);
|
|
2092
|
+
return mergeNonNull(program, [{ ...first, keywords: [...first.keywords, ...loose] }, ...rest]);
|
|
2093
|
+
}
|
|
2094
|
+
const keywords = parts.flatMap((part) => part.keywords);
|
|
2095
|
+
const [only] = parts;
|
|
2096
|
+
if (parts.length === 1 && only !== undefined && only.members.length === 1) {
|
|
2097
|
+
const member = only.members[0];
|
|
2098
|
+
return isEnvelope(member)
|
|
2099
|
+
? mergeObjects(program, [only])
|
|
2100
|
+
: memberValue(program, member, only.keywords, only.at);
|
|
2101
|
+
}
|
|
2102
|
+
if (disjointParts(program, parts))
|
|
2103
|
+
return NOTHING;
|
|
2104
|
+
// A part whose type every other part's admits is the value, every part's keywords on it.
|
|
2105
|
+
for (const candidate of parts) {
|
|
2106
|
+
const type = typeOfPart(candidate);
|
|
2107
|
+
if (type === undefined)
|
|
2108
|
+
continue;
|
|
2109
|
+
const scalars = [];
|
|
2110
|
+
const narrows = parts.every((other) => {
|
|
2111
|
+
if (other === candidate)
|
|
2112
|
+
return true;
|
|
2113
|
+
const target = typeOfPart(other);
|
|
2114
|
+
const each = target === undefined || !publishedAlike(program, target, candidate, other)
|
|
2115
|
+
? undefined
|
|
2116
|
+
: narrowsTo(program, type, target);
|
|
2117
|
+
if (each !== undefined)
|
|
2118
|
+
scalars.push(...each);
|
|
2119
|
+
return each !== undefined;
|
|
2120
|
+
});
|
|
2121
|
+
if (narrows)
|
|
2122
|
+
return memberValue(program, type, [...keywords, ...scalars], candidate.at);
|
|
2123
|
+
}
|
|
2124
|
+
/**
|
|
2125
|
+
* **A nested union kept whole is spelled out where it meets another declaration**, its members
|
|
2126
|
+
* held to the rest with its own exclusivity: `C` beside `@oneOf { v0: A | B, v1: string }` is
|
|
2127
|
+
* `C & A` or `C & B`. Left whole, the two objects were joined with `.and()`, which pooled the keys
|
|
2128
|
+
* a sealed side refuses and threw on the two defaults of `k`.
|
|
2129
|
+
*/
|
|
2130
|
+
const nested = parts.findIndex((part) => part.members.length === 1 &&
|
|
2131
|
+
!isEnvelope(part.members[0]) &&
|
|
2132
|
+
part.members[0].kind === "Union");
|
|
2133
|
+
if (nested !== -1) {
|
|
2134
|
+
const part = parts[nested];
|
|
2135
|
+
const spelled = partOfType(program, part.members[0], part.keywords);
|
|
2136
|
+
if (spelled.members.length === 0)
|
|
2137
|
+
return NOTHING;
|
|
2138
|
+
return mergeNonNull(program, parts.map((each, index) => index === nested ? { ...spelled, ...(part.at === undefined ? {} : { at: part.at }) } : each));
|
|
2139
|
+
}
|
|
2140
|
+
// A union is held to the rest member by member, and a member nothing else admits is dropped.
|
|
2141
|
+
const split = parts.findIndex((part) => part.members.length > 1);
|
|
2142
|
+
if (split !== -1) {
|
|
2143
|
+
const union = parts[split];
|
|
2144
|
+
const members = union.members.map((member) => mergeNonNull(program, parts.map((part, index) => index === split
|
|
2145
|
+
? {
|
|
2146
|
+
members: [member],
|
|
2147
|
+
nullable: false,
|
|
2148
|
+
keywords: union.keywords,
|
|
2149
|
+
...(union.at === undefined ? {} : { at: union.at }),
|
|
2150
|
+
}
|
|
2151
|
+
: part)));
|
|
853
2152
|
/**
|
|
854
|
-
* **A
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
* for the declaration itself, and not for a schema that WRAPS it: measured on the conformance
|
|
858
|
-
* corpus, `z.record(z.string(), innerModelSchema)` - what `@body body: Record<InnerModel>`
|
|
859
|
-
* emits - infers `children?: unknown`, and `.parse()` on it returns `unknown` outright. The
|
|
860
|
-
* generated server then failed to compile with `TS2322`, in `type/dictionary`, which is how
|
|
861
|
-
* this was found; nothing said the emitted TYPE was weaker than the document.
|
|
862
|
-
*
|
|
863
|
-
* Annotating the GETTER rather than the declaration is what keeps `.shape` and `.extend`,
|
|
864
|
-
* which inheritance and every group spread depend on. `: z.ZodType<T, T>` names both of Zod's
|
|
865
|
-
* parameters for the reason `renderSchemas` names both: one leaves the input side `unknown`.
|
|
2153
|
+
* **A `@oneOf` holding a refused member validates nothing**: the document still counts the
|
|
2154
|
+
* member, so a value it and another both match is refused there, and dropped here it was
|
|
2155
|
+
* accepted - as a merge holding a refused declaration holds nothing.
|
|
866
2156
|
*/
|
|
867
|
-
const
|
|
2157
|
+
const refusal = members.find(holdsRefusal);
|
|
2158
|
+
if (union.exclusive === true && refusal !== undefined)
|
|
2159
|
+
return heldRefusalOf(refusal);
|
|
2160
|
+
const kept = members.filter((member) => member.kind !== "never");
|
|
2161
|
+
const [one] = kept;
|
|
2162
|
+
if (one === undefined)
|
|
2163
|
+
return NOTHING;
|
|
2164
|
+
// A `@oneOf` union stays exclusive member by member: each merged member matches where its own did.
|
|
2165
|
+
if (kept.length === 1)
|
|
2166
|
+
return one;
|
|
2167
|
+
return union.exclusive === true
|
|
2168
|
+
? { kind: "union", members: kept, exclusive: true }
|
|
2169
|
+
: { kind: "union", members: kept };
|
|
2170
|
+
}
|
|
2171
|
+
const members = parts.map((part) => part.members[0]);
|
|
2172
|
+
const fixed = members.find((member) => !isEnvelope(member) && literalValueOf(member) !== undefined);
|
|
2173
|
+
if (fixed !== undefined) {
|
|
2174
|
+
const value = literalValueOf(fixed);
|
|
2175
|
+
const admitted = members.every((member) => !isEnvelope(member) && admitsValue(program, member, value));
|
|
2176
|
+
const kept = keywords.every((source) => brokenConstraintOf(program, source, value) === undefined);
|
|
2177
|
+
return admitted && kept ? { kind: "type", type: fixed, keywords: [] } : NOTHING;
|
|
2178
|
+
}
|
|
2179
|
+
if (members.every((member) => isObjectMember(member)))
|
|
2180
|
+
return mergeObjects(program, parts);
|
|
2181
|
+
if (members.every((member) => !isEnvelope(member) && isListMember(member))) {
|
|
2182
|
+
return mergeLists(program, parts);
|
|
2183
|
+
}
|
|
2184
|
+
if (members.some((member) => !isEnvelope(member) && member.kind === "Tuple")) {
|
|
2185
|
+
return { kind: "refused", why: "a tuple is held to another declaration" };
|
|
2186
|
+
}
|
|
2187
|
+
// Two scalars of one JSON type that neither narrows: the value is checked against both.
|
|
2188
|
+
return {
|
|
2189
|
+
kind: "all",
|
|
2190
|
+
parts: parts.map((part) => memberValue(program, part.members[0], part.keywords, part.at)),
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* **Whether a type is one schema at the visibility each of two parts is published at**, so that
|
|
2195
|
+
* what narrows it at one narrows it at the other. A record's value is published at the record's
|
|
2196
|
+
* visibility: `QX { x: M1 }` sent in a PATCH beside a `Record<M1>` holds `x` to `M1Update` and to
|
|
2197
|
+
* `M1`, and taken as narrowed by itself it was `M1Update` alone, `c` optional where `M1` requires it.
|
|
2198
|
+
*/
|
|
2199
|
+
function publishedAlike(program, type, one, other) {
|
|
2200
|
+
const a = one.at ?? visibility;
|
|
2201
|
+
const b = other.at ?? visibility;
|
|
2202
|
+
if (a !== b &&
|
|
2203
|
+
renderedVisibilityOf(program, type, a) !== renderedVisibilityOf(program, type, b)) {
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
2206
|
+
const own = typeOfPart(one);
|
|
2207
|
+
return own === undefined || !elementsPublishedApart(program, own, a, type, b);
|
|
2208
|
+
}
|
|
2209
|
+
/** The one type a part stands for, where it can be rendered whole: a single member, or its union. */
|
|
2210
|
+
function typeOfPart(part) {
|
|
2211
|
+
if (part.members.length === 1) {
|
|
2212
|
+
const [member] = part.members;
|
|
2213
|
+
return member === undefined || isEnvelope(member) ? undefined : member;
|
|
2214
|
+
}
|
|
2215
|
+
if (part.members.some(isEnvelope))
|
|
2216
|
+
return undefined;
|
|
2217
|
+
return part.type !== undefined && !hasNull(part.type) ? part.type : undefined;
|
|
2218
|
+
}
|
|
2219
|
+
/**
|
|
2220
|
+
* A member as the value, its keywords on it - a literal checked against them now - published at
|
|
2221
|
+
* `at` where its part is published apart from the merge: a record's value, at the record's.
|
|
2222
|
+
*/
|
|
2223
|
+
function memberValue(program, type, keywords, at) {
|
|
2224
|
+
const value = literalValueOf(type);
|
|
2225
|
+
if (value === undefined)
|
|
2226
|
+
return { kind: "type", type, keywords, ...(at === undefined ? {} : { at }) };
|
|
2227
|
+
return keywords.every((source) => brokenConstraintOf(program, source, value) === undefined)
|
|
2228
|
+
? { kind: "type", type, keywords: [] }
|
|
2229
|
+
: NOTHING;
|
|
2230
|
+
}
|
|
2231
|
+
/** Whether the parts share no JSON type, so no value is one of each. */
|
|
2232
|
+
function disjointParts(program, parts) {
|
|
2233
|
+
const typesOf = (part) => {
|
|
2234
|
+
const types = new Set();
|
|
2235
|
+
for (const member of part.members) {
|
|
2236
|
+
const each = isEnvelope(member)
|
|
2237
|
+
? new Set(["object"])
|
|
2238
|
+
: jsonTypesOf(program, member);
|
|
2239
|
+
if (each === undefined)
|
|
2240
|
+
return undefined;
|
|
2241
|
+
for (const type of each)
|
|
2242
|
+
types.add(type);
|
|
2243
|
+
}
|
|
2244
|
+
return types;
|
|
2245
|
+
};
|
|
2246
|
+
const numeric = (type) => type === "integer" || type === "number" || type === "fraction";
|
|
2247
|
+
let shared;
|
|
2248
|
+
for (const part of parts) {
|
|
2249
|
+
const types = typesOf(part);
|
|
2250
|
+
if (types === undefined)
|
|
2251
|
+
continue;
|
|
2252
|
+
shared =
|
|
2253
|
+
shared === undefined
|
|
2254
|
+
? [...types]
|
|
2255
|
+
: shared.filter((type) => [...types].some((other) => other === type || (numeric(other) && numeric(type))));
|
|
2256
|
+
}
|
|
2257
|
+
return shared !== undefined && shared.length === 0;
|
|
2258
|
+
}
|
|
2259
|
+
function isObjectMember(member) {
|
|
2260
|
+
if (isEnvelope(member))
|
|
2261
|
+
return true;
|
|
2262
|
+
return member.kind === "Model" && effectiveIndexer(member)?.key.name !== "integer";
|
|
2263
|
+
}
|
|
2264
|
+
function isListMember(member) {
|
|
2265
|
+
return member.kind === "Model" && effectiveIndexer(member)?.key.name === "integer";
|
|
2266
|
+
}
|
|
2267
|
+
/** Lists merged element by element, with every declaration's list keywords. */
|
|
2268
|
+
function mergeLists(program, parts) {
|
|
2269
|
+
const lists = parts.map((part) => part.members[0]);
|
|
2270
|
+
// Array literals all, the element is an `Item`'s - see `atItem`.
|
|
2271
|
+
const [first] = lists;
|
|
2272
|
+
const literal = lists.every((list) => isArrayLiteral(program, list)) ? first : undefined;
|
|
2273
|
+
// Each list's element at its own visibility - a literal's with `Item`, a named list's without.
|
|
2274
|
+
const merge = () => mergeParts(program, lists.map((list, index) => {
|
|
2275
|
+
const at = parts[index]?.at ?? visibility;
|
|
868
2276
|
return {
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
? `\tget ${key}()${annotation} {\n\t\treturn ${value};\n\t},`
|
|
872
|
-
: `\t${key}: ${value},`,
|
|
2277
|
+
...partOfType(program, effectiveIndexer(list).value, []),
|
|
2278
|
+
at: isArrayLiteral(program, list) ? at | Visibility.Item : at,
|
|
873
2279
|
};
|
|
2280
|
+
}));
|
|
2281
|
+
const element = literal === undefined ? merge() : atItem(program, literal, merge);
|
|
2282
|
+
return {
|
|
2283
|
+
kind: "array",
|
|
2284
|
+
element,
|
|
2285
|
+
keywords: parts.flatMap((part, index) => [...part.keywords, lists[index]]),
|
|
2286
|
+
...(literal === undefined ? {} : { list: literal }),
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* A model as the object it publishes at the current position, or `undefined` where it accepts
|
|
2291
|
+
* nothing - refused as `invalid-subtype-discriminator` or `sealed-base-extended`.
|
|
2292
|
+
*/
|
|
2293
|
+
function shapeOfModel(program, model, keywords) {
|
|
2294
|
+
if (unsatisfiableSubtype(program, model))
|
|
2295
|
+
return undefined;
|
|
2296
|
+
const extension = sealedBaseExtensionOf(program, model);
|
|
2297
|
+
if (extension?.required === true)
|
|
2298
|
+
return undefined;
|
|
2299
|
+
const binding = bindingIndexerOf(model);
|
|
2300
|
+
const rest = binding !== undefined && !isNeverType(binding.value) ? binding.value : undefined;
|
|
2301
|
+
/**
|
|
2302
|
+
* The keys the binding indexer's own model and those above it publish, which it does not hold. A
|
|
2303
|
+
* key whose declaration openapi3 leaves out of the properties - see `atPublication` - is the
|
|
2304
|
+
* indexer's: `additionalProperties` evaluates it.
|
|
2305
|
+
*/
|
|
2306
|
+
const exempt = binding === undefined
|
|
2307
|
+
? undefined
|
|
2308
|
+
: new Set(publishedPropertiesOf(program, binding.link).map(({ property }) => rawPropertyKey(program, property)));
|
|
2309
|
+
/**
|
|
2310
|
+
* **A string indexer only means "dictionary" when the model declares NO properties** - read from
|
|
2311
|
+
* the UNFILTERED list, because a model whose only properties are metadata is still a declared
|
|
2312
|
+
* object rather than a dictionary.
|
|
2313
|
+
*/
|
|
2314
|
+
const dictionary = rest !== undefined && flattenedPropertiesOf(program, model).length === 0;
|
|
2315
|
+
const discriminators = discriminatorsOf(program, model);
|
|
2316
|
+
const injected = injectedDiscriminatorsOf(program, model);
|
|
2317
|
+
const published = new Map(publishedPropertiesOf(program, model).map((entry) => [
|
|
2318
|
+
rawPropertyKey(program, entry.property),
|
|
2319
|
+
entry,
|
|
2320
|
+
]));
|
|
2321
|
+
const members = renderedPropertiesOf(program, model)
|
|
2322
|
+
/**
|
|
2323
|
+
* An optional property a template instance adds to a base openapi3 seals is refused by the
|
|
2324
|
+
* document whenever it is sent - see `sealedBaseExtensionOf` - so it is not declared, and the
|
|
2325
|
+
* sealed object refuses it too.
|
|
2326
|
+
*/
|
|
2327
|
+
.filter(({ property }) => extension === undefined || !extension.keys.includes(rawPropertyKey(program, property)))
|
|
2328
|
+
.flatMap(({ property, overridden }) => {
|
|
2329
|
+
const key = rawPropertyKey(program, property);
|
|
2330
|
+
const rendered = [property, ...overridden];
|
|
2331
|
+
const bound = binding !== undefined && exempt !== undefined && !exempt.has(key);
|
|
2332
|
+
// `required` is read where the model is reduced to, as openapi3 reads it.
|
|
2333
|
+
const required = discriminators.includes(key) ||
|
|
2334
|
+
rendered.some((declaration) => isRequiredAt(program, declaration));
|
|
2335
|
+
const entry = published.get(key);
|
|
2336
|
+
/**
|
|
2337
|
+
* **A key no published declaration states is held by what covers it in the document**: the
|
|
2338
|
+
* indexer, or the `string` openapi3 adds for a discriminator, below. One nothing covers is
|
|
2339
|
+
* `split-visibility`, refused, and keeps the declaration it was rendered from.
|
|
2340
|
+
*/
|
|
2341
|
+
if (entry === undefined && required && discriminators.includes(key))
|
|
2342
|
+
return [];
|
|
2343
|
+
const declarations = entry !== undefined
|
|
2344
|
+
? [entry.property, ...entry.overridden]
|
|
2345
|
+
: bound && rest !== undefined
|
|
2346
|
+
? []
|
|
2347
|
+
: rendered;
|
|
2348
|
+
return [
|
|
2349
|
+
{
|
|
2350
|
+
key,
|
|
2351
|
+
declarations,
|
|
2352
|
+
// A key the binding indexer holds is held to its value as well - see `bindingIndexerOf`.
|
|
2353
|
+
beside: bound && rest !== undefined ? [partOfType(program, rest, [])] : [],
|
|
2354
|
+
forbidden: bound && rest === undefined,
|
|
2355
|
+
injected: injected.includes(key),
|
|
2356
|
+
required,
|
|
2357
|
+
},
|
|
2358
|
+
];
|
|
874
2359
|
});
|
|
875
|
-
|
|
2360
|
+
// A key a `Record<never>` above it refuses: no value where it is required, refused where sent.
|
|
2361
|
+
if (members.some((member) => member.forbidden && member.required))
|
|
2362
|
+
return undefined;
|
|
876
2363
|
/**
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
* import the first time - the emitted schema referenced a name it never imported, and only the
|
|
880
|
-
* compile check in `test/emit.test.ts` noticed.
|
|
881
|
-
*
|
|
882
|
-
* `.refine()` returns an effect rather than an object, so `applyConstraints` appending it after
|
|
883
|
-
* `.loose()` is also the only order that works.
|
|
2364
|
+
* **The discriminator openapi3 adds where nothing declares it**, a required `string`, on the base
|
|
2365
|
+
* and - through `allOf` - on every model under it, an intermediate subtype standing alone included.
|
|
884
2366
|
*/
|
|
2367
|
+
for (const key of discriminators) {
|
|
2368
|
+
if (members.some((member) => member.key === key))
|
|
2369
|
+
continue;
|
|
2370
|
+
members.push({
|
|
2371
|
+
key,
|
|
2372
|
+
declarations: [],
|
|
2373
|
+
beside: [stringPart(program)],
|
|
2374
|
+
forbidden: false,
|
|
2375
|
+
injected: false,
|
|
2376
|
+
required: true,
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
return {
|
|
2380
|
+
members: members.filter((member) => !member.forbidden),
|
|
2381
|
+
sealed: rest === undefined && (binding !== undefined || isSealed(model)),
|
|
2382
|
+
rest,
|
|
2383
|
+
dictionary,
|
|
2384
|
+
keywords: [...keywords, model],
|
|
2385
|
+
site: model,
|
|
2386
|
+
at: visibility,
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* **The indexer that holds a model's keys: the TOPMOST string indexer in its chain.**
|
|
2391
|
+
*
|
|
2392
|
+
* openapi3 publishes a root model's indexer as `additionalProperties`, which holds every key the
|
|
2393
|
+
* model does not declare itself - a subtype's own keys included, since the subtype is `allOf` the
|
|
2394
|
+
* root - and a lower model's as `unevaluatedProperties`, which the root's has already evaluated. So
|
|
2395
|
+
* the topmost indexer holds every key its own model and those above it do not declare, and any below
|
|
2396
|
+
* it never applies. Read as the nearest, a subtype's `m: Inner2` escaped the base's
|
|
2397
|
+
* `...Record<Inner>` and a subtype's own indexer refused keys the document accepts.
|
|
2398
|
+
*/
|
|
2399
|
+
export function bindingIndexerOf(model) {
|
|
2400
|
+
let binding;
|
|
2401
|
+
for (let link = model; link !== undefined; link = link.baseModel) {
|
|
2402
|
+
if (link.indexer?.key.name === "string")
|
|
2403
|
+
binding = { link, value: link.indexer.value };
|
|
2404
|
+
}
|
|
2405
|
+
return binding;
|
|
2406
|
+
}
|
|
2407
|
+
/** The wrapper openapi3 publishes for an envelope, as an object of its two required members. */
|
|
2408
|
+
function shapeOfEnvelope(program, envelope) {
|
|
2409
|
+
const tag = $(program).literal.createString(envelope.name);
|
|
2410
|
+
// Published where the union reduces to - see `envelopeVisibilityOf` - not where a merge spells it out.
|
|
2411
|
+
const at = envelopeVisibilityOf(program, envelope.union, envelope.variant);
|
|
2412
|
+
// Both optional where a PATCH makes them so, as the envelope is published.
|
|
2413
|
+
const required = !implicitlyOptional(at);
|
|
2414
|
+
return {
|
|
2415
|
+
members: [
|
|
2416
|
+
{
|
|
2417
|
+
key: envelope.discriminator,
|
|
2418
|
+
declarations: [],
|
|
2419
|
+
beside: [{ members: [tag], nullable: false, keywords: [], type: tag }],
|
|
2420
|
+
injected: false,
|
|
2421
|
+
required,
|
|
2422
|
+
},
|
|
2423
|
+
{
|
|
2424
|
+
key: envelope.property,
|
|
2425
|
+
declarations: [],
|
|
2426
|
+
// At the envelope's visibility: a list literal's at `Item`, beside a named list's without.
|
|
2427
|
+
beside: [{ ...partOfType(program, envelope.variant, []), at }],
|
|
2428
|
+
injected: false,
|
|
2429
|
+
required,
|
|
2430
|
+
},
|
|
2431
|
+
],
|
|
2432
|
+
sealed: sealObjectSchemas,
|
|
2433
|
+
rest: undefined,
|
|
2434
|
+
dictionary: false,
|
|
2435
|
+
keywords: [],
|
|
2436
|
+
site: envelope.union,
|
|
2437
|
+
at,
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
/** Objects merged key by key - see {@link Merged}. */
|
|
2441
|
+
function mergeObjects(program, parts) {
|
|
2442
|
+
const members = parts.map((part) => part.members[0]);
|
|
2443
|
+
if (mergesInFlight.some((entry) => entry.length === members.length && entry.every((member) => members.includes(member)))) {
|
|
2444
|
+
return { kind: "refused", why: "the merged value refers back to itself", recursive: true };
|
|
2445
|
+
}
|
|
2446
|
+
mergesInFlight.push(members);
|
|
2447
|
+
try {
|
|
2448
|
+
const shapes = [];
|
|
2449
|
+
for (const [index, member] of members.entries()) {
|
|
2450
|
+
const part = parts[index];
|
|
2451
|
+
const requested = part.at ?? visibility;
|
|
2452
|
+
const at = isEnvelope(member) ? requested : renderedVisibilityOf(program, member, requested);
|
|
2453
|
+
/**
|
|
2454
|
+
* A component openapi3 publishes split - refused as `split-visibility` wherever it is
|
|
2455
|
+
* rendered - holds a value to a schema no value satisfies, and so does every merge holding it.
|
|
2456
|
+
* Merged from its declared shape, `Sub` accepted what its `allOf` refuses.
|
|
2457
|
+
*/
|
|
2458
|
+
if (!isEnvelope(member) && isPublishedSplit(program, member, at))
|
|
2459
|
+
return WITHHELD;
|
|
2460
|
+
if (!isEnvelope(member) && held.refusedAt(member, at))
|
|
2461
|
+
return WITHHELD;
|
|
2462
|
+
const shape = withVisibility(at, () => isEnvelope(member)
|
|
2463
|
+
? shapeOfEnvelope(program, member)
|
|
2464
|
+
: shapeOfModel(program, member, part.keywords));
|
|
2465
|
+
if (shape === undefined)
|
|
2466
|
+
return NOTHING;
|
|
2467
|
+
shapes.push(shape);
|
|
2468
|
+
}
|
|
2469
|
+
return mergeShapes(program, shapes);
|
|
2470
|
+
}
|
|
2471
|
+
finally {
|
|
2472
|
+
mergesInFlight.pop();
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
function mergeShapes(program, shapes) {
|
|
2476
|
+
const keys = [...new Set(shapes.flatMap((shape) => shape.members.map((member) => member.key)))];
|
|
885
2477
|
/**
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
|
|
891
|
-
|
|
2478
|
+
* Each declaration at the visibility its shape is published at - see `positionOfDeclarations` -
|
|
2479
|
+
* wherever that is not where the merge is rendered: `M13[]` beside a named list under a PATCH holds
|
|
2480
|
+
* its element at `Item`, where `M13` is canonical, and its `a: M14` rendered at the PATCH was
|
|
2481
|
+
* `M14Update`, `d` optional where the document's `M14` requires it.
|
|
2482
|
+
*/
|
|
2483
|
+
const apart = shapes.some((shape) => shape.at !== visibility);
|
|
2484
|
+
const members = [];
|
|
2485
|
+
for (const key of keys) {
|
|
2486
|
+
const present = [];
|
|
2487
|
+
const declaredAt = [];
|
|
2488
|
+
const beside = [];
|
|
2489
|
+
let forbidden = false;
|
|
2490
|
+
for (const shape of shapes) {
|
|
2491
|
+
const member = shape.members.find((each) => each.key === key);
|
|
2492
|
+
if (member !== undefined) {
|
|
2493
|
+
present.push(member);
|
|
2494
|
+
declaredAt.push(...member.declarations.map(() => shape.at));
|
|
2495
|
+
}
|
|
2496
|
+
else if (shape.sealed)
|
|
2497
|
+
forbidden = true;
|
|
2498
|
+
else if (shape.rest !== undefined) {
|
|
2499
|
+
beside.push({ ...partOfType(program, shape.rest, []), at: shape.at });
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
const required = present.some((member) => member.required);
|
|
2503
|
+
// A key a sealed side does not declare: refused when sent, and so no value where it is required.
|
|
2504
|
+
if (forbidden) {
|
|
2505
|
+
if (required)
|
|
2506
|
+
return NOTHING;
|
|
2507
|
+
continue;
|
|
2508
|
+
}
|
|
2509
|
+
const declarations = present.flatMap((member) => member.declarations);
|
|
2510
|
+
const stated = present.flatMap((member) => member.beside);
|
|
2511
|
+
const injected = present.some((member) => member.injected);
|
|
2512
|
+
const [own, ...ancestors] = declarations;
|
|
2513
|
+
if (own === undefined) {
|
|
2514
|
+
members.push({
|
|
2515
|
+
key,
|
|
2516
|
+
property: undefined,
|
|
2517
|
+
position: { ...OWN_POSITION, required, value: mergeParts(program, [...stated, ...beside]) },
|
|
2518
|
+
});
|
|
2519
|
+
continue;
|
|
2520
|
+
}
|
|
2521
|
+
members.push({
|
|
2522
|
+
key,
|
|
2523
|
+
property: own,
|
|
2524
|
+
position: positionOfDeclarations(program, own, ancestors, {
|
|
2525
|
+
required,
|
|
2526
|
+
injected,
|
|
2527
|
+
beside: [...stated, ...beside],
|
|
2528
|
+
...(apart ? { at: declaredAt } : {}),
|
|
2529
|
+
}),
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
2532
|
+
return {
|
|
2533
|
+
kind: "object",
|
|
2534
|
+
members,
|
|
2535
|
+
rest: mergedRest(program, shapes),
|
|
2536
|
+
dictionary: shapes.every((shape) => shape.dictionary),
|
|
2537
|
+
keywords: shapes.flatMap((shape) => shape.keywords),
|
|
2538
|
+
site: shapes.length === 1 ? shapes[0]?.site : undefined,
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
/** What a key no member names may hold, across every shape merged. */
|
|
2542
|
+
function mergedRest(program, shapes) {
|
|
2543
|
+
if (shapes.some((shape) => shape.sealed))
|
|
2544
|
+
return "sealed";
|
|
2545
|
+
const typed = shapes.filter((shape) => shape.rest !== undefined);
|
|
2546
|
+
if (typed.length === 0)
|
|
2547
|
+
return "open";
|
|
2548
|
+
// `...Record<unknown>` keeps what it is sent; a typed indexer, or a typeless scalar, is a schema.
|
|
2549
|
+
if (typed.every((shape) => isUnknownType(shape.rest)))
|
|
2550
|
+
return "loose";
|
|
2551
|
+
return mergeParts(program, typed.map((shape) => ({ ...partOfType(program, shape.rest, []), at: shape.at })));
|
|
2552
|
+
}
|
|
2553
|
+
/**
|
|
2554
|
+
* **Whether a merged value refuses anything a declaration states**, at any depth: a refused merge, a
|
|
2555
|
+
* key whose declarations cannot be merged, or a refused declaration it holds. Such a value is not
|
|
2556
|
+
* what the document publishes, so a `@oneOf` cannot count a value against it as the document does.
|
|
2557
|
+
*/
|
|
2558
|
+
function holdsRefusal(value) {
|
|
2559
|
+
switch (value.kind) {
|
|
2560
|
+
case "never":
|
|
2561
|
+
return value.withheld === true;
|
|
2562
|
+
case "refused":
|
|
2563
|
+
return true;
|
|
2564
|
+
case "object":
|
|
2565
|
+
return value.members.some((member) => member.position.refused !== undefined ||
|
|
2566
|
+
(member.position.value !== undefined && holdsRefusal(member.position.value)));
|
|
2567
|
+
case "nullable":
|
|
2568
|
+
return holdsRefusal(value.inner);
|
|
2569
|
+
case "array":
|
|
2570
|
+
return holdsRefusal(value.element);
|
|
2571
|
+
case "union":
|
|
2572
|
+
return value.members.some(holdsRefusal);
|
|
2573
|
+
case "all":
|
|
2574
|
+
return value.parts.some(holdsRefusal);
|
|
2575
|
+
default:
|
|
2576
|
+
return false;
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
/**
|
|
2580
|
+
* **The refusal a value holds, as the value's own**: the first merge it refuses, at any depth, so the
|
|
2581
|
+
* declaration holding the value reports why - `LeafB.u`, where `D6` meets `@oneOf { M3, M5 }` and
|
|
2582
|
+
* a tuple meets a list. A refused declaration it holds has reported itself, and leaves no value.
|
|
2583
|
+
*/
|
|
2584
|
+
function heldRefusalOf(value) {
|
|
2585
|
+
switch (value.kind) {
|
|
2586
|
+
case "refused":
|
|
2587
|
+
return value;
|
|
2588
|
+
case "object":
|
|
2589
|
+
for (const member of value.members) {
|
|
2590
|
+
const refused = member.position.refused;
|
|
2591
|
+
if (refused !== undefined) {
|
|
2592
|
+
return {
|
|
2593
|
+
kind: "refused",
|
|
2594
|
+
why: refused.why,
|
|
2595
|
+
...(refused.recursive ? { recursive: true } : {}),
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
const inner = member.position.value;
|
|
2599
|
+
if (inner !== undefined && holdsRefusal(inner))
|
|
2600
|
+
return heldRefusalOf(inner);
|
|
2601
|
+
}
|
|
2602
|
+
return WITHHELD;
|
|
2603
|
+
case "nullable":
|
|
2604
|
+
return heldRefusalOf(value.inner);
|
|
2605
|
+
case "array":
|
|
2606
|
+
return heldRefusalOf(value.element);
|
|
2607
|
+
case "union": {
|
|
2608
|
+
const held = value.members.find(holdsRefusal);
|
|
2609
|
+
return held === undefined ? WITHHELD : heldRefusalOf(held);
|
|
2610
|
+
}
|
|
2611
|
+
case "all": {
|
|
2612
|
+
const held = value.parts.find(holdsRefusal);
|
|
2613
|
+
return held === undefined ? WITHHELD : heldRefusalOf(held);
|
|
2614
|
+
}
|
|
2615
|
+
default:
|
|
2616
|
+
return WITHHELD;
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* The first refusal a merged value holds outside an object's declared members, which report their
|
|
2621
|
+
* own. A member no declaration states - an envelope's `value` - has no one to report it, so its
|
|
2622
|
+
* refusal is the enclosing value's; left there, a recursive merge through an envelope was emitted as
|
|
2623
|
+
* `z.never()` with nothing said.
|
|
2624
|
+
*/
|
|
2625
|
+
function refusalIn(value) {
|
|
2626
|
+
switch (value.kind) {
|
|
2627
|
+
case "object":
|
|
2628
|
+
return value.members
|
|
2629
|
+
.filter((member) => member.property === undefined)
|
|
2630
|
+
.map((member) => member.position.value === undefined ? undefined : refusalIn(member.position.value))
|
|
2631
|
+
.find((refusal) => refusal !== undefined);
|
|
2632
|
+
case "refused":
|
|
2633
|
+
return value;
|
|
2634
|
+
case "nullable":
|
|
2635
|
+
return refusalIn(value.inner);
|
|
2636
|
+
case "array":
|
|
2637
|
+
return refusalIn(value.element);
|
|
2638
|
+
case "union":
|
|
2639
|
+
return value.members.map(refusalIn).find((why) => why !== undefined);
|
|
2640
|
+
case "all":
|
|
2641
|
+
return value.parts.map(refusalIn).find((why) => why !== undefined);
|
|
2642
|
+
default:
|
|
2643
|
+
return undefined;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
/** The indexer in force, which a model can inherit - `model X extends Record<unknown>`. */
|
|
2647
|
+
export function effectiveIndexer(model) {
|
|
2648
|
+
for (let link = model; link !== undefined; link = link.baseModel) {
|
|
2649
|
+
if (link.indexer !== undefined)
|
|
2650
|
+
return link.indexer;
|
|
2651
|
+
}
|
|
2652
|
+
return undefined;
|
|
2653
|
+
}
|
|
2654
|
+
/**
|
|
2655
|
+
* The subtypes a `@discriminator` base dispatches to: the models its document's `mapping` names.
|
|
2656
|
+
*
|
|
2657
|
+
* **The compiler's own reading, which is what openapi3 writes the mapping from.** A derived model
|
|
2658
|
+
* that declares no value for the discriminator is looked through to the models derived from it
|
|
2659
|
+
* that do. This used to be the base's DIRECT subtypes, so `Animal` -> `Mammal` (no `kind`) -> `Cat`
|
|
2660
|
+
* dispatched to `Mammal`, which carries no literal `kind`, and Zod threw "Invalid discriminated union
|
|
2661
|
+
* option" on every parse - a known kind, an unknown one, anything - while `Cat`, the model the
|
|
2662
|
+
* mapping names for `"cat"`, was not in the dispatch at all.
|
|
2663
|
+
*
|
|
2664
|
+
* Filtered by openapi3's own {@link includeDerivedModel} so a template declaration is not mistaken
|
|
2665
|
+
* for a real subtype - the same rule that decides whether a base may be sealed.
|
|
2666
|
+
*/
|
|
2667
|
+
export function discriminatedSubtypes(program, model) {
|
|
2668
|
+
const discriminator = getDiscriminator(program, model);
|
|
2669
|
+
if (discriminator === undefined)
|
|
2670
|
+
return [];
|
|
2671
|
+
return mappedSubtypes(program, model).filter((derived) => extendsModel(derived, model) &&
|
|
2672
|
+
invalidSubtypeOf(program, derived, discriminator.propertyName) === undefined &&
|
|
2673
|
+
// Refused as `split-visibility`, it is `z.never()`, which no dispatch can hold.
|
|
2674
|
+
!isPublishedSplit(program, derived, renderedVisibilityOf(program, derived, visibility)));
|
|
2675
|
+
}
|
|
2676
|
+
/**
|
|
2677
|
+
* **Whether a subtype is published as `allOf` the base at all.** `Create<Pet>` is a model of its
|
|
2678
|
+
* own, its mapping the canonical `Cat`'s, which holds `x` to `B` where `CreatePet` holds it to
|
|
2679
|
+
* `CreateB`: the document validates a value by `CreatePet`'s own properties, and dispatched to `Cat`
|
|
2680
|
+
* it accepted the `B` that `CreatePet` refuses.
|
|
2681
|
+
*/
|
|
2682
|
+
function extendsModel(derived, model) {
|
|
2683
|
+
for (let link = derived.baseModel; link !== undefined; link = link.baseModel) {
|
|
2684
|
+
if (link === model)
|
|
2685
|
+
return true;
|
|
2686
|
+
}
|
|
2687
|
+
return false;
|
|
2688
|
+
}
|
|
2689
|
+
/**
|
|
2690
|
+
* The subtypes a discriminated base dispatches to, each with the discriminator value its mapping
|
|
2691
|
+
* names it for - see {@link discriminatedSubtypes}.
|
|
2692
|
+
*/
|
|
2693
|
+
export function dispatchedSubtypesOf(program, model) {
|
|
2694
|
+
const discriminator = getDiscriminator(program, model);
|
|
2695
|
+
if (discriminator === undefined)
|
|
2696
|
+
return [];
|
|
2697
|
+
const held = new Set(discriminatedSubtypes(program, model));
|
|
2698
|
+
const { variants } = ignoreDiagnostics(getDiscriminatedUnionFromInheritance(model, discriminator));
|
|
2699
|
+
return [...variants].filter(([, derived]) => held.has(derived));
|
|
2700
|
+
}
|
|
2701
|
+
/** Every model the base's `mapping` names, whether or not a dispatch can hold it. */
|
|
2702
|
+
function mappedSubtypes(program, model) {
|
|
2703
|
+
const discriminator = getDiscriminator(program, model);
|
|
2704
|
+
if (discriminator === undefined)
|
|
2705
|
+
return [];
|
|
2706
|
+
const { variants } = ignoreDiagnostics(getDiscriminatedUnionFromInheritance(model, discriminator));
|
|
2707
|
+
return [...new Set(variants.values())].filter((derived) => includeDerivedModel(derived));
|
|
2708
|
+
}
|
|
2709
|
+
/**
|
|
2710
|
+
* **Why a model the mapping names cannot be dispatched to, or `undefined` where it can.**
|
|
2711
|
+
*
|
|
2712
|
+
* A dispatch reads one fixed value per subtype - `z.discriminatedUnion` has no other way to choose -
|
|
2713
|
+
* and the document's `mapping` names a subtype for exactly the values its discriminator fixes. Two
|
|
2714
|
+
* declarations break that, and each used to break the emitted module:
|
|
2715
|
+
*
|
|
2716
|
+
* - **a discriminator that is not one fixed value** - `kind: "lab" | string`, or a subtype that
|
|
2717
|
+
* renames `kind` with `@encodedName` so the `kind` it publishes is its base's open `string`. Zod
|
|
2718
|
+
* threw "Invalid discriminated union option" on the first parse, or on import.
|
|
2719
|
+
* - **a value a declaration above it refuses** - `kind: "cobra"` under `@maxLength(3) kind: string`.
|
|
2720
|
+
* The document publishes the subtype as `allOf` that base, so no value is one; validated as its own
|
|
2721
|
+
* declaration alone, every `"cobra"` was accepted.
|
|
2722
|
+
*
|
|
2723
|
+
* Each is refused as `invalid-subtype-discriminator`, as `undeclared-discriminator` refuses a
|
|
2724
|
+
* discriminator the document cannot state, and the base dispatches to its other subtypes.
|
|
2725
|
+
*/
|
|
2726
|
+
export function invalidSubtypeOf(program, subtype, propertyName) {
|
|
2727
|
+
const entry = publishedPropertiesOf(program, subtype).find(({ property }) => rawPropertyKey(program, property) === propertyName);
|
|
2728
|
+
const values = entry === undefined ? undefined : fixedValuesOf(entry.property.type);
|
|
2729
|
+
/**
|
|
2730
|
+
* **Fixed, but not where openapi3 reads it**: `Cat { @visibility(Lifecycle.Read) kind: "cat" }`
|
|
2731
|
+
* first reached from a create body is published without its `kind`, which `Pet`'s `string` holds
|
|
2732
|
+
* in its place - see `atPublication`. Told "it publishes 'kind' as 'string'", the author was not
|
|
2733
|
+
* told why their `"cat"` was not it.
|
|
2734
|
+
*/
|
|
2735
|
+
const hidden = values !== undefined
|
|
2736
|
+
? undefined
|
|
2737
|
+
: flattenedPropertiesOf(program, subtype)
|
|
2738
|
+
.filter(({ property }) => rawPropertyKey(program, property) === propertyName)
|
|
2739
|
+
.flatMap(({ property, overridden }) => [property, ...overridden])
|
|
2740
|
+
.find((declaration) => fixedValuesOf(declaration.type) !== undefined &&
|
|
2741
|
+
!atPublication(program, declaration, () => isPayloadProperty(program, declaration)));
|
|
2742
|
+
if (hidden !== undefined) {
|
|
2743
|
+
const at = atPublication(program, hidden, () => visibility);
|
|
2744
|
+
const instead = entry === undefined
|
|
2745
|
+
? "the 'string' openapi3 adds"
|
|
2746
|
+
: `'${entry.property.model?.name ?? "its base"}''s '${getTypeName(entry.property.type)}'`;
|
|
2747
|
+
return {
|
|
2748
|
+
why: `its '${propertyName}' is not visible at '${visibilityName(at)}', where openapi3 reads the properties of '${hidden.model?.name ?? subtype.name}', so it publishes ${instead} in its place, which is not one fixed value`,
|
|
2749
|
+
satisfiable: true,
|
|
2750
|
+
invisibleAt: at,
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
/**
|
|
2754
|
+
* **Declared, but not where the request is**: `@visibility(Lifecycle.Read) kind: "dog"` is no
|
|
2755
|
+
* part of the schema at `Create`, which publishes the `string` openapi3 adds in its place. Told as
|
|
2756
|
+
* "declare it as one fixed value", the author was sent to the declaration already there.
|
|
2757
|
+
*/
|
|
2758
|
+
if (entry === undefined && declaresKey(program, subtype, propertyName)) {
|
|
2759
|
+
return {
|
|
2760
|
+
why: `its '${propertyName}' is not visible at '${visibilityName(visibility)}', where it publishes the 'string' openapi3 adds in its place, which is not one fixed value`,
|
|
2761
|
+
satisfiable: true,
|
|
2762
|
+
invisibleAt: visibility,
|
|
2763
|
+
};
|
|
2764
|
+
}
|
|
2765
|
+
if (entry === undefined || values === undefined) {
|
|
2766
|
+
// Renamed away, the key it publishes is the `string` openapi3 adds where no model declares it.
|
|
2767
|
+
const declared = entry !== undefined
|
|
2768
|
+
? `'${getTypeName(entry.property.type)}'`
|
|
2769
|
+
: injectedDiscriminatorsOf(program, subtype).includes(propertyName)
|
|
2770
|
+
? "the 'string' openapi3 adds where no model above declares it"
|
|
2771
|
+
: "nothing";
|
|
2772
|
+
return {
|
|
2773
|
+
why: `it publishes '${propertyName}' as ${declared}, which is not one fixed value`,
|
|
2774
|
+
satisfiable: true,
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
for (const ancestor of entry.overridden) {
|
|
2778
|
+
const owner = ancestor.model?.name ?? "its base";
|
|
2779
|
+
const published = publishedJsonTypeOf(program, ancestor);
|
|
2780
|
+
if (published !== undefined && published !== "string") {
|
|
2781
|
+
return {
|
|
2782
|
+
why: `it fixes '${propertyName}' as a string, which '${owner}' publishes as a JSON ${published}, so no value is one`,
|
|
2783
|
+
satisfiable: false,
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
for (const value of values) {
|
|
2787
|
+
const broken = brokenConstraintOf(program, ancestor, value) ??
|
|
2788
|
+
refusedValueOf(program, ancestor.type, value);
|
|
2789
|
+
if (broken !== undefined) {
|
|
2790
|
+
return {
|
|
2791
|
+
why: `it fixes '${propertyName}' as '${value}', which '${owner}' refuses with ${broken}, so no value is one`,
|
|
2792
|
+
satisfiable: false,
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
return undefined;
|
|
2798
|
+
}
|
|
2799
|
+
/** Whether a model or one above it declares a key, visible or not. */
|
|
2800
|
+
function declaresKey(program, model, key) {
|
|
2801
|
+
for (let link = model; link !== undefined; link = link.baseModel) {
|
|
2802
|
+
for (const property of link.properties.values()) {
|
|
2803
|
+
if (rawPropertyKey(program, property) === key)
|
|
2804
|
+
return true;
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
return false;
|
|
2808
|
+
}
|
|
2809
|
+
/** A visibility as its flags read: `Update | Patch`, `Create | Item`. */
|
|
2810
|
+
export function visibilityName(at) {
|
|
2811
|
+
const flags = [
|
|
2812
|
+
["Read", Visibility.Read],
|
|
2813
|
+
["Create", Visibility.Create],
|
|
2814
|
+
["Update", Visibility.Update],
|
|
2815
|
+
["Delete", Visibility.Delete],
|
|
2816
|
+
["Query", Visibility.Query],
|
|
2817
|
+
["Patch", Visibility.Patch],
|
|
2818
|
+
["Item", Visibility.Item],
|
|
2819
|
+
];
|
|
2820
|
+
return flags
|
|
2821
|
+
.filter(([, flag]) => (at & flag) !== 0)
|
|
2822
|
+
.map(([named]) => named)
|
|
2823
|
+
.join(" | ");
|
|
2824
|
+
}
|
|
2825
|
+
/**
|
|
2826
|
+
* Why a declared type refuses a string value, read without running anything - or `undefined` where
|
|
2827
|
+
* it admits it. A union admits what any member admits, so `kind: K2 | null`, with `K2` a scalar whose
|
|
2828
|
+
* base declares `@maxLength(3)`, refuses `"cobra"` through `K2`'s chain.
|
|
2829
|
+
*/
|
|
2830
|
+
function refusedValueOf(program, type, value) {
|
|
2831
|
+
switch (type.kind) {
|
|
2832
|
+
case "Scalar": {
|
|
2833
|
+
const json = jsonTypeOfScalar(program, type);
|
|
2834
|
+
if (json !== undefined && json !== "string")
|
|
2835
|
+
return `its type, a JSON ${json}`;
|
|
2836
|
+
return brokenConstraintOf(program, type, value);
|
|
2837
|
+
}
|
|
2838
|
+
case "String":
|
|
2839
|
+
return type.value === value ? undefined : `its value '${type.value}'`;
|
|
2840
|
+
case "EnumMember":
|
|
2841
|
+
return (type.value ?? type.name) === value ? undefined : `its value`;
|
|
2842
|
+
case "Enum":
|
|
2843
|
+
case "Union": {
|
|
2844
|
+
const reasons = membersOf(type).map((member) => refusedValueOf(program, member, value));
|
|
2845
|
+
return reasons.includes(undefined) ? undefined : `'${getTypeName(type)}'`;
|
|
2846
|
+
}
|
|
2847
|
+
case "Intrinsic":
|
|
2848
|
+
return type.name === "unknown" ? undefined : `'${type.name}'`;
|
|
2849
|
+
case "Model":
|
|
2850
|
+
case "Tuple":
|
|
2851
|
+
case "Number":
|
|
2852
|
+
case "Boolean":
|
|
2853
|
+
return `'${getTypeName(type)}'`;
|
|
2854
|
+
default:
|
|
2855
|
+
return undefined;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
/**
|
|
2859
|
+
* **Whether a model is a subtype its base's mapping names that no value can be** - its fixed
|
|
2860
|
+
* discriminator refused by a declaration above it. Refused as `invalid-subtype-discriminator` and
|
|
2861
|
+
* emitted as nothing, so used directly as a body it accepts nothing, as the document does, where it
|
|
2862
|
+
* used to accept its own declaration.
|
|
2863
|
+
*/
|
|
2864
|
+
export function unsatisfiableSubtype(program, model) {
|
|
2865
|
+
for (let base = model.baseModel; base !== undefined; base = base.baseModel) {
|
|
2866
|
+
const discriminator = getDiscriminator(program, base);
|
|
2867
|
+
if (discriminator === undefined || !mappedSubtypes(program, base).includes(model))
|
|
2868
|
+
continue;
|
|
2869
|
+
if (invalidSubtypeOf(program, model, discriminator.propertyName)?.satisfiable === false) {
|
|
2870
|
+
return true;
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
return false;
|
|
2874
|
+
}
|
|
2875
|
+
/** The JSON type a scalar-valued property is published as, `@encode` included. */
|
|
2876
|
+
function publishedJsonTypeOf(program, property) {
|
|
2877
|
+
const encoding = encodedValueOf(program, property);
|
|
2878
|
+
if (encoding.kind === "replaced" || encoding.kind === "both") {
|
|
2879
|
+
return jsonTypeOfScalar(program, encoding.scalar);
|
|
2880
|
+
}
|
|
2881
|
+
const type = nullableInnerOf(property.type) ?? property.type;
|
|
2882
|
+
return type.kind === "Scalar" ? jsonTypeOfScalar(program, type) : undefined;
|
|
2883
|
+
}
|
|
2884
|
+
/** The fixed string values a discriminator's type allows, or `undefined` where it is open. */
|
|
2885
|
+
function fixedValuesOf(type) {
|
|
2886
|
+
switch (type.kind) {
|
|
2887
|
+
case "String":
|
|
2888
|
+
return [type.value];
|
|
2889
|
+
case "EnumMember":
|
|
2890
|
+
return typeof type.value === "number" ? undefined : [type.value ?? type.name];
|
|
2891
|
+
case "UnionVariant":
|
|
2892
|
+
return fixedValuesOf(type.type);
|
|
2893
|
+
case "Enum":
|
|
2894
|
+
case "Union": {
|
|
2895
|
+
const values = [];
|
|
2896
|
+
for (const member of membersOf(type)) {
|
|
2897
|
+
const fixed = fixedValuesOf(member);
|
|
2898
|
+
if (fixed === undefined)
|
|
2899
|
+
return undefined;
|
|
2900
|
+
values.push(...fixed);
|
|
2901
|
+
}
|
|
2902
|
+
return values;
|
|
2903
|
+
}
|
|
2904
|
+
default:
|
|
2905
|
+
return undefined;
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
/** `invalid-subtype-discriminator` for every model a base's mapping names that cannot be dispatched to. */
|
|
2909
|
+
function reportInvalidSubtypes(program, model) {
|
|
2910
|
+
const discriminator = getDiscriminator(program, model);
|
|
2911
|
+
if (discriminator === undefined)
|
|
2912
|
+
return;
|
|
2913
|
+
for (const subtype of mappedSubtypes(program, model)) {
|
|
2914
|
+
const invalid = invalidSubtypeOf(program, subtype, discriminator.propertyName);
|
|
2915
|
+
if (invalid === undefined ||
|
|
2916
|
+
!firstReportOf(program, "invalid-subtype-discriminator", subtype)) {
|
|
2917
|
+
continue;
|
|
2918
|
+
}
|
|
2919
|
+
reportDiagnostic(program, {
|
|
2920
|
+
code: "invalid-subtype-discriminator",
|
|
2921
|
+
messageId: invalid.invisibleAt === undefined ? "default" : "invisible",
|
|
2922
|
+
target: subtype,
|
|
2923
|
+
format: {
|
|
2924
|
+
name: subtype.name,
|
|
2925
|
+
base: model.name,
|
|
2926
|
+
property: discriminator.propertyName,
|
|
2927
|
+
why: invalid.why,
|
|
2928
|
+
at: visibilityName(invalid.invisibleAt ?? visibility),
|
|
2929
|
+
},
|
|
2930
|
+
});
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
/**
|
|
2934
|
+
* **A model extending a base that openapi3 seals because it does not count the model as derived.**
|
|
2935
|
+
*
|
|
2936
|
+
* openapi3 seals a model with no derived models it publishes, and a template instance is not one
|
|
2937
|
+
* (`includeDerivedModel`). So with `seal-object-schemas` on, `Pet<string> extends Animal` leaves
|
|
2938
|
+
* `Animal` sealed, and `Pet<string>` - published as `allOf` that sealed `Animal` - refuses every
|
|
2939
|
+
* value carrying a property `Pet<string>` adds, since `Animal`'s own `unevaluatedProperties` cannot
|
|
2940
|
+
* see it. Validated as its own flattened properties, such a value was accepted. Returns the sealed
|
|
2941
|
+
* base and the keys it refuses, or `undefined` where nothing is refused.
|
|
2942
|
+
*/
|
|
2943
|
+
export function sealedBaseExtensionOf(program, model) {
|
|
2944
|
+
if (includeDerivedModel(model))
|
|
2945
|
+
return undefined;
|
|
2946
|
+
for (let base = model.baseModel; base !== undefined; base = base.baseModel) {
|
|
2947
|
+
if (!isSealed(base))
|
|
2948
|
+
continue;
|
|
2949
|
+
const sealed = new Set(publishedPropertiesOf(program, base).map(({ property }) => rawPropertyKey(program, property)));
|
|
2950
|
+
const added = publishedPropertiesOf(program, model)
|
|
2951
|
+
.map(({ property }) => property)
|
|
2952
|
+
.filter((property) => !sealed.has(rawPropertyKey(program, property)));
|
|
2953
|
+
if (added.length === 0)
|
|
2954
|
+
return undefined;
|
|
2955
|
+
return {
|
|
2956
|
+
base,
|
|
2957
|
+
keys: added.map((property) => rawPropertyKey(program, property)),
|
|
2958
|
+
// Only a REQUIRED addition leaves no value; an optional one the document refuses only when sent.
|
|
2959
|
+
required: added.some((property) => !(property.optional || isOptionalAt(program, property))),
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
return undefined;
|
|
2963
|
+
}
|
|
2964
|
+
export function discriminatedBaseOf(program, model) {
|
|
2965
|
+
const discriminator = getDiscriminator(program, model);
|
|
2966
|
+
const subtypes = discriminatedSubtypes(program, model);
|
|
2967
|
+
if (discriminator === undefined || subtypes.length === 0)
|
|
2968
|
+
return undefined;
|
|
2969
|
+
const beside = subtypes.filter((subtype) => discriminatedBaseOf(program, subtype) !== undefined);
|
|
2970
|
+
return {
|
|
2971
|
+
propertyName: discriminator.propertyName,
|
|
2972
|
+
dispatched: subtypes.filter((subtype) => !beside.includes(subtype)),
|
|
2973
|
+
beside,
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
const NOTHING_HELD = {
|
|
2977
|
+
refused: () => false,
|
|
2978
|
+
annotated: () => false,
|
|
2979
|
+
refusedAt: () => false,
|
|
2980
|
+
holdsRefusalAt: () => false,
|
|
2981
|
+
};
|
|
2982
|
+
let held = NOTHING_HELD;
|
|
2983
|
+
export function withHeldDeclarations(given, run) {
|
|
2984
|
+
const previous = held;
|
|
2985
|
+
held = given;
|
|
2986
|
+
try {
|
|
2987
|
+
return run();
|
|
2988
|
+
}
|
|
2989
|
+
finally {
|
|
2990
|
+
held = previous;
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
function modelToZod(program, model) {
|
|
2994
|
+
/**
|
|
2995
|
+
* **A `@discriminator` base is its own object, deserialized as a subtype where the value is one.**
|
|
2996
|
+
* See {@link DiscriminatedBase}.
|
|
892
2997
|
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
2998
|
+
* `z.discriminatedUnion` is the dispatch, and composes both ways this corpus needs. Measured on
|
|
2999
|
+
* Zod 4.4.3: a union may be an OPTION of another union, and an option carrying a getter that refers
|
|
3000
|
+
* back to the union being defined does **not** throw, because the discriminator is read without
|
|
3001
|
+
* enumerating the rest of the shape. `Eagle.friends?: Bird[]` is exactly that. A nested base is a
|
|
3002
|
+
* `z.union` itself, with no literal values to dispatch on, so it sits beside the dispatch.
|
|
898
3003
|
*
|
|
899
|
-
*
|
|
900
|
-
*
|
|
901
|
-
* `circular-model`. `z.lazy()` is the place to put it: wrapping the whole expression defers the
|
|
902
|
-
* getter past module initialisation, and by the time the schema is forced every declaration exists.
|
|
903
|
-
* Measured on 4.4.3: `z.lazy(() => z.object({ get kid() {...} }).catchall(z.number()))` parses a
|
|
904
|
-
* nested value and still applies the catchall.
|
|
3004
|
+
* The base arm comes last: `z.union` hands back the first arm that accepts, so a valid subtype is
|
|
3005
|
+
* deserialized as that subtype, and only a value no subtype accepts is the base.
|
|
905
3006
|
*/
|
|
906
|
-
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
3007
|
+
reportInvalidSubtypes(program, model);
|
|
3008
|
+
const base = discriminatedBaseOf(program, model);
|
|
3009
|
+
if (base !== undefined) {
|
|
3010
|
+
const arms = [];
|
|
3011
|
+
const options = base.dispatched
|
|
3012
|
+
.map((derived) => typeToZod(program, derived))
|
|
3013
|
+
.filter((option) => !held.refused(option));
|
|
3014
|
+
const dispatched = options.filter((option) => !held.annotated(option));
|
|
3015
|
+
if (dispatched.length > 0) {
|
|
3016
|
+
arms.push(`z.discriminatedUnion(${literal(base.propertyName)}, [${dispatched.join(", ")}])`);
|
|
3017
|
+
}
|
|
3018
|
+
arms.push(...options.filter((option) => held.annotated(option)));
|
|
3019
|
+
arms.push(...base.beside.map((derived) => typeToZod(program, derived)));
|
|
3020
|
+
arms.push(modelObjectToZod(program, model));
|
|
3021
|
+
return `z.union([${arms.join(", ")}])`;
|
|
3022
|
+
}
|
|
3023
|
+
return modelObjectToZod(program, model);
|
|
3024
|
+
}
|
|
3025
|
+
/**
|
|
3026
|
+
* A model as the object of its own properties, inherited ones included.
|
|
3027
|
+
*
|
|
3028
|
+
* **Given a discriminated base, this is its base arm**: the same object, with the required `string`
|
|
3029
|
+
* openapi3 adds to the base's properties where the base declares no discriminator of its own.
|
|
3030
|
+
*/
|
|
3031
|
+
function modelObjectToZod(program, model) {
|
|
3032
|
+
// Refused as `invalid-subtype-discriminator`, by the base whose mapping names it.
|
|
3033
|
+
if (unsatisfiableSubtype(program, model))
|
|
3034
|
+
return UNREPRESENTABLE;
|
|
3035
|
+
if (reportSealedBaseExtended(program, model))
|
|
3036
|
+
return UNREPRESENTABLE;
|
|
3037
|
+
const indexer = effectiveIndexer(model);
|
|
3038
|
+
if (indexer?.key.name === "integer") {
|
|
3039
|
+
const element = atItem(program, model, () => typeToZod(program, indexer.value));
|
|
3040
|
+
return applyConstraints(program, `z.array(${element})`, model);
|
|
3041
|
+
}
|
|
3042
|
+
return mergedToZod(program, objectOfModel(program, model));
|
|
3043
|
+
}
|
|
3044
|
+
/** `sealed-base-extended` for a model a sealed base leaves no value of, and whether it was one. */
|
|
3045
|
+
function reportSealedBaseExtended(program, model) {
|
|
3046
|
+
const extension = sealedBaseExtensionOf(program, model);
|
|
3047
|
+
if (extension?.required !== true)
|
|
3048
|
+
return false;
|
|
3049
|
+
const name = getTypeName(model, { namespaceFilter: () => false });
|
|
3050
|
+
// Keyed on the instance as well: `Twice<string>` and `Twice<int32>` are one node, two findings.
|
|
3051
|
+
if (firstReportOf(program, "sealed-base-extended", model, name)) {
|
|
3052
|
+
reportDiagnostic(program, {
|
|
3053
|
+
code: "sealed-base-extended",
|
|
3054
|
+
target: model,
|
|
3055
|
+
format: {
|
|
3056
|
+
name,
|
|
3057
|
+
base: getTypeName(extension.base, { namespaceFilter: () => false }),
|
|
3058
|
+
keys: extension.keys.map((key) => `'${key}'`).join(", "),
|
|
3059
|
+
},
|
|
3060
|
+
});
|
|
3061
|
+
}
|
|
3062
|
+
return true;
|
|
915
3063
|
}
|
|
916
3064
|
/**
|
|
917
|
-
*
|
|
3065
|
+
* A model as the object it publishes at the current position: the merge of one - see
|
|
3066
|
+
* {@link mergeShapes}. **HTTP metadata is not payload**, and neither is a property invisible here or
|
|
3067
|
+
* a `never` one, which the document drops; each is judged declaration by declaration - see
|
|
3068
|
+
* {@link publishedPropertiesOf}.
|
|
3069
|
+
*/
|
|
3070
|
+
export function objectOfModel(program, model) {
|
|
3071
|
+
const shape = shapeOfModel(program, model, []);
|
|
3072
|
+
return shape === undefined ? NOTHING : mergeShapes(program, [shape]);
|
|
3073
|
+
}
|
|
3074
|
+
/**
|
|
3075
|
+
* **Whether a value of this type can be anything but text, so a parameter carried in text needs
|
|
3076
|
+
* reading** - see `wireRead`, which reads it against the schema. A type all of whose values are
|
|
3077
|
+
* strings is left without a reader, and its validator is the schema alone.
|
|
918
3078
|
*
|
|
919
3079
|
* A path, query or header value is always a string on the wire, so a numeric or boolean parameter has
|
|
920
|
-
* to be decoded before the document's schema runs. Deciding
|
|
3080
|
+
* to be decoded before the document's schema runs. Deciding WHETHER it is used to be done by
|
|
921
3081
|
* inspecting the emitted Zod - `declared.startsWith("z.number()")` - which is a string about a string
|
|
922
3082
|
* and fails silently the moment the expression is spelled any other way.
|
|
923
3083
|
*
|
|
@@ -928,9 +3088,12 @@ function modelToZod(program, model) {
|
|
|
928
3088
|
* is a number - the disagreement is with the wire.
|
|
929
3089
|
*
|
|
930
3090
|
* Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
|
|
931
|
-
* single list of what becomes a number.
|
|
3091
|
+
* single list of what becomes a number. **And WHICH reading is the value's is the schema's to say**,
|
|
3092
|
+
* not a kind read from the type: a kind could not say `int32 | boolean`, a numeric enum, or
|
|
3093
|
+
* `int32 | "all"`, and each refused a value the document accepts.
|
|
932
3094
|
*
|
|
933
|
-
* **It still inspects the emitted expression to decide
|
|
3095
|
+
* **It still inspects the emitted expression to decide whether a scalar is numeric, and that did
|
|
3096
|
+
* drift once more.**
|
|
934
3097
|
* The test was `startsWith("z.number()")`, and giving each integer width its declared bounds spelled
|
|
935
3098
|
* nine of them `z.int(`, `z.int32(` and `z.uint32(` instead. Every one silently lost its wire
|
|
936
3099
|
* decoder: 22 arms went red at once, which is the only reason it was not shipped. The prefixes live
|
|
@@ -947,41 +3110,49 @@ function modelToZod(program, model) {
|
|
|
947
3110
|
* have to agree and nothing about the two being in the same file makes them.
|
|
948
3111
|
*/
|
|
949
3112
|
const NUMERIC_EXPRESSIONS = ["z.number()", "z.int(", "z.int32(", "z.uint32("];
|
|
950
|
-
export function
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
return "number";
|
|
970
|
-
if (type.kind === "Boolean")
|
|
971
|
-
return "boolean";
|
|
972
|
-
if (type.kind === "Union") {
|
|
973
|
-
const kinds = new Set();
|
|
974
|
-
for (const variant of type.variants.values()) {
|
|
975
|
-
// `T | null` is still a T on the wire; the null arm needs no decoding.
|
|
976
|
-
if (variant.type.kind === "Intrinsic" && variant.type.name === "null")
|
|
977
|
-
continue;
|
|
978
|
-
kinds.add(wireKindOf(program, variant.type));
|
|
3113
|
+
export function readsFromText(program, type) {
|
|
3114
|
+
switch (type.kind) {
|
|
3115
|
+
case "Scalar": {
|
|
3116
|
+
// Its own encoding or an inherited one - see `scalarEncodingOf`.
|
|
3117
|
+
const encoded = scalarEncodingOf(program, type)?.encoded;
|
|
3118
|
+
// A scalar that is not standard states no type, and the schema walk refuses it.
|
|
3119
|
+
if (encoded !== undefined && !program.checker.isStdType(encoded))
|
|
3120
|
+
return false;
|
|
3121
|
+
if (encoded !== undefined)
|
|
3122
|
+
return readsFromText(program, encoded);
|
|
3123
|
+
for (let current = type; current !== undefined; current = current.baseScalar) {
|
|
3124
|
+
const mapped = standardScalarSchemaOf(program, current);
|
|
3125
|
+
if (mapped === undefined)
|
|
3126
|
+
continue;
|
|
3127
|
+
return (NUMERIC_EXPRESSIONS.some((prefix) => mapped.startsWith(prefix)) ||
|
|
3128
|
+
mapped === "z.boolean()");
|
|
3129
|
+
}
|
|
3130
|
+
// A scalar with no standard base is any value, which text can spell as a number.
|
|
3131
|
+
return isTypelessMember(program, type);
|
|
979
3132
|
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
3133
|
+
/**
|
|
3134
|
+
* **A value of any type is read from text as one too**: `@maxLength(2) a?: string | unknown`
|
|
3135
|
+
* sent `?a=12345` is the number the document reads, which no `maxLength` constrains. Left as
|
|
3136
|
+
* text, it was refused as too long.
|
|
3137
|
+
*/
|
|
3138
|
+
case "Intrinsic":
|
|
3139
|
+
return type.name === "unknown";
|
|
3140
|
+
// A literal is its own type, and a union of them is the enum form a parameter commonly takes.
|
|
3141
|
+
case "Number":
|
|
3142
|
+
case "Boolean":
|
|
3143
|
+
return true;
|
|
3144
|
+
case "EnumMember":
|
|
3145
|
+
return typeof type.value === "number";
|
|
3146
|
+
case "Enum":
|
|
3147
|
+
return [...type.members.values()].some((member) => typeof member.value === "number");
|
|
3148
|
+
case "UnionVariant":
|
|
3149
|
+
return readsFromText(program, type.type);
|
|
3150
|
+
// Any member that is not text: `int32 | boolean`, `int32 | "all"` - see `wireRead`.
|
|
3151
|
+
case "Union":
|
|
3152
|
+
return [...type.variants.values()].some((variant) => readsFromText(program, variant.type));
|
|
3153
|
+
default:
|
|
3154
|
+
return false;
|
|
983
3155
|
}
|
|
984
|
-
return undefined;
|
|
985
3156
|
}
|
|
986
3157
|
/**
|
|
987
3158
|
* A name as an object key - quoted when it is not a legal JavaScript identifier.
|
|
@@ -1065,11 +3236,7 @@ export function jsDocComment(text, indent) {
|
|
|
1065
3236
|
* validator that renamed on the way through would be a transform, which JSON Schema cannot express
|
|
1066
3237
|
* and no document could state - the same reason `@trimmed` was removed.
|
|
1067
3238
|
*/
|
|
1068
|
-
function
|
|
1069
|
-
return objectKey(rawPropertyKey(program, property));
|
|
1070
|
-
}
|
|
1071
|
-
/** The same wire name, unquoted - for a vocabulary tuple, where the value is data rather than code. */
|
|
1072
|
-
function rawPropertyKey(program, property) {
|
|
3239
|
+
export function rawPropertyKey(program, property) {
|
|
1073
3240
|
return resolveEncodedName(program, property, JSON_MEDIA_TYPE);
|
|
1074
3241
|
}
|
|
1075
3242
|
/**
|
|
@@ -1149,37 +3316,963 @@ function isStdDateTime(program, scalar) {
|
|
|
1149
3316
|
}
|
|
1150
3317
|
/** Whether a property's value, or a scalar, is a date-time: one of the two, or built on one. */
|
|
1151
3318
|
function isDateTime(program, target) {
|
|
1152
|
-
const type = target.kind === "ModelProperty" ? (
|
|
3319
|
+
const type = target.kind === "ModelProperty" ? encodingTargetOf(target.type) : target;
|
|
1153
3320
|
for (let current = type.kind === "Scalar" ? type : undefined; current !== undefined; current = current.baseScalar) {
|
|
1154
3321
|
if (isStdDateTime(program, current))
|
|
1155
3322
|
return true;
|
|
1156
3323
|
}
|
|
1157
|
-
return false;
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
3324
|
+
return false;
|
|
3325
|
+
}
|
|
3326
|
+
/**
|
|
3327
|
+
* The type a property's `@encode` lands on in the document: the one member beside `null` where
|
|
3328
|
+
* openapi3 applies it there, and the declared type otherwise - see `encodedBesideNullOf`. A named
|
|
3329
|
+
* `union MaybeTime { utcDateTime, null }` is a `$ref`, so `rfc3339` on it publishes `type: string`
|
|
3330
|
+
* over the whole union and refuses `null`; read through the `null`, it was "no change", and `null`
|
|
3331
|
+
* was accepted.
|
|
3332
|
+
*/
|
|
3333
|
+
function encodingTargetOf(type) {
|
|
3334
|
+
const beside = encodedBesideNullOf(type);
|
|
3335
|
+
return beside?.length === 1 && beside[0] !== undefined ? beside[0] : type;
|
|
3336
|
+
}
|
|
3337
|
+
/** Whether a property's value, or a scalar, is a `duration`, or built on one. */
|
|
3338
|
+
function isDuration(program, target) {
|
|
3339
|
+
const type = target.kind === "ModelProperty" ? encodingTargetOf(target.type) : target;
|
|
3340
|
+
for (let current = type.kind === "Scalar" ? type : undefined; current !== undefined; current = current.baseScalar) {
|
|
3341
|
+
if (current === program.checker.getStdType("duration"))
|
|
3342
|
+
return true;
|
|
3343
|
+
}
|
|
3344
|
+
return false;
|
|
3345
|
+
}
|
|
3346
|
+
/**
|
|
3347
|
+
* The JSON type a value is published as before its own `@encode` replaces it: a property's declared
|
|
3348
|
+
* scalar, or the base a scalar is built from. `undefined` where it is not a scalar.
|
|
3349
|
+
*/
|
|
3350
|
+
function typeBeforeEncoding(program, target) {
|
|
3351
|
+
const type = target.kind === "ModelProperty" ? encodingTargetOf(target.type) : target.baseScalar;
|
|
3352
|
+
return type?.kind === "Scalar" ? jsonTypeOfScalar(program, type) : undefined;
|
|
3353
|
+
}
|
|
3354
|
+
export function encodedTypeOf(program, target) {
|
|
3355
|
+
const encoded = getEncode(program, target);
|
|
3356
|
+
if (encoded === undefined) {
|
|
3357
|
+
return target.kind === "ModelProperty" && isDateTimeHeader(program, target)
|
|
3358
|
+
? program.checker.getStdType("string")
|
|
3359
|
+
: undefined;
|
|
3360
|
+
}
|
|
3361
|
+
/**
|
|
3362
|
+
* **`rfc3339` on a date-time is the form it already has, so it changes nothing.** openapi3's
|
|
3363
|
+
* `mergeFormatAndEncoding` keeps `format: date-time` for it, and the type it reports, `string`,
|
|
3364
|
+
* emitted `z.string()`: the explicit spelling of the default dropped the date-time check the
|
|
3365
|
+
* document still states. Only the encodings that change the wire form - `rfc7231`,
|
|
3366
|
+
* `unixTimestamp` - change the type.
|
|
3367
|
+
*
|
|
3368
|
+
* **Only where the value already IS that string.** `@encode(rfc3339) at: Ts`, with `Ts` a
|
|
3369
|
+
* date-time `@encode`d as an integer, publishes `{ $ref: Ts, type: "string" }`, which no value
|
|
3370
|
+
* satisfies; read as "no change" it validated `Ts`'s integer and contradicted the document. The
|
|
3371
|
+
* same holds for `ISO8601` on a duration, the form `duration` already has.
|
|
3372
|
+
*/
|
|
3373
|
+
const before = typeBeforeEncoding(program, target);
|
|
3374
|
+
const unchanged = (encoded.encoding === "rfc3339" && isDateTime(program, target)) ||
|
|
3375
|
+
(encoded.encoding === "ISO8601" && isDuration(program, target));
|
|
3376
|
+
if (unchanged && before === "string")
|
|
3377
|
+
return undefined;
|
|
3378
|
+
if (target.kind === "ModelProperty" &&
|
|
3379
|
+
isQueryParam(program, target) &&
|
|
3380
|
+
PARAMETER_STYLE_ENCODINGS.has(encoded.encoding ?? "")) {
|
|
3381
|
+
return undefined;
|
|
3382
|
+
}
|
|
3383
|
+
return encoded.type;
|
|
3384
|
+
}
|
|
3385
|
+
export function encodedValueOf(program, property) {
|
|
3386
|
+
const scalar = encodedTypeOf(program, property);
|
|
3387
|
+
if (scalar === undefined)
|
|
3388
|
+
return { kind: "declared" };
|
|
3389
|
+
const declared = property.type;
|
|
3390
|
+
if (!program.checker.isStdType(scalar)) {
|
|
3391
|
+
return { kind: "unsatisfiable", scalar, declared, why: "typeless" };
|
|
3392
|
+
}
|
|
3393
|
+
const besideNull = encodedBesideNullOf(declared);
|
|
3394
|
+
const nullable = besideNull !== undefined;
|
|
3395
|
+
const inner = besideNull ?? [declared];
|
|
3396
|
+
const whole = inner.length === 1 ? inner[0] : undefined;
|
|
3397
|
+
if (whole !== undefined && !keepsDeclaredType(program, whole)) {
|
|
3398
|
+
return { kind: "replaced", scalar, nullable };
|
|
3399
|
+
}
|
|
3400
|
+
// A standard scalar always has a JSON type.
|
|
3401
|
+
const target = jsonTypeOfScalar(program, scalar) ?? "string";
|
|
3402
|
+
const members = whole === undefined ? inner : membersOf(whole);
|
|
3403
|
+
const kept = members.filter((member) => admits(program, member, target));
|
|
3404
|
+
if (kept.length === 0) {
|
|
3405
|
+
return { kind: "unsatisfiable", scalar, declared, why: nullable ? "null" : "nothing" };
|
|
3406
|
+
}
|
|
3407
|
+
return {
|
|
3408
|
+
kind: "both",
|
|
3409
|
+
scalar,
|
|
3410
|
+
form: getEncode(program, property)?.encoding,
|
|
3411
|
+
nullable,
|
|
3412
|
+
/**
|
|
3413
|
+
* The declared type whole where nothing was ruled out and it holds no scalar of its own -
|
|
3414
|
+
* so a named enum or union of literals is still referenced - and its members otherwise, so a
|
|
3415
|
+
* member scalar can be read the way `keptMemberToZod` reads it.
|
|
3416
|
+
*/
|
|
3417
|
+
kept: whole !== undefined &&
|
|
3418
|
+
kept.length === members.length &&
|
|
3419
|
+
(whole.kind === "Scalar" || !members.some((member) => member.kind === "Scalar"))
|
|
3420
|
+
? whole
|
|
3421
|
+
: kept,
|
|
3422
|
+
within: kept.every((member) => isWithin(program, member, target)),
|
|
3423
|
+
};
|
|
3424
|
+
}
|
|
3425
|
+
/**
|
|
3426
|
+
* **The members beside `null` that openapi3 3.1 applies an encoding to, where it applies it to them
|
|
3427
|
+
* rather than to the whole union.**
|
|
3428
|
+
*
|
|
3429
|
+
* Its `applyEncoding` takes that path only for an `anyOf` of exactly two members, one of them
|
|
3430
|
+
* `{ type: "null" }`. The members are what it PUBLISHES, not the variants written: literals of one
|
|
3431
|
+
* kind are merged into a single `enum`, so `"a" | "b" | null` is two members and `null` survives the
|
|
3432
|
+
* encoding, while `"a" | 1 | null` is three and the encoded `type` refuses `null`. A named union is
|
|
3433
|
+
* published as a `$ref`, never as that `anyOf`. Read off the variants, `"a" | "b" | null` refused the
|
|
3434
|
+
* `null` the document allows.
|
|
3435
|
+
*/
|
|
3436
|
+
function encodedBesideNullOf(type) {
|
|
3437
|
+
if (type.kind !== "Union" || isPublishedAsReference(type))
|
|
3438
|
+
return undefined;
|
|
3439
|
+
const variants = [...type.variants.values()].map((variant) => variant.type);
|
|
3440
|
+
const isNull = (each) => each.kind === "Intrinsic" && each.name === "null";
|
|
3441
|
+
const nulls = variants.filter(isNull).length;
|
|
3442
|
+
if (nulls !== 1)
|
|
3443
|
+
return undefined;
|
|
3444
|
+
const rest = variants.filter((each) => !isNull(each));
|
|
3445
|
+
const isLiteral = (each) => each.kind === "String" || each.kind === "Number" || each.kind === "Boolean";
|
|
3446
|
+
// Each `null` is a member of its own: `"a" | null | null` publishes three, and the encoding lands
|
|
3447
|
+
// on the whole `anyOf`.
|
|
3448
|
+
const published = new Set(rest.filter(isLiteral).map((each) => each.kind)).size +
|
|
3449
|
+
rest.filter((each) => !isLiteral(each)).length;
|
|
3450
|
+
return published === 1 ? rest : undefined;
|
|
3451
|
+
}
|
|
3452
|
+
/**
|
|
3453
|
+
* **Whether openapi3 publishes a union as a `$ref` to a component, rather than inline.**
|
|
3454
|
+
*
|
|
3455
|
+
* A named union is a component; an anonymous one is written out where it is used. A union template
|
|
3456
|
+
* instance is a component only where every argument has a name of its own to build the component's
|
|
3457
|
+
* name from - `Opt<int32>` is `OptInt32` - and is written out inline otherwise, as `Opt<"a">` is:
|
|
3458
|
+
* the emitter that names declarations calls such an instance "unspeakable". Read as a `$ref`, the
|
|
3459
|
+
* `null` that `Opt<"a">` publishes beside `"a"` was refused.
|
|
3460
|
+
*/
|
|
3461
|
+
function isPublishedAsReference(union) {
|
|
3462
|
+
if (union.name === undefined || union.name === "")
|
|
3463
|
+
return false;
|
|
3464
|
+
return union.templateMapper === undefined || union.templateMapper.args.every(isSpeakable);
|
|
3465
|
+
}
|
|
3466
|
+
/** Whether a template argument has a declaration name openapi3 can build a component name from. */
|
|
3467
|
+
function isSpeakable(argument) {
|
|
3468
|
+
const type = (argument.entityKind === "Indeterminate"
|
|
3469
|
+
? argument.type
|
|
3470
|
+
: argument);
|
|
3471
|
+
if (type === undefined || !("kind" in type))
|
|
3472
|
+
return false;
|
|
3473
|
+
switch (type.kind) {
|
|
3474
|
+
case "Model":
|
|
3475
|
+
case "Scalar":
|
|
3476
|
+
case "Interface":
|
|
3477
|
+
case "Operation":
|
|
3478
|
+
case "Enum":
|
|
3479
|
+
case "Union":
|
|
3480
|
+
case "Intrinsic":
|
|
3481
|
+
if (!("name" in type) || type.name === undefined || type.name === "")
|
|
3482
|
+
return false;
|
|
3483
|
+
return (!("templateMapper" in type) ||
|
|
3484
|
+
type.templateMapper === undefined ||
|
|
3485
|
+
type.templateMapper.args.every(isSpeakable));
|
|
3486
|
+
default:
|
|
3487
|
+
return false;
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
/**
|
|
3491
|
+
* Whether openapi3 keeps the declared type's schema beside an encoding in a way that still
|
|
3492
|
+
* constrains the value: a `$ref` to anything it declares - a model, an enum, a named union, a scalar
|
|
3493
|
+
* outside the standard library - or an inline union or literal, whose `anyOf` or `enum` stays.
|
|
3494
|
+
*
|
|
3495
|
+
* **Declared means what `@typespec/openapi` says is declared**, through its own `shouldInline`. An
|
|
3496
|
+
* inline list, record or object - `{ ...Filter }` among them, which has no name of its own - keeps
|
|
3497
|
+
* only keywords a scalar is not checked against. This read the emitter's own naming, which names a
|
|
3498
|
+
* spread after the model spread into it, and refused `{ ...Filter }` as unsatisfiable while the
|
|
3499
|
+
* document publishes it inline and accepts any string.
|
|
3500
|
+
*/
|
|
3501
|
+
function keepsDeclaredType(program, declared) {
|
|
3502
|
+
switch (declared.kind) {
|
|
3503
|
+
case "Enum":
|
|
3504
|
+
case "EnumMember":
|
|
3505
|
+
case "Union":
|
|
3506
|
+
case "String":
|
|
3507
|
+
case "Number":
|
|
3508
|
+
case "Boolean":
|
|
3509
|
+
return true;
|
|
3510
|
+
case "Scalar":
|
|
3511
|
+
case "Model":
|
|
3512
|
+
return !shouldInline(program, declared);
|
|
3513
|
+
default:
|
|
3514
|
+
return false;
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
/** A declared type as the members its schema lists: a union's variants, an enum's members. */
|
|
3518
|
+
/**
|
|
3519
|
+
* **A union's members, a nested union spelled out only where it combines as the union does** -
|
|
3520
|
+
* `anyOf` inside `anyOf`, `oneOf` inside `oneOf`. A nested union of the other kind is one member,
|
|
3521
|
+
* whole: `@oneOf` over `int32 | float` and a `string` is exactly one of two, where spelled out it was
|
|
3522
|
+
* exactly one of three and an integer, both an `int32` and a `float`, was none; and a `@oneOf` inside
|
|
3523
|
+
* a plain union, spelled out, admitted the value two of its members accept.
|
|
3524
|
+
*/
|
|
3525
|
+
function alikeMembersOf(program, type, exclusive = isExclusive(program, type)) {
|
|
3526
|
+
if (type.kind === "UnionVariant")
|
|
3527
|
+
return alikeMembersOf(program, type.type, exclusive);
|
|
3528
|
+
if (type.kind === "Enum")
|
|
3529
|
+
return [...type.members.values()];
|
|
3530
|
+
if (type.kind !== "Union")
|
|
3531
|
+
return [type];
|
|
3532
|
+
/**
|
|
3533
|
+
* **A nested union is one member unless both sides are `anyOf`**: `anyOf(anyOf(a, b), c)` is
|
|
3534
|
+
* `anyOf(a, b, c)`, but `oneOf(oneOf(a, b), c)` is not `oneOf(a, b, c)` - `1` matches both of
|
|
3535
|
+
* `int32 | float` and so fails the inner union, and is valid by `c` alone. Spelled out, `1` was
|
|
3536
|
+
* refused where the document accepts it.
|
|
3537
|
+
*/
|
|
3538
|
+
const members = [...type.variants.values()].flatMap((variant) => {
|
|
3539
|
+
const member = variant.type;
|
|
3540
|
+
return member.kind === "Union" && (exclusive || isPublishedOneOf(program, member))
|
|
3541
|
+
? [member]
|
|
3542
|
+
: alikeMembersOf(program, member, exclusive);
|
|
3543
|
+
});
|
|
3544
|
+
return exclusive ? withoutRepeatedLiterals(members) : members;
|
|
3545
|
+
}
|
|
3546
|
+
/**
|
|
3547
|
+
* **A literal repeated in a `@oneOf` is one member**, as openapi3 publishes every literal of one
|
|
3548
|
+
* JSON kind as one `enum`: `@oneOf union D { a: 1, b: 1, c: string }` is
|
|
3549
|
+
* `oneOf: [{ enum: [1, 1] }, { type: string }]`, which `1` matches once. Each its own member, `1`
|
|
3550
|
+
* matched two, and was refused.
|
|
3551
|
+
*/
|
|
3552
|
+
function withoutRepeatedLiterals(members) {
|
|
3553
|
+
const seen = new Set();
|
|
3554
|
+
return members.filter((member) => {
|
|
3555
|
+
const value = isEnvelope(member) || member.kind === "EnumMember" ? undefined : literalValueOf(member);
|
|
3556
|
+
if (value === undefined)
|
|
3557
|
+
return true;
|
|
3558
|
+
const key = `${typeof value}\u0000${String(value)}`;
|
|
3559
|
+
if (seen.has(key))
|
|
3560
|
+
return false;
|
|
3561
|
+
seen.add(key);
|
|
3562
|
+
return true;
|
|
3563
|
+
});
|
|
3564
|
+
}
|
|
3565
|
+
/** Whether `null` is a value of a type: a member, once under `@oneOf`, or a member with no type. */
|
|
3566
|
+
function admitsNullValue(program, type, exclusive = isExclusive(program, type)) {
|
|
3567
|
+
if (type.kind === "UnionVariant")
|
|
3568
|
+
return admitsNullValue(program, type.type);
|
|
3569
|
+
if (isNull(type) || isTypelessMember(program, type))
|
|
3570
|
+
return true;
|
|
3571
|
+
if (type.kind !== "Union")
|
|
3572
|
+
return false;
|
|
3573
|
+
const matches = alikeMembersOf(program, type, exclusive).filter((member) => admitsNullValue(program, member)).length;
|
|
3574
|
+
return exclusive ? matches === 1 : matches > 0;
|
|
3575
|
+
}
|
|
3576
|
+
function membersOf(type) {
|
|
3577
|
+
// `DogKind.Golden` names a variant of a union, and stands for its type.
|
|
3578
|
+
if (type.kind === "UnionVariant")
|
|
3579
|
+
return membersOf(type.type);
|
|
3580
|
+
if (type.kind === "Union")
|
|
3581
|
+
return [...type.variants.values()].flatMap((v) => membersOf(v.type));
|
|
3582
|
+
if (type.kind === "Enum")
|
|
3583
|
+
return [...type.members.values()];
|
|
3584
|
+
return [type];
|
|
3585
|
+
}
|
|
3586
|
+
/** Whether a member of the declared type has a value of the encoded scalar's JSON type. */
|
|
3587
|
+
function admits(program, member, target) {
|
|
3588
|
+
const types = jsonTypesOf(program, member);
|
|
3589
|
+
if (types === undefined)
|
|
3590
|
+
return true;
|
|
3591
|
+
return [...types].some((type) => type === target ||
|
|
3592
|
+
(target === "integer" && type === "number") ||
|
|
3593
|
+
(target === "number" && (type === "integer" || type === "fraction")));
|
|
3594
|
+
}
|
|
3595
|
+
/** Whether every value of a member is of the encoded scalar's JSON type, so it alone says both. */
|
|
3596
|
+
function isWithin(program, member, target) {
|
|
3597
|
+
const types = jsonTypesOf(program, member);
|
|
3598
|
+
if (types === undefined)
|
|
3599
|
+
return false;
|
|
3600
|
+
return [...types].every((type) => type === target || (target === "number" && (type === "integer" || type === "fraction")));
|
|
3601
|
+
}
|
|
3602
|
+
const INTEGER_SCALARS = [
|
|
3603
|
+
"integer",
|
|
3604
|
+
"int8",
|
|
3605
|
+
"int16",
|
|
3606
|
+
"int32",
|
|
3607
|
+
"int64",
|
|
3608
|
+
"uint8",
|
|
3609
|
+
"uint16",
|
|
3610
|
+
"uint32",
|
|
3611
|
+
"uint64",
|
|
3612
|
+
"safeint",
|
|
3613
|
+
];
|
|
3614
|
+
const NUMBER_SCALARS = ["numeric", "float", "float32", "float64", "decimal", "decimal128"];
|
|
3615
|
+
/**
|
|
3616
|
+
* The JSON type a scalar is published as, its own scalar-level `@encode` included - or `undefined`
|
|
3617
|
+
* for a scalar with no standard base, which openapi3 publishes as `{}`, any value. Read as a string,
|
|
3618
|
+
* `@encode("x", int32)` on one was refused as unsatisfiable while the document accepts `5`.
|
|
3619
|
+
*/
|
|
3620
|
+
export function jsonTypeOfScalar(program, scalar) {
|
|
3621
|
+
const { checker } = program;
|
|
3622
|
+
for (let current = scalar; current !== undefined; current = current.baseScalar) {
|
|
3623
|
+
const encoded = encodedTypeOf(program, current);
|
|
3624
|
+
if (encoded !== undefined && encoded !== current && checker.isStdType(encoded)) {
|
|
3625
|
+
return jsonTypeOfScalar(program, encoded);
|
|
3626
|
+
}
|
|
3627
|
+
const link = current;
|
|
3628
|
+
if (INTEGER_SCALARS.some((name) => link === checker.getStdType(name)))
|
|
3629
|
+
return "integer";
|
|
3630
|
+
if (NUMBER_SCALARS.some((name) => link === checker.getStdType(name)))
|
|
3631
|
+
return "number";
|
|
3632
|
+
if (link === checker.getStdType("boolean"))
|
|
3633
|
+
return "boolean";
|
|
3634
|
+
if (checker.isStdType(link))
|
|
3635
|
+
return "string";
|
|
3636
|
+
}
|
|
3637
|
+
return undefined;
|
|
3638
|
+
}
|
|
3639
|
+
/** The JSON types a declared type admits, or `undefined` where it admits any. */
|
|
3640
|
+
function jsonTypesOf(program, type) {
|
|
3641
|
+
const numeric = (value) => (Number.isInteger(value) ? "integer" : "fraction");
|
|
3642
|
+
switch (type.kind) {
|
|
3643
|
+
case "Scalar": {
|
|
3644
|
+
const json = jsonTypeOfScalar(program, type);
|
|
3645
|
+
return json === undefined ? undefined : new Set([json]);
|
|
3646
|
+
}
|
|
3647
|
+
case "String":
|
|
3648
|
+
return new Set(["string"]);
|
|
3649
|
+
case "Number":
|
|
3650
|
+
return new Set([numeric(type.value)]);
|
|
3651
|
+
case "Boolean":
|
|
3652
|
+
return new Set(["boolean"]);
|
|
3653
|
+
case "EnumMember":
|
|
3654
|
+
return new Set([typeof type.value === "number" ? numeric(type.value) : "string"]);
|
|
3655
|
+
case "Enum":
|
|
3656
|
+
case "Union": {
|
|
3657
|
+
const types = new Set();
|
|
3658
|
+
for (const member of membersOf(type)) {
|
|
3659
|
+
const memberTypes = jsonTypesOf(program, member);
|
|
3660
|
+
if (memberTypes === undefined)
|
|
3661
|
+
return undefined;
|
|
3662
|
+
for (const each of memberTypes)
|
|
3663
|
+
types.add(each);
|
|
3664
|
+
}
|
|
3665
|
+
return types;
|
|
3666
|
+
}
|
|
3667
|
+
case "Tuple":
|
|
3668
|
+
return new Set(["array"]);
|
|
3669
|
+
case "Model":
|
|
3670
|
+
return new Set([type.indexer?.key.name === "integer" ? "array" : "object"]);
|
|
3671
|
+
case "Intrinsic":
|
|
3672
|
+
return type.name === "null" ? new Set(["null"]) : undefined;
|
|
3673
|
+
default:
|
|
3674
|
+
return undefined;
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
/** `unsatisfiable-encoding`, once per declaration however many positions render it. */
|
|
3678
|
+
function reportUnsatisfiableEncoding(program, target, format) {
|
|
3679
|
+
if (!firstReportOf(program, "unsatisfiable-encoding", target, `${format.declared} ${format.encoded}`)) {
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
const why = {
|
|
3683
|
+
typeless: `'${format.encoded}' is not a standard scalar, so '@typespec/openapi3' publishes no type for it and the document accepts any value`,
|
|
3684
|
+
nothing: "'@typespec/openapi3' keeps the declared type beside the encoded one, and no value is both, so the document accepts nothing",
|
|
3685
|
+
null: "'@typespec/openapi3' keeps the declared type beside the encoded one, and no value but null is both, so the document accepts only null",
|
|
3686
|
+
}[format.why];
|
|
3687
|
+
reportDiagnostic(program, {
|
|
3688
|
+
code: "unsatisfiable-encoding",
|
|
3689
|
+
target,
|
|
3690
|
+
format: { name: format.name, declared: format.declared, encoded: format.encoded, why },
|
|
3691
|
+
});
|
|
3692
|
+
}
|
|
3693
|
+
/** A type's name for a message: its own where it has one, else its spelling. */
|
|
3694
|
+
function nameOfType(type) {
|
|
3695
|
+
return "name" in type && typeof type.name === "string" && type.name !== ""
|
|
3696
|
+
? type.name
|
|
3697
|
+
: getTypeName(type);
|
|
3698
|
+
}
|
|
3699
|
+
/**
|
|
3700
|
+
* **Whether an encoded value is written as the encoded scalar intersected with what it keeps.**
|
|
3701
|
+
*
|
|
3702
|
+
* Where the kept type alone says both it is written alone - but a constraint on the property cannot
|
|
3703
|
+
* go on a kept union or literal, which carries no `.min` or `.regex`, so then the constraints go on
|
|
3704
|
+
* the encoded scalar and the kept type is intersected beside it. Applied to the union,
|
|
3705
|
+
* `@encode("x", string) @minLength(2) v: string | "a"` emitted `z.union(...).min(2)`, and the module
|
|
3706
|
+
* threw on import.
|
|
3707
|
+
*/
|
|
3708
|
+
export function encodedIntersects(program, property, encoding) {
|
|
3709
|
+
if (!encoding.within)
|
|
3710
|
+
return true;
|
|
3711
|
+
const kept = encoding.kept;
|
|
3712
|
+
const scalar = !Array.isArray(kept) && kept.kind === "Scalar";
|
|
3713
|
+
return hasConstraints(program, property) && !scalar;
|
|
3714
|
+
}
|
|
3715
|
+
/**
|
|
3716
|
+
* **A union property's constraints, applied to each member as JSON Schema applies them.**
|
|
3717
|
+
*
|
|
3718
|
+
* `@minLength(2) v: string | "a" | int32` is published `{ anyOf: [...], minLength: 2 }`, and
|
|
3719
|
+
* `minLength` constrains a value only where it is a string: `"ab"` and `5` are valid, `"a"` is not.
|
|
3720
|
+
* Each member takes the keywords of its own JSON type, and a literal member breaking one is no value
|
|
3721
|
+
* at all. Applied to the union, this emitted `z.union(...).min(2)`, and the module threw on import.
|
|
3722
|
+
* The constraints are the property's own and those of every declaration above it the position
|
|
3723
|
+
* leaves - see `PropertyPosition` - and, where openapi3 adds the discriminator as a `string`, only
|
|
3724
|
+
* the members that can be a string are left. Returns the members left, or `undefined` where the
|
|
3725
|
+
* property is not a union read member by member.
|
|
3726
|
+
*/
|
|
3727
|
+
export function constrainedMembersOf(program, property, position = OWN_POSITION) {
|
|
3728
|
+
const declared = nullableInnerOf(property.type) ?? property.type;
|
|
3729
|
+
const sources = [property, ...position.constrainedBy];
|
|
3730
|
+
const constrained = sources.some((source) => hasConstraints(program, source));
|
|
3731
|
+
const typeless = isTypelessMember(program, declared);
|
|
3732
|
+
/**
|
|
3733
|
+
* A value with no type of its own, constrained or narrowed to a string, is read as its kinds. An
|
|
3734
|
+
* enum a keyword constrains is read as its members, as a union of literals is: `e: Color` under a
|
|
3735
|
+
* declaration's `@maxLength(3)` keeps `red`, where the keyword applied to the enum's schema was
|
|
3736
|
+
* `colorSchema.max(3)`, and the module threw on import.
|
|
3737
|
+
*/
|
|
3738
|
+
const members = declared.kind === "Union" || (declared.kind === "Enum" && constrained);
|
|
3739
|
+
if (!members && !(typeless && (position.stringOnly || constrained))) {
|
|
3740
|
+
return undefined;
|
|
3741
|
+
}
|
|
3742
|
+
// A `null` a declaration above refuses is no member.
|
|
3743
|
+
const dropsNull = position.nonNull && hasNull(declared);
|
|
3744
|
+
if (declared.kind === "Union" && !constrained && !position.stringOnly && !dropsNull) {
|
|
3745
|
+
return undefined;
|
|
3746
|
+
}
|
|
3747
|
+
const string = program.checker.getStdType("string");
|
|
3748
|
+
const exclusive = isExclusive(program, declared) || isExclusive(program, property);
|
|
3749
|
+
return (alikeMembersOf(program, declared, exclusive)
|
|
3750
|
+
.flatMap((member) => {
|
|
3751
|
+
if (!position.nonNull)
|
|
3752
|
+
return [member];
|
|
3753
|
+
const kept = withoutNull(program, member);
|
|
3754
|
+
return kept === undefined ? [] : [kept];
|
|
3755
|
+
})
|
|
3756
|
+
.map((member) => (position.stringOnly && isTypelessMember(program, member) ? string : member))
|
|
3757
|
+
.filter((member) => !position.stringOnly || admits(program, member, "string"))
|
|
3758
|
+
/**
|
|
3759
|
+
* A literal a declaration above refuses is no member: by its keywords, or by the check its
|
|
3760
|
+
* declared scalar emits. Each member is then exactly its values the position admits, so a
|
|
3761
|
+
* `@oneOf` counts a value as before: `@oneOf union O1 { Color, string }` under `k: utcDateTime`
|
|
3762
|
+
* kept `"red"`, matched by no other narrowed member, where the document finds it in two.
|
|
3763
|
+
*/
|
|
3764
|
+
.filter((member) => {
|
|
3765
|
+
const value = literalValueOf(member);
|
|
3766
|
+
return (value === undefined ||
|
|
3767
|
+
sources.every((source) => brokenConstraintOf(program, source, value) === undefined &&
|
|
3768
|
+
(source.kind !== "Scalar" || scalarAccepts(program, source, value) !== false)));
|
|
3769
|
+
}));
|
|
3770
|
+
}
|
|
3771
|
+
/**
|
|
3772
|
+
* **A member without its `null`, where a declaration above refuses `null`**, or `undefined` where
|
|
3773
|
+
* nothing is left. A `@oneOf` keeps a nested union whole - see `alikeMembersOf` - so the `null` of
|
|
3774
|
+
* `@oneOf union O8 { v0: "x" | null, v1: "y" }` sat inside a member, and under `c: string` the
|
|
3775
|
+
* value accepted the `null` the document refuses. Taken out of the member it is sound: a value other
|
|
3776
|
+
* than `null` matches each member exactly as before, so a `@oneOf` counts it the same.
|
|
3777
|
+
*/
|
|
3778
|
+
function withoutNull(program, member) {
|
|
3779
|
+
const type = member.kind === "UnionVariant" ? member.type : member;
|
|
3780
|
+
if (isNull(type))
|
|
3781
|
+
return undefined;
|
|
3782
|
+
if (type.kind !== "Union" || !hasNull(type))
|
|
3783
|
+
return member;
|
|
3784
|
+
const kept = [...type.variants.values()].flatMap((variant) => {
|
|
3785
|
+
const each = withoutNull(program, variant.type);
|
|
3786
|
+
return each === undefined ? [] : [each];
|
|
3787
|
+
});
|
|
3788
|
+
if (kept.length <= 1)
|
|
3789
|
+
return kept[0];
|
|
3790
|
+
const union = $(program).union.create(kept);
|
|
3791
|
+
if (isExclusive(program, type))
|
|
3792
|
+
exclusiveWithoutNull.add(union);
|
|
3793
|
+
return union;
|
|
3794
|
+
}
|
|
3795
|
+
/** The unions {@link withoutNull} builds from a `@oneOf`, which stay exclusive without the decorator. */
|
|
3796
|
+
const exclusiveWithoutNull = new WeakSet();
|
|
3797
|
+
/** A member with no JSON type of its own: `unknown`, or a scalar with no standard base. */
|
|
3798
|
+
export function isTypelessMember(program, member) {
|
|
3799
|
+
if (member.kind === "Intrinsic")
|
|
3800
|
+
return member.name === "unknown";
|
|
3801
|
+
return member.kind === "Scalar" && jsonTypeOfScalar(program, member) === undefined;
|
|
3802
|
+
}
|
|
3803
|
+
/**
|
|
3804
|
+
* **Any JSON value, kind by kind** - what a member with no type is, where a keyword constrains the
|
|
3805
|
+
* kinds it applies to: `@minLength(2) v: string | unknown` refuses `"a"`, which `unknown` alone
|
|
3806
|
+
* accepted. TypeScript states the same union.
|
|
3807
|
+
*/
|
|
3808
|
+
const ANY_JSON = [
|
|
3809
|
+
["z.string()", "string", "string"],
|
|
3810
|
+
["z.number()", "number", "number"],
|
|
3811
|
+
["z.boolean()", "boolean", "boolean"],
|
|
3812
|
+
["z.null()", "null", "null"],
|
|
3813
|
+
["z.array(z.unknown())", "array", "unknown[]"],
|
|
3814
|
+
["z.record(z.string(), z.unknown())", "object", "globalThis.Record<string, unknown>"],
|
|
3815
|
+
];
|
|
3816
|
+
export const ANY_JSON_TS = ANY_JSON.map(([, , ts]) => ts).join(" | ");
|
|
3817
|
+
/**
|
|
3818
|
+
* A value with the constraints of the declarations above it applied - see `PropertyPosition` - for
|
|
3819
|
+
* the value's own JSON type, as JSON Schema applies them through `allOf`.
|
|
3820
|
+
*/
|
|
3821
|
+
function withAncestors(program, value, property, position) {
|
|
3822
|
+
const json = ownJsonTypeOf(program, property);
|
|
3823
|
+
return position.constrainedBy.reduce((each, ancestor) => applyConstraints(program, each, ancestor, json === undefined ? { quiet: true } : { type: json, quiet: true }), value);
|
|
3824
|
+
}
|
|
3825
|
+
/** The JSON type of a property's own value, where one keyword family applies to it. */
|
|
3826
|
+
function ownJsonTypeOf(program, property) {
|
|
3827
|
+
const published = publishedJsonTypeOf(program, property);
|
|
3828
|
+
if (published !== undefined)
|
|
3829
|
+
return published;
|
|
3830
|
+
const declared = nullableInnerOf(property.type) ?? property.type;
|
|
3831
|
+
if (declared.kind === "Model")
|
|
3832
|
+
return declared.indexer?.key.name === "integer" ? "array" : "object";
|
|
3833
|
+
if (declared.kind === "Tuple")
|
|
3834
|
+
return "array";
|
|
3835
|
+
return isNull(declared) ? "null" : undefined;
|
|
3836
|
+
}
|
|
3837
|
+
/** `unrepresentable-override`, once per declaration and ancestor. */
|
|
3838
|
+
function reportUnrepresentableOverride(program, property, position) {
|
|
3839
|
+
const refused = position.refused;
|
|
3840
|
+
if (refused === undefined)
|
|
3841
|
+
return;
|
|
3842
|
+
const owner = refused.ancestor.model?.name ?? "its base";
|
|
3843
|
+
if (!firstReportOf(program, "unrepresentable-override", property, owner))
|
|
3844
|
+
return;
|
|
3845
|
+
reportDiagnostic(program, {
|
|
3846
|
+
code: "unrepresentable-override",
|
|
3847
|
+
messageId: refused.recursive ? "recursive" : "default",
|
|
3848
|
+
target: property,
|
|
3849
|
+
format: {
|
|
3850
|
+
name: property.name,
|
|
3851
|
+
model: property.model?.name ?? "",
|
|
3852
|
+
base: owner,
|
|
3853
|
+
declared: getTypeName(refused.ancestor.type, { namespaceFilter: () => false }),
|
|
3854
|
+
why: refused.why,
|
|
3855
|
+
},
|
|
3856
|
+
});
|
|
3857
|
+
}
|
|
3858
|
+
/** A literal member's value, or `undefined` where the member is not a literal. */
|
|
3859
|
+
function literalValueOf(type) {
|
|
3860
|
+
switch (type.kind) {
|
|
3861
|
+
case "String":
|
|
3862
|
+
case "Number":
|
|
3863
|
+
case "Boolean":
|
|
3864
|
+
return type.value;
|
|
3865
|
+
case "EnumMember":
|
|
3866
|
+
return type.value ?? type.name;
|
|
3867
|
+
default:
|
|
3868
|
+
return undefined;
|
|
3869
|
+
}
|
|
3870
|
+
}
|
|
3871
|
+
/** One member of a constrained union, with each source's keywords for its JSON type. */
|
|
3872
|
+
function constrainedMemberToZod(program, member, sources) {
|
|
3873
|
+
const constrain = (schema, type) => sources.reduce((value, source) => applyConstraints(program, value, source, { type, quiet: true }), schema);
|
|
3874
|
+
if (isTypelessMember(program, member)) {
|
|
3875
|
+
return `z.union([${ANY_JSON.map(([schema, json]) => constrain(schema, json)).join(", ")}])`;
|
|
3876
|
+
}
|
|
3877
|
+
const schema = typeToZod(program, member);
|
|
3878
|
+
if (literalValueOf(member) !== undefined)
|
|
3879
|
+
return schema;
|
|
3880
|
+
const type = member.kind === "Scalar"
|
|
3881
|
+
? jsonTypeOfScalar(program, member)
|
|
3882
|
+
: member.kind === "Tuple" ||
|
|
3883
|
+
(member.kind === "Model" && member.indexer?.key.name === "integer")
|
|
3884
|
+
? "array"
|
|
3885
|
+
: member.kind === "Intrinsic" && member.name === "null"
|
|
3886
|
+
? "null"
|
|
3887
|
+
: member.kind === "Model"
|
|
3888
|
+
? "object"
|
|
3889
|
+
: undefined;
|
|
3890
|
+
return type === undefined ? schema : constrain(schema, type);
|
|
3891
|
+
}
|
|
3892
|
+
/**
|
|
3893
|
+
* What an encoded property keeps of its declared type, as Zod: the whole type where none of it was
|
|
3894
|
+
* ruled out, else the members of the encoded scalar's JSON type.
|
|
3895
|
+
*
|
|
3896
|
+
* **A declared scalar of the encoded scalar's JSON type keeps its keywords and not its format.**
|
|
3897
|
+
* openapi3 publishes `@encode(rfc7231) at: Instant` as `{ $ref: Instant, type: "string" }`, and
|
|
3898
|
+
* `Instant`'s own check - an ISO date-time - is the wire form the encoding replaced: an HTTP-date is
|
|
3899
|
+
* the value now. What the reference still asserts is `Instant`'s constraints. Validated as `Instant`,
|
|
3900
|
+
* every HTTP-date was refused.
|
|
3901
|
+
*/
|
|
3902
|
+
function keptToZod(program, encoding, schemaOf) {
|
|
3903
|
+
if (Array.isArray(encoding.kept)) {
|
|
3904
|
+
return membersToZod(program, encoding.kept, (member) => keptMemberToZod(program, encoding, member) ?? typeToZod(program, member));
|
|
3905
|
+
}
|
|
3906
|
+
const kept = encoding.kept;
|
|
3907
|
+
return keptMemberToZod(program, encoding, kept) ?? schemaOf(kept);
|
|
3908
|
+
}
|
|
3909
|
+
/**
|
|
3910
|
+
* A declared scalar of the encoded scalar's JSON type, as the encoded scalar plus the declared one's
|
|
3911
|
+
* constraints - or `undefined` where the member is read as itself. The encoding replaced the form the
|
|
3912
|
+
* scalar's own check is for, unless it IS that form: `rfc3339` on a date-time, `ISO8601` on a
|
|
3913
|
+
* duration.
|
|
3914
|
+
*/
|
|
3915
|
+
function keptMemberToZod(program, encoding, member) {
|
|
3916
|
+
return replacesFormOf(program, encoding, member)
|
|
3917
|
+
? applyConstraints(program, scalarSchemaOf(program, encoding.scalar), member)
|
|
3918
|
+
: undefined;
|
|
3919
|
+
}
|
|
3920
|
+
/** Whether a kept member is a scalar whose own check the encoding's wire form replaces. */
|
|
3921
|
+
export function replacesFormOf(program, encoding, member) {
|
|
3922
|
+
if (member.kind !== "Scalar")
|
|
3923
|
+
return false;
|
|
3924
|
+
if (jsonTypeOfScalar(program, member) !== jsonTypeOfScalar(program, encoding.scalar))
|
|
3925
|
+
return false;
|
|
3926
|
+
const ownForm = (encoding.form === "rfc3339" && isDateTime(program, member)) ||
|
|
3927
|
+
(encoding.form === "ISO8601" && isDuration(program, member));
|
|
3928
|
+
return !ownForm;
|
|
3929
|
+
}
|
|
3930
|
+
/** Members of a declared type, as the one schema a union of them is. */
|
|
3931
|
+
function membersToZod(program, members, render = (member) => typeToZod(program, member), exclusive = false) {
|
|
3932
|
+
if (members.every((member) => member.kind === "String")) {
|
|
3933
|
+
const literals = members.map((member) => literal(member.value));
|
|
3934
|
+
return `z.enum([${literals.join(", ")}])`;
|
|
3935
|
+
}
|
|
3936
|
+
const [only] = members;
|
|
3937
|
+
if (members.length === 1 && only !== undefined)
|
|
3938
|
+
return render(only);
|
|
3939
|
+
return `${exclusive ? "z.xor" : "z.union"}([${members.map(render).join(", ")}])`;
|
|
3940
|
+
}
|
|
3941
|
+
/**
|
|
3942
|
+
* **Whether a union is published as `oneOf`, where exactly one member may accept a value.**
|
|
3943
|
+
* `@oneOf` is `@typespec/openapi3`'s decorator, on a union or on a property whose inline union it
|
|
3944
|
+
* makes `oneOf`; openapi3 publishes `anyOf` otherwise. Read by its state key, which the library mints
|
|
3945
|
+
* with `Symbol.for`, so this package depends on nothing it does not already. Validated as `z.union`,
|
|
3946
|
+
* a value two members accept was accepted where the document refuses it; `z.xor` is the exclusive
|
|
3947
|
+
* union.
|
|
3948
|
+
*/
|
|
3949
|
+
/**
|
|
3950
|
+
* **Whether openapi3 publishes a union as `oneOf`**: a `@oneOf`, or a `@discriminated` union, whose
|
|
3951
|
+
* variants it publishes as `oneOf` beside the discriminator mapping. A nested one is a member of its
|
|
3952
|
+
* own, whole: spread into `DN | O2`, `@discriminated union DN { a: NA, b: NB }` lost its
|
|
3953
|
+
* exclusivity, and under a PATCH `{}` - which both `NAUpdate` and `NBUpdate` accept, so `DNUpdate`
|
|
3954
|
+
* refuses - was accepted.
|
|
3955
|
+
*/
|
|
3956
|
+
function isPublishedOneOf(program, union) {
|
|
3957
|
+
return isExclusive(program, union) || getDiscriminatedUnion(program, union)[0] !== undefined;
|
|
3958
|
+
}
|
|
3959
|
+
export function isExclusive(program, target) {
|
|
3960
|
+
return (program.stateMap(Symbol.for("@typespec/openapi3.oneOf")).get(target) === true ||
|
|
3961
|
+
exclusiveWithoutNull.has(target));
|
|
3962
|
+
}
|
|
3963
|
+
/** The inline union a property's `@oneOf` makes exclusive, while the property's value renders. */
|
|
3964
|
+
let exclusiveSite;
|
|
3965
|
+
/** Whether a union is exclusive where it is rendered: its own `@oneOf`, or its property's. */
|
|
3966
|
+
export function isExclusiveHere(program, union) {
|
|
3967
|
+
return isExclusive(program, union) || exclusiveSite === union;
|
|
3968
|
+
}
|
|
3969
|
+
/** The inline union a property's `@oneOf` makes exclusive; a named one is a `$ref` it leaves alone. */
|
|
3970
|
+
export function exclusiveSiteOf(program, property, declared) {
|
|
3971
|
+
return isExclusive(program, property) && declared.kind === "Union" && !declared.name
|
|
3972
|
+
? declared
|
|
3973
|
+
: undefined;
|
|
3974
|
+
}
|
|
3975
|
+
/**
|
|
3976
|
+
* **Whether a `@oneOf` holds a member whose declaration is refused, or refers to one that is**, so
|
|
3977
|
+
* that it validates nothing. openapi3 still publishes the member, and counts it: `@oneOf union O {
|
|
3978
|
+
* a: S, b: N }` with `S` refused as `split-visibility` accepted `{"id": "i"}`, which matches both
|
|
3979
|
+
* members in the document and so is refused there - `z.xor` counted the refused `S` as matching
|
|
3980
|
+
* nothing. **A member holding a refused declaration further down is held stricter in the same way**:
|
|
3981
|
+
* `@oneOf { v0: M16, v1: M13 }`, with `M13`'s `c?: M14` refused, accepted `{ c: {...}, e: "red" }`,
|
|
3982
|
+
* which matches both in the document, as `M16` alone. Both walks ask it, so the validator and the
|
|
3983
|
+
* contract type are `never` together.
|
|
3984
|
+
*/
|
|
3985
|
+
export function holdsRefusedMember(program, union, exclusive) {
|
|
3986
|
+
if (!exclusive)
|
|
3987
|
+
return false;
|
|
3988
|
+
return [...union.variants.values()].some(({ type }) => {
|
|
3989
|
+
if (type.kind !== "Model" && type.kind !== "Union")
|
|
3990
|
+
return false;
|
|
3991
|
+
return held.holdsRefusalAt(type, renderedVisibilityOf(program, type, visibility));
|
|
3992
|
+
});
|
|
3993
|
+
}
|
|
3994
|
+
export function withExclusiveSite(site, run) {
|
|
3995
|
+
const previous = exclusiveSite;
|
|
3996
|
+
exclusiveSite = site;
|
|
3997
|
+
try {
|
|
3998
|
+
return run();
|
|
3999
|
+
}
|
|
4000
|
+
finally {
|
|
4001
|
+
exclusiveSite = previous;
|
|
4002
|
+
}
|
|
4003
|
+
}
|
|
4004
|
+
/**
|
|
4005
|
+
* The members of a union a value's keywords leave, or `undefined` where the type is no union: a
|
|
4006
|
+
* literal member breaking one is no value at all. Shared by both walks, so they list the same members.
|
|
4007
|
+
*/
|
|
4008
|
+
export function keptMembersOf(program, type, keywords) {
|
|
4009
|
+
if (type.kind !== "Union" && type.kind !== "Enum")
|
|
4010
|
+
return undefined;
|
|
4011
|
+
return membersOf(type).filter((member) => {
|
|
4012
|
+
const value = literalValueOf(member);
|
|
4013
|
+
return (value === undefined ||
|
|
4014
|
+
keywords.every((source) => brokenConstraintOf(program, source, value) === undefined));
|
|
4015
|
+
});
|
|
4016
|
+
}
|
|
4017
|
+
/** The keywords that constrain anything, of the declarations a merged value carries. */
|
|
4018
|
+
export function constrainingKeywords(program, keywords) {
|
|
4019
|
+
return keywords.filter((source) => hasConstraints(program, source));
|
|
4020
|
+
}
|
|
4021
|
+
/** A type with keywords on it, each applied to a member of its own JSON type. */
|
|
4022
|
+
function typeWithKeywordsToZod(program, type, keywords) {
|
|
4023
|
+
const sources = constrainingKeywords(program, keywords);
|
|
4024
|
+
if (sources.length === 0)
|
|
4025
|
+
return typeToZod(program, type);
|
|
4026
|
+
const members = keptMembersOf(program, type, sources);
|
|
4027
|
+
if (members === undefined)
|
|
4028
|
+
return constrainedMemberToZod(program, type, sources);
|
|
4029
|
+
if (members.length === 0)
|
|
4030
|
+
return UNREPRESENTABLE;
|
|
4031
|
+
return membersToZod(program, members, (member) => constrainedMemberToZod(program, member, sources), isExclusive(program, type));
|
|
4032
|
+
}
|
|
4033
|
+
/** Keywords applied to a value of one JSON type; a model's are its own, read as declared. */
|
|
4034
|
+
function applyKeywords(program, expression, keywords, json) {
|
|
4035
|
+
return keywords.reduce((each, source) => source.kind === "Model"
|
|
4036
|
+
? applyConstraints(program, each, source)
|
|
4037
|
+
: applyConstraints(program, each, source, { type: json, quiet: true }), expression);
|
|
4038
|
+
}
|
|
4039
|
+
/** A merged value as Zod - see {@link Merged}. */
|
|
4040
|
+
function mergedToZod(program, value) {
|
|
4041
|
+
switch (value.kind) {
|
|
4042
|
+
case "never":
|
|
4043
|
+
case "refused":
|
|
4044
|
+
return UNREPRESENTABLE;
|
|
4045
|
+
case "type": {
|
|
4046
|
+
const typed = () => typeWithKeywordsToZod(program, value.type, value.keywords);
|
|
4047
|
+
return value.at === undefined ? typed() : withVisibility(value.at, typed);
|
|
4048
|
+
}
|
|
4049
|
+
case "whole":
|
|
4050
|
+
return propertyValueToZod(program, value.property).base;
|
|
4051
|
+
case "nullable":
|
|
4052
|
+
return `${mergedToZod(program, value.inner)}.nullable()`;
|
|
4053
|
+
case "union": {
|
|
4054
|
+
const strings = value.members.map((member) => member.kind === "type" && member.type.kind === "String" ? member.type.value : undefined);
|
|
4055
|
+
// `z.enum` is one member of each value: an exclusive union matching a value twice refuses it.
|
|
4056
|
+
if (value.exclusive !== true && strings.every((each) => each !== undefined)) {
|
|
4057
|
+
return `z.enum([${strings.map((each) => literal(each)).join(", ")}])`;
|
|
4058
|
+
}
|
|
4059
|
+
const rendered = value.members.map((member) => mergedToZod(program, member));
|
|
4060
|
+
if (value.exclusive === true)
|
|
4061
|
+
return `z.xor([${rendered.join(", ")}])`;
|
|
4062
|
+
const kept = rendered.flatMap((text, index) => rendered.indexOf(text) === index ? [index] : []);
|
|
4063
|
+
noteWrittenMembers(program, value, kept);
|
|
4064
|
+
const members = kept.map((index) => rendered[index]);
|
|
4065
|
+
return members.length === 1 ? members[0] : `z.union([${members.join(", ")}])`;
|
|
4066
|
+
}
|
|
4067
|
+
case "all": {
|
|
4068
|
+
const [first, ...rest] = value.parts.map((part) => mergedToZod(program, part));
|
|
4069
|
+
return rest.reduce((each, part) => `${each}.and(${part})`, first ?? UNREPRESENTABLE);
|
|
4070
|
+
}
|
|
4071
|
+
case "array": {
|
|
4072
|
+
const element = () => mergedToZod(program, value.element);
|
|
4073
|
+
return applyKeywords(program, `z.array(${value.list === undefined ? element() : atItem(program, value.list, element)})`, value.keywords, "array");
|
|
4074
|
+
}
|
|
4075
|
+
case "object":
|
|
4076
|
+
return objectToZod(program, value);
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
/**
|
|
4080
|
+
* **The members a merged union is written with, by the validator and the contract type alike: one
|
|
4081
|
+
* of each text the validator writes, in an `anyOf`.** `M19 | M19` merged with `Record<M19>` was two
|
|
4082
|
+
* identical objects, whose two `z.input` types the wire assertion told apart from the one `Record` the
|
|
4083
|
+
* contract type is (`TS2344`). Each walk deduplicating by its own text disagreed the other way: `S3`
|
|
4084
|
+
* merged under `M1 | M2` is two objects the validator writes apart - `b` held to one `@minLength` and
|
|
4085
|
+
* to two - and the contract type wrote as one, and TypeScript counts a union of two such objects as
|
|
4086
|
+
* no object. So the validator decides, as it writes them, and the contract type writes the members
|
|
4087
|
+
* it kept - see {@link noteWrittenMembers}. Repeated in a `@oneOf`, a value matching both is refused,
|
|
4088
|
+
* so there each stays.
|
|
4089
|
+
*/
|
|
4090
|
+
export function membersToWrite(program, value) {
|
|
4091
|
+
if (value.exclusive === true)
|
|
4092
|
+
return value.members;
|
|
4093
|
+
const kept = writtenMembers.get(program)?.get(mergedKeyOf(value));
|
|
4094
|
+
return kept === undefined ? value.members : kept.map((index) => value.members[index]);
|
|
4095
|
+
}
|
|
4096
|
+
/** The members of each merged union the validator wrote, by the union's {@link mergedKeyOf}. */
|
|
4097
|
+
const writtenMembers = new WeakMap();
|
|
4098
|
+
function noteWrittenMembers(program, value, kept) {
|
|
4099
|
+
let byKey = writtenMembers.get(program);
|
|
4100
|
+
if (byKey === undefined) {
|
|
4101
|
+
byKey = new Map();
|
|
4102
|
+
writtenMembers.set(program, byKey);
|
|
4103
|
+
}
|
|
4104
|
+
byKey.set(mergedKeyOf(value), kept);
|
|
4105
|
+
}
|
|
4106
|
+
/**
|
|
4107
|
+
* **A merged value's shape as text**, the same for the same value whichever walk merged it: each
|
|
4108
|
+
* walk merges a position afresh, so the two values are alike and never one object. A TypeSpec type is
|
|
4109
|
+
* itself, by an id of its own.
|
|
4110
|
+
*/
|
|
4111
|
+
function mergedKeyOf(value) {
|
|
4112
|
+
if (value === null || typeof value !== "object")
|
|
4113
|
+
return JSON.stringify(value) ?? "undefined";
|
|
4114
|
+
if ("entityKind" in value) {
|
|
4115
|
+
let id = entityIds.get(value);
|
|
4116
|
+
if (id === undefined) {
|
|
4117
|
+
id = ++entityCount;
|
|
4118
|
+
entityIds.set(value, id);
|
|
4119
|
+
}
|
|
4120
|
+
return `#${id}`;
|
|
4121
|
+
}
|
|
4122
|
+
if (Array.isArray(value))
|
|
4123
|
+
return `[${value.map(mergedKeyOf).join(",")}]`;
|
|
4124
|
+
return `{${Object.keys(value)
|
|
4125
|
+
.toSorted()
|
|
4126
|
+
.map((key) => `${JSON.stringify(key)}:${mergedKeyOf(value[key])}`)
|
|
4127
|
+
.join(",")}}`;
|
|
4128
|
+
}
|
|
4129
|
+
const entityIds = new WeakMap();
|
|
4130
|
+
let entityCount = 0;
|
|
4131
|
+
/**
|
|
4132
|
+
* An object as Zod: its members, and what it does with a key none of them names.
|
|
4133
|
+
*
|
|
4134
|
+
* Both a model and a merge of several end here, so a merged object is written by the rules a model
|
|
4135
|
+
* is - and a model is just the merge of one.
|
|
4136
|
+
*/
|
|
4137
|
+
function objectToZod(program, object) {
|
|
4138
|
+
const { rest } = object;
|
|
4139
|
+
const site = object.site;
|
|
4140
|
+
const restToZod = (value) => site === undefined
|
|
4141
|
+
? mergedToZod(program, value)
|
|
4142
|
+
: withDeclarationSite(site, () => mergedToZod(program, value));
|
|
4143
|
+
/**
|
|
4144
|
+
* **A string indexer only means "dictionary" when the model declares NO properties.**
|
|
4145
|
+
*
|
|
4146
|
+
* `model X { id: string; ...Record<unknown> }` - the standard TypeSpec way to say "these fields,
|
|
4147
|
+
* plus anything else", and what `@loose` was replaced by - carries both. Returning
|
|
4148
|
+
* `z.record(...)` for it emits a schema that accepts an object and then **strips every declared
|
|
4149
|
+
* field**, so `respond()`'s drift detection degrades to "is it an object" and the pinned fields
|
|
4150
|
+
* silently stop being checked. This function's own history is the other half of the same mistake:
|
|
4151
|
+
* it once keyed on the model's *name*, and lost the indexer of anything derived from `Record`.
|
|
4152
|
+
*/
|
|
4153
|
+
if (object.dictionary && typeof rest !== "string") {
|
|
4154
|
+
return applyKeywords(program, `z.record(z.string(), ${restToZod(rest)})`, object.keywords, "object");
|
|
4155
|
+
}
|
|
4156
|
+
if (object.dictionary && rest === "loose") {
|
|
4157
|
+
return applyKeywords(program, "z.record(z.string(), z.unknown())", object.keywords, "object");
|
|
1165
4158
|
}
|
|
1166
4159
|
/**
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
4160
|
+
* Permissiveness, read from the INDEXER and from nothing else.
|
|
4161
|
+
*
|
|
4162
|
+
* `...Record<unknown>` is the standard TypeSpec construct and the one `@typespec/openapi3` can
|
|
4163
|
+
* see, so it is the only source. There was a second - `@loose`, a decorator of ours, invisible to
|
|
4164
|
+
* the document - and it is gone: a model that accepted unknown properties while the published
|
|
4165
|
+
* schema said otherwise is a runtime disagreeing with its own contract.
|
|
4166
|
+
*
|
|
4167
|
+
* An indexer over something other than `unknown` is a typed catchall, where `.loose()` would drop
|
|
4168
|
+
* that value type on the floor.
|
|
4169
|
+
*
|
|
4170
|
+
* **`Record<never>` is how a spec says "and nothing else", not a value type to walk** - sealed,
|
|
4171
|
+
* as `@typespec/openapi3` publishes it with `additionalProperties: {not: {}}`.
|
|
4172
|
+
*
|
|
4173
|
+
* A request model REJECTS an unrecognised field rather than stripping it. Silently dropping
|
|
4174
|
+
* something a caller sent is the same failure this whole surface exists to remove, one layer out:
|
|
4175
|
+
* the request succeeds and does less than it was asked to, with nothing anywhere reporting it.
|
|
1172
4176
|
*/
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
4177
|
+
const catchall = typeof rest === "string" ? undefined : rest;
|
|
4178
|
+
/**
|
|
4179
|
+
* **A property that refers back into an unfinished declaration is emitted as a GETTER.**
|
|
4180
|
+
*
|
|
4181
|
+
* `model InnerModel { children?: InnerModel[] }` is a plain recursive tree, and openapi3 publishes
|
|
4182
|
+
* it as a self-`$ref`. Emitting `children: z.array(innerModelSchema)` reads the `const` inside its
|
|
4183
|
+
* own initialiser, so the module throws on import; deferring the reference behind a getter is the
|
|
4184
|
+
* idiom Zod documents for exactly this, and it is the only one that keeps `z.infer` inferring the
|
|
4185
|
+
* recursive type rather than collapsing to `any`. `z.lazy(() => innerModelSchema)` runs correctly
|
|
4186
|
+
* but does not typecheck - measured: `TS7022`, "implicitly has type 'any' because it does not have
|
|
4187
|
+
* a type annotation and is referenced directly or indirectly in its own initializer".
|
|
4188
|
+
*/
|
|
4189
|
+
const members = object.members.map((member) => {
|
|
4190
|
+
const key = objectKey(member.key);
|
|
4191
|
+
const { property, position } = member;
|
|
4192
|
+
if (property === undefined) {
|
|
4193
|
+
const value = captureBackEdges(() => mergedToZod(program, position.value ?? NOTHING));
|
|
4194
|
+
// Optional where no declaration requires it - an envelope's members under a PATCH.
|
|
4195
|
+
const schema = position.required ? value.value : `${value.value}.exactOptional()`;
|
|
4196
|
+
return {
|
|
4197
|
+
deferred: value.deferred,
|
|
4198
|
+
text: value.deferred
|
|
4199
|
+
? `\tget ${key}() {\n\t\treturn ${schema};\n\t},`
|
|
4200
|
+
: `\t${key}: ${schema},`,
|
|
4201
|
+
};
|
|
4202
|
+
}
|
|
4203
|
+
const { value, deferred } = captureBackEdges(() => withDeclarationSite(property, () => atPosition(position, () => atDeclaration(program, property, () => propertyToZod(program, property, position)))));
|
|
4204
|
+
/**
|
|
4205
|
+
* **A getter without a return type infers `unknown`, and the collapse is SILENT.**
|
|
4206
|
+
*
|
|
4207
|
+
* The docblock above claims the getter keeps `z.infer` inferring the recursive type. It does
|
|
4208
|
+
* for the declaration itself, and not for a schema that WRAPS it: measured on the conformance
|
|
4209
|
+
* corpus, `z.record(z.string(), innerModelSchema)` - what `@body body: Record<InnerModel>`
|
|
4210
|
+
* emits - infers `children?: unknown`, and `.parse()` on it returns `unknown` outright. The
|
|
4211
|
+
* generated server then failed to compile with `TS2322`, in `type/dictionary`, which is how
|
|
4212
|
+
* this was found; nothing said the emitted TYPE was weaker than the document.
|
|
4213
|
+
*
|
|
4214
|
+
* Annotating the GETTER rather than the declaration is what keeps `.shape` and `.extend`,
|
|
4215
|
+
* which inheritance and every group spread depend on. `: z.ZodType<T, T>` names both of Zod's
|
|
4216
|
+
* parameters for the reason `renderSchemas` names both: one leaves the input side `unknown`.
|
|
4217
|
+
*/
|
|
4218
|
+
const annotation = deferred
|
|
4219
|
+
? (atDeclaration(program, property, () => annotateDeferred(property, position)) ?? "")
|
|
4220
|
+
: "";
|
|
4221
|
+
return {
|
|
4222
|
+
deferred,
|
|
4223
|
+
text: deferred
|
|
4224
|
+
? `\tget ${key}()${annotation} {\n\t\treturn ${value};\n\t},`
|
|
4225
|
+
: `\t${key}: ${value},`,
|
|
4226
|
+
};
|
|
4227
|
+
});
|
|
4228
|
+
const entries = members.map((member) => member.text);
|
|
4229
|
+
const suffix = rest === "loose"
|
|
4230
|
+
? ".loose()"
|
|
4231
|
+
: catchall === undefined
|
|
4232
|
+
? ""
|
|
4233
|
+
: `.catchall(${restToZod(catchall)})`;
|
|
4234
|
+
const strict = rest === "sealed" ? ".strict()" : "";
|
|
4235
|
+
/**
|
|
4236
|
+
* **A deferred property forces the CONSTRUCTOR form of the object.**
|
|
4237
|
+
*
|
|
4238
|
+
* Measured on Zod 4.4.3: `.strict()`, `.loose()` and `.catchall()` all read `shape` eagerly, so
|
|
4239
|
+
* each one fires the getter during module initialisation and throws `Cannot access 'X' before
|
|
4240
|
+
* initialization` - the very thing the getter exists to avoid. `z.strictObject` and
|
|
4241
|
+
* `z.looseObject` take the same shape without reading it.
|
|
4242
|
+
*
|
|
4243
|
+
* **So the constructor form is used wherever it exists, not only where a cycle forces it.** It is
|
|
4244
|
+
* also what Zod 4 asks for: its own types say "Consider `z.looseObject(A.shape)` instead" on
|
|
4245
|
+
* `.loose()` and `.strict()`, and `.passthrough()` is deprecated outright. Emitting one spelling
|
|
4246
|
+
* means a model that gains a back edge later does not silently change form, and the eager-`shape`
|
|
4247
|
+
* hazard above stops being something this walk has to keep reasoning about.
|
|
4248
|
+
*
|
|
4249
|
+
* **A TYPED catchall has no constructor form, and that used to be a refusal.** There is nowhere
|
|
4250
|
+
* safe to put the getter - `.catchall()` reads `shape` eagerly - so the cycle was reported as
|
|
4251
|
+
* `circular-model`. `z.lazy()` is the place to put it: wrapping the whole expression defers the
|
|
4252
|
+
* getter past module initialisation, and by the time the schema is forced every declaration exists.
|
|
4253
|
+
* Measured on 4.4.3: `z.lazy(() => z.object({ get kid() {...} }).catchall(z.number()))` parses a
|
|
4254
|
+
* nested value and still applies the catchall.
|
|
4255
|
+
*/
|
|
4256
|
+
const lazily = members.some((member) => member.deferred);
|
|
4257
|
+
const constructible = suffix === "" || suffix === ".loose()";
|
|
4258
|
+
const shape = entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
4259
|
+
const body = constructible
|
|
4260
|
+
? `${suffix === ".loose()" ? "z.looseObject" : strict === ".strict()" ? "z.strictObject" : "z.object"}(${shape})`
|
|
4261
|
+
: lazily
|
|
4262
|
+
? `z.lazy(() => z.object(${shape})${suffix}${strict})`
|
|
4263
|
+
: `z.object(${shape})${suffix}${strict}`;
|
|
4264
|
+
/**
|
|
4265
|
+
* A model-level constraint goes through the same path as every other constraint. `.refine()`
|
|
4266
|
+
* returns an effect rather than an object, so applying it after `.loose()` is the only order that
|
|
4267
|
+
* works.
|
|
4268
|
+
*/
|
|
4269
|
+
return applyKeywords(program, body, object.keywords, "object");
|
|
1181
4270
|
}
|
|
1182
|
-
|
|
4271
|
+
/**
|
|
4272
|
+
* A property's VALUE as Zod - its type, encoding, constraints and nullability - without its
|
|
4273
|
+
* optionality, its default or its description, which belong to the position.
|
|
4274
|
+
*/
|
|
4275
|
+
function propertyValueToZod(program, property, position = OWN_POSITION) {
|
|
1183
4276
|
/**
|
|
1184
4277
|
* **Constraints go INSIDE `.nullable()`, not after it.**
|
|
1185
4278
|
*
|
|
@@ -1191,12 +4284,45 @@ export function propertyToZod(program, property) {
|
|
|
1191
4284
|
* `.optional()` stays outermost: a constraint after it would apply to the optionality rather than
|
|
1192
4285
|
* to the value.
|
|
1193
4286
|
*/
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
const
|
|
4287
|
+
/**
|
|
4288
|
+
* `@encode` is read as `encodedValueOf` says the document publishes it, nullability included:
|
|
4289
|
+
* openapi3 3.1 publishes `anyOf: [<the encoded value>, { type: "null" }]` where the encoding is
|
|
4290
|
+
* applied beside `null`, and the encoded `type` rules `null` out where it is applied to the whole.
|
|
4291
|
+
*/
|
|
4292
|
+
const encoding = encodedValueOf(program, property);
|
|
4293
|
+
/**
|
|
4294
|
+
* **A named union is its own declaration**, where nothing about the position is applied to its
|
|
4295
|
+
* members: openapi3 publishes `m: MaybeN1<ItemN1>` as `$ref: MaybeN1`, and the contract types
|
|
4296
|
+
* declare `MaybeN1`. Peeled to `itemN1Schema.nullable()` here, it had no validator.
|
|
4297
|
+
*/
|
|
4298
|
+
const whole = property.type.kind === "Union" &&
|
|
4299
|
+
!shouldInline(program, property.type) &&
|
|
4300
|
+
encoding.kind === "declared" &&
|
|
4301
|
+
!position.nonNull &&
|
|
4302
|
+
position.constrainedBy.length === 0 &&
|
|
4303
|
+
!hasConstraints(program, property);
|
|
4304
|
+
const nullable = whole ? undefined : peeledNullOf(program, property);
|
|
4305
|
+
if (encoding.kind === "unsatisfiable") {
|
|
4306
|
+
reportUnsatisfiableEncoding(program, property, {
|
|
4307
|
+
name: property.name,
|
|
4308
|
+
declared: nameOfType(encoding.declared),
|
|
4309
|
+
encoded: encoding.scalar.name,
|
|
4310
|
+
why: encoding.why,
|
|
4311
|
+
});
|
|
4312
|
+
}
|
|
4313
|
+
// A declaration above refusing `null` leaves the value without it - see `PropertyPosition`.
|
|
4314
|
+
const bare = position.nonNull ||
|
|
4315
|
+
(encoding.kind === "declared"
|
|
4316
|
+
? nullable === undefined
|
|
4317
|
+
: encoding.kind === "unsatisfiable" || !encoding.nullable);
|
|
4318
|
+
/** The type whose schema is the value, for the description a scalar carries. */
|
|
4319
|
+
const declared = encoding.kind === "declared"
|
|
4320
|
+
? (nullable ?? property.type)
|
|
4321
|
+
: encoding.kind === "replaced"
|
|
4322
|
+
? encoding.scalar
|
|
4323
|
+
: encoding.kind === "both" && !Array.isArray(encoding.kept)
|
|
4324
|
+
? encoding.kept
|
|
4325
|
+
: property.type;
|
|
1200
4326
|
/**
|
|
1201
4327
|
* **One description per position, and a property's own doc wins over its scalar's.**
|
|
1202
4328
|
*
|
|
@@ -1212,10 +4338,60 @@ export function propertyToZod(program, property) {
|
|
|
1212
4338
|
* branch and only the property's belongs to the property.
|
|
1213
4339
|
*/
|
|
1214
4340
|
const inlinedScalar = bare && declared.kind === "Scalar";
|
|
1215
|
-
|
|
1216
|
-
const
|
|
4341
|
+
// A property's `@oneOf` makes its INLINE union exclusive; a named one is a `$ref` it leaves alone.
|
|
4342
|
+
const exclusive = exclusiveSiteOf(program, property, declared);
|
|
4343
|
+
const schemaOf = (type) => bare && type.kind === "Scalar"
|
|
4344
|
+
? scalarSchemaOf(program, type)
|
|
4345
|
+
: withExclusiveSite(exclusive, () => typeToZod(program, type));
|
|
4346
|
+
let constrained;
|
|
4347
|
+
switch (encoding.kind) {
|
|
4348
|
+
case "unsatisfiable":
|
|
4349
|
+
constrained = UNREPRESENTABLE;
|
|
4350
|
+
break;
|
|
4351
|
+
case "declared": {
|
|
4352
|
+
const sources = [property, ...position.constrainedBy];
|
|
4353
|
+
const members = constrainedMembersOf(program, property, position);
|
|
4354
|
+
constrained =
|
|
4355
|
+
members === undefined
|
|
4356
|
+
? withAncestors(program, applyConstraints(program, schemaOf(declared), property), property, position)
|
|
4357
|
+
: members.length === 0
|
|
4358
|
+
? UNREPRESENTABLE
|
|
4359
|
+
: membersToZod(program, members, (member) => constrainedMemberToZod(program, member, sources), exclusive !== undefined || isExclusive(program, declared));
|
|
4360
|
+
break;
|
|
4361
|
+
}
|
|
4362
|
+
case "replaced":
|
|
4363
|
+
constrained = withAncestors(program, applyConstraints(program, schemaOf(encoding.scalar), property), property, position);
|
|
4364
|
+
break;
|
|
4365
|
+
case "both": {
|
|
4366
|
+
/**
|
|
4367
|
+
* The property's own constraints are applied to the encoded scalar's schema, never to an
|
|
4368
|
+
* intersection, which carries no `.min` or `.regex`; the kept type brings its own.
|
|
4369
|
+
*/
|
|
4370
|
+
const kept = keptToZod(program, encoding, schemaOf);
|
|
4371
|
+
constrained = !encodedIntersects(program, property, encoding)
|
|
4372
|
+
? applyConstraints(program, kept, property)
|
|
4373
|
+
: `${applyConstraints(program, scalarSchemaOf(program, encoding.scalar), property)}.and(${kept})`;
|
|
4374
|
+
break;
|
|
4375
|
+
}
|
|
4376
|
+
}
|
|
1217
4377
|
const base = bare ? constrained : `${constrained}.nullable()`;
|
|
1218
4378
|
const description = getDoc(program, property) ?? (inlinedScalar ? scalarDocOf(program, declared) : undefined);
|
|
4379
|
+
return { base, description };
|
|
4380
|
+
}
|
|
4381
|
+
export function propertyToZod(program, property, position = OWN_POSITION) {
|
|
4382
|
+
if (position.refused !== undefined)
|
|
4383
|
+
reportUnrepresentableOverride(program, property, position);
|
|
4384
|
+
// Merged with the declarations above it where the value narrows none - see `mergeParts`.
|
|
4385
|
+
const own = position.value === undefined
|
|
4386
|
+
? atReading(program, property, () => propertyValueToZod(program, property, position))
|
|
4387
|
+
: { base: mergedToZod(program, position.value), description: getDoc(program, property) };
|
|
4388
|
+
// What the declarations above it leave of the value - see `PropertyPosition`.
|
|
4389
|
+
const base = position.never || position.refused !== undefined
|
|
4390
|
+
? UNREPRESENTABLE
|
|
4391
|
+
: position.nullOnly
|
|
4392
|
+
? "z.null()"
|
|
4393
|
+
: own.base;
|
|
4394
|
+
const { description } = own;
|
|
1219
4395
|
/**
|
|
1220
4396
|
* **Optionality is a function of the position too, not only of the `?` in the spec.**
|
|
1221
4397
|
*
|
|
@@ -1225,8 +4401,12 @@ export function propertyToZod(program, property) {
|
|
|
1225
4401
|
* would reject every partial update the contract invites. `metadataInfo.isOptional` is the same
|
|
1226
4402
|
* predicate `@typespec/openapi3` uses to decide the `required` list.
|
|
1227
4403
|
*/
|
|
1228
|
-
const optional = property.optional || isOptionalAt(program, property);
|
|
4404
|
+
const optional = !position.required && (property.optional || isOptionalAt(program, property));
|
|
1229
4405
|
reportInheritedName(program, property, optional);
|
|
4406
|
+
// A value nothing satisfies takes no default: there is no value to fall back to.
|
|
4407
|
+
if (refusedValue(program, property, position)) {
|
|
4408
|
+
return described(optional ? `${base}.exactOptional()` : base, description);
|
|
4409
|
+
}
|
|
1230
4410
|
/**
|
|
1231
4411
|
* **A default only applies where the document says the property may be absent.**
|
|
1232
4412
|
*
|
|
@@ -1243,11 +4423,19 @@ export function propertyToZod(program, property) {
|
|
|
1243
4423
|
* about a value that cannot be reached either way, naming the less useful of the two problems.
|
|
1244
4424
|
*/
|
|
1245
4425
|
if (!optional && property.defaultValue !== undefined) {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
4426
|
+
// Required by its position rather than its `?`: a discriminator, or a declaration above it.
|
|
4427
|
+
const imposed = position.required && (property.optional || isOptionalAt(program, property));
|
|
4428
|
+
const discriminator = imposed &&
|
|
4429
|
+
property.model !== undefined &&
|
|
4430
|
+
discriminatorsOf(program, property.model).includes(rawPropertyKey(program, property));
|
|
4431
|
+
if (firstReportOf(program, "default-on-required-property", property)) {
|
|
4432
|
+
reportDiagnostic(program, {
|
|
4433
|
+
code: "default-on-required-property",
|
|
4434
|
+
messageId: discriminator ? "discriminator" : imposed ? "ancestor" : "default",
|
|
4435
|
+
target: property,
|
|
4436
|
+
format: { name: property.name },
|
|
4437
|
+
});
|
|
4438
|
+
}
|
|
1251
4439
|
return described(base, description);
|
|
1252
4440
|
}
|
|
1253
4441
|
/**
|
|
@@ -1257,9 +4445,19 @@ export function propertyToZod(program, property) {
|
|
|
1257
4445
|
* already accepts an absent value, and the two together would make the OUTPUT optional as well,
|
|
1258
4446
|
* so the fallback could be skipped.
|
|
1259
4447
|
*/
|
|
1260
|
-
const defaulted = defaultOf(program, property);
|
|
1261
|
-
if (defaulted !== undefined)
|
|
1262
|
-
|
|
4448
|
+
const defaulted = defaultOf(program, property, position);
|
|
4449
|
+
if (defaulted !== undefined) {
|
|
4450
|
+
/**
|
|
4451
|
+
* **A default no literal of the validator's type spells is the validator's own parse of it** -
|
|
4452
|
+
* see {@link setsKeyItsIndexerRefuses}. The value is the same: it was held to the schema above,
|
|
4453
|
+
* and a parse of a valid value is that value with its defaults filled, as the literal is.
|
|
4454
|
+
*/
|
|
4455
|
+
const value = jsonLiteral(defaulted);
|
|
4456
|
+
const fallback = setsKeyItsIndexerRefuses(program, property.type, defaulted)
|
|
4457
|
+
? `() => ${base}.parse(${value})`
|
|
4458
|
+
: value;
|
|
4459
|
+
return described(`${base}.default(${fallback})`, description);
|
|
4460
|
+
}
|
|
1263
4461
|
/**
|
|
1264
4462
|
* **`.exactOptional()`, because the document says the KEY may be absent and nothing more.**
|
|
1265
4463
|
*
|
|
@@ -1339,58 +4537,6 @@ function wireNameOf(program, property) {
|
|
|
1339
4537
|
}
|
|
1340
4538
|
return rawPropertyKey(program, property);
|
|
1341
4539
|
}
|
|
1342
|
-
/**
|
|
1343
|
-
* A default value as a JS literal, or `undefined` where this emitter cannot render one.
|
|
1344
|
-
*
|
|
1345
|
-
* **Composite defaults used to be REFUSED, and the refusal was wrong on the emitter's own
|
|
1346
|
-
* governing rule.** `#["a", "b"]` and `#{ x: 1 }` were reported as `unsupported-default` with the
|
|
1347
|
-
* reason "a populated literal default would need each element rendered, and no schema here has one".
|
|
1348
|
-
* That was a statement about this function rather than about what can be represented: `.default()`
|
|
1349
|
-
* takes any JS value, and `@typespec/openapi3` publishes these exactly -
|
|
1350
|
-
* `default: ["a","b"]`, `default: {"x":1,"label":"hi"}`, and nested arrays too, measured from one
|
|
1351
|
-
* compile of a spec carrying all three. So the document could say it, Zod could enforce it, and only
|
|
1352
|
-
* this emitter refused - which is the same spec being representable by one emitter and not the other,
|
|
1353
|
-
* the one thing a differential between them cannot tolerate.
|
|
1354
|
-
*
|
|
1355
|
-
* **The refusal also emitted `.default(z.never())`**, because `UNREPRESENTABLE` is a schema
|
|
1356
|
-
* expression and this position wants a VALUE. Had the diagnostic ever been downgraded to a warning,
|
|
1357
|
-
* that would have compiled and set every such default to a Zod object.
|
|
1358
|
-
*
|
|
1359
|
-
* A value is pure data - strings, numbers, booleans, null, and arrays and objects of those - so it
|
|
1360
|
-
* renders as JSON. Anything else stays a refusal, and `ScalarValue` is the live case: a default like
|
|
1361
|
-
* `utcDateTime.fromISO(...)` is a constructor call rather than a literal.
|
|
1362
|
-
*/
|
|
1363
|
-
function renderValue(value) {
|
|
1364
|
-
if (value.valueKind === "StringValue")
|
|
1365
|
-
return literal(value.value);
|
|
1366
|
-
if (value.valueKind === "NumericValue")
|
|
1367
|
-
return String(value.value.asNumber());
|
|
1368
|
-
if (value.valueKind === "BooleanValue")
|
|
1369
|
-
return String(value.value);
|
|
1370
|
-
if (value.valueKind === "NullValue")
|
|
1371
|
-
return "null";
|
|
1372
|
-
if (value.valueKind === "EnumValue") {
|
|
1373
|
-
const member = value.value;
|
|
1374
|
-
return literal(member.value ?? member.name);
|
|
1375
|
-
}
|
|
1376
|
-
if (value.valueKind === "ArrayValue") {
|
|
1377
|
-
const rendered = value.values.map(renderValue);
|
|
1378
|
-
// One unrenderable element makes the whole literal unrenderable - a partial array would be a
|
|
1379
|
-
// different default from the one the spec declares, which is worse than refusing.
|
|
1380
|
-
return rendered.some((entry) => entry === undefined) ? undefined : `[${rendered.join(", ")}]`;
|
|
1381
|
-
}
|
|
1382
|
-
if (value.valueKind === "ObjectValue") {
|
|
1383
|
-
const entries = [];
|
|
1384
|
-
for (const [name, descriptor] of value.properties) {
|
|
1385
|
-
const rendered = renderValue(descriptor.value);
|
|
1386
|
-
if (rendered === undefined)
|
|
1387
|
-
return undefined;
|
|
1388
|
-
entries.push(`${literal(name)}: ${rendered}`);
|
|
1389
|
-
}
|
|
1390
|
-
return `{${entries.join(", ")}}`;
|
|
1391
|
-
}
|
|
1392
|
-
return undefined;
|
|
1393
|
-
}
|
|
1394
4540
|
/**
|
|
1395
4541
|
* **Whether `propertyToZod` emits `.default(...)` for this property, which is exactly whether its
|
|
1396
4542
|
* INPUT admits an explicit `undefined`.**
|
|
@@ -1403,34 +4549,483 @@ function renderValue(value) {
|
|
|
1403
4549
|
*
|
|
1404
4550
|
* Pure: it reports nothing, so the type walk can ask it without duplicating a diagnostic.
|
|
1405
4551
|
*/
|
|
1406
|
-
export function emitsDefault(program, property) {
|
|
1407
|
-
const optional = property.optional || isOptionalAt(program, property);
|
|
1408
|
-
|
|
1409
|
-
property.defaultValue
|
|
1410
|
-
|
|
4552
|
+
export function emitsDefault(program, property, position = OWN_POSITION) {
|
|
4553
|
+
const optional = !position.required && (property.optional || isOptionalAt(program, property));
|
|
4554
|
+
if (!optional ||
|
|
4555
|
+
property.defaultValue === undefined ||
|
|
4556
|
+
refusedValue(program, property, position)) {
|
|
4557
|
+
return false;
|
|
4558
|
+
}
|
|
4559
|
+
const serialized = defaultJsonOf(program, property);
|
|
4560
|
+
return (serialized !== undefined &&
|
|
4561
|
+
defaultClashOf(program, property, position, serialized.json) === undefined);
|
|
4562
|
+
}
|
|
4563
|
+
/**
|
|
4564
|
+
* Whether the value at this position validates nothing - refused, or no value of it is valid - so
|
|
4565
|
+
* it takes no default: there is nothing to fall back to, and `z.never().default(...)` does not
|
|
4566
|
+
* compile.
|
|
4567
|
+
*/
|
|
4568
|
+
function refusedValue(program, property, position) {
|
|
4569
|
+
if (position.never || position.nullOnly || position.refused !== undefined)
|
|
4570
|
+
return true;
|
|
4571
|
+
if (encodedValueOf(program, property).kind === "unsatisfiable")
|
|
4572
|
+
return true;
|
|
4573
|
+
const declared = nullableInnerOf(property.type) ?? property.type;
|
|
4574
|
+
// A tuple parameter is refused as `unsupported-type`.
|
|
4575
|
+
return declared.kind === "Tuple" && parameterPosition;
|
|
4576
|
+
}
|
|
4577
|
+
/**
|
|
4578
|
+
* **Why a default is no value the position accepts, or `undefined` where it is one.**
|
|
4579
|
+
*
|
|
4580
|
+
* A default is written in the declared type and published as the JSON openapi3 serializes it, and
|
|
4581
|
+
* that is not always a value of the schema it sits beside. `@encode` can change the type under it -
|
|
4582
|
+
* `@encode(string) count?: int32 = 5` is `{ type: "string", default: 5 }`, and a scalar built on an
|
|
4583
|
+
* encoded scalar carries the encoding too; a property's constraints can rule out a union's literal
|
|
4584
|
+
* the default names; a key the default sets can be one the position does not publish - a read-only
|
|
4585
|
+
* property in a create body, a model the document only knows as a union member. Filled in, any of
|
|
4586
|
+
* these handed the handler a value its type says it cannot hold, and the emitted module did not
|
|
4587
|
+
* compile. No fallback is both the document's and a valid value, so none is emitted, and
|
|
4588
|
+
* `invalid-default` says why. Checked member by member through lists, records and objects.
|
|
4589
|
+
*/
|
|
4590
|
+
function defaultClashOf(program, property, position, json) {
|
|
4591
|
+
/**
|
|
4592
|
+
* **An integer past what a JSON number holds exactly is published as `null`** - `int64 = 2^53 + 1`
|
|
4593
|
+
* - and was reported as though the author had written `null`.
|
|
4594
|
+
*/
|
|
4595
|
+
const written = property.defaultValue;
|
|
4596
|
+
if (json === null && written?.valueKind === "NumericValue") {
|
|
4597
|
+
return `it is ${written.value.toString()}, which no JSON number holds exactly, and openapi3 publishes it as null`;
|
|
4598
|
+
}
|
|
4599
|
+
const clash = valueClashOf(program, property, position, json);
|
|
4600
|
+
/**
|
|
4601
|
+
* **Where openapi3 publishes fewer keys than the default sets, the message says so**: an object
|
|
4602
|
+
* default on a union-typed property is serialized as `{}`, and "it leaves out 'kind'" read as
|
|
4603
|
+
* though the spec had.
|
|
4604
|
+
*/
|
|
4605
|
+
if (clash !== undefined &&
|
|
4606
|
+
written?.valueKind === "ObjectValue" &&
|
|
4607
|
+
json !== null &&
|
|
4608
|
+
typeof json === "object" &&
|
|
4609
|
+
Object.keys(json).length < written.properties.size) {
|
|
4610
|
+
return `openapi3 publishes it as ${JSON.stringify(json)}, without keys it sets, and ${clash}`;
|
|
4611
|
+
}
|
|
4612
|
+
return clash;
|
|
4613
|
+
}
|
|
4614
|
+
/**
|
|
4615
|
+
* **Why a JSON value is no value of a property at its position, or `undefined` where it is one** -
|
|
4616
|
+
* asked of a default, and of each key a default sets, at that key's own position.
|
|
4617
|
+
*/
|
|
4618
|
+
function valueClashOf(program, property, position, json) {
|
|
4619
|
+
// Every declaration above holds the value too, merged or implied - a `null` one refuses included.
|
|
4620
|
+
for (const ancestor of position.ancestors) {
|
|
4621
|
+
const clash = jsonClashOf(program, json, valueJsonTypesOf(program, ancestor), ancestor.type);
|
|
4622
|
+
if (clash !== undefined)
|
|
4623
|
+
return clash;
|
|
4624
|
+
}
|
|
4625
|
+
/**
|
|
4626
|
+
* **The check its own scalar emits** - a width, a format - which the document publishes as
|
|
4627
|
+
* `format` and JSON Schema reads as an annotation, so no keyword above says it: a `plainDate` of
|
|
4628
|
+
* February 30th, a `url` that is no URL, a date past what an `int32` timestamp encodes. Each was
|
|
4629
|
+
* filled in, and refused when sent back. Asked of the encoded scalar where `@encode` replaces the
|
|
4630
|
+
* value's type, as the validator asks it - and first, since a keyword `@encode` moves onto a type it
|
|
4631
|
+
* does not apply to is dropped: `@encode(string) @minValue(5) low?: int32 = 3` is no string, and
|
|
4632
|
+
* was reported as breaking the `@minValue` the validator does not enforce.
|
|
4633
|
+
*/
|
|
4634
|
+
const declared = nullableInnerOf(property.type) ?? property.type;
|
|
4635
|
+
const encoding = encodedValueOf(program, property);
|
|
4636
|
+
const validated = encoding.kind === "replaced" || encoding.kind === "both"
|
|
4637
|
+
? encoding.scalar
|
|
4638
|
+
: declared.kind === "Scalar"
|
|
4639
|
+
? declared
|
|
4640
|
+
: undefined;
|
|
4641
|
+
if (validated !== undefined &&
|
|
4642
|
+
json !== null &&
|
|
4643
|
+
scalarAccepts(program, validated, json) === false) {
|
|
4644
|
+
return `it is no '${validated.name}' the validator accepts`;
|
|
4645
|
+
}
|
|
4646
|
+
/**
|
|
4647
|
+
* **And every keyword on the way: the property's, each declaration's above it, and its declared
|
|
4648
|
+
* scalar's.** `@minValue(10) mv?: int32 = 5` filled a `5` the same validator refuses when it is
|
|
4649
|
+
* sent, and the handler was handed it.
|
|
4650
|
+
*/
|
|
4651
|
+
const broken = keywordClashOf(program, json, [
|
|
4652
|
+
property,
|
|
4653
|
+
...position.constrainedBy,
|
|
4654
|
+
...position.ancestors,
|
|
4655
|
+
...(declared.kind === "Scalar" ? [declared] : []),
|
|
4656
|
+
]);
|
|
4657
|
+
if (broken !== undefined)
|
|
4658
|
+
return `it breaks ${broken}`;
|
|
4659
|
+
/**
|
|
4660
|
+
* **A merged value is the one schema the document holds it to**, so it is asked whole: what no
|
|
4661
|
+
* declaration states is part of it - the value of a typed indexer of a model merged with this
|
|
4662
|
+
* one, which holds every key that model does not name. `Root { u: B | A }` with `A` a
|
|
4663
|
+
* `Record<Small>` and `Sub { u: B2 }` filled `B2`'s `k = 7` into the `A` branch, where `Small`
|
|
4664
|
+
* refuses it.
|
|
4665
|
+
*/
|
|
4666
|
+
if (position.value !== undefined) {
|
|
4667
|
+
const clash = mergedClashOf(program, json, position.value);
|
|
4668
|
+
if (clash !== undefined)
|
|
4669
|
+
return clash;
|
|
4670
|
+
}
|
|
4671
|
+
const members = position.value === undefined ? constrainedMembersOf(program, property, position) : undefined;
|
|
4672
|
+
if (members !== undefined) {
|
|
4673
|
+
const fits = (json === null && hasNull(property.type)) ||
|
|
4674
|
+
members.some((member) => jsonClashOf(program, json, jsonTypesOf(program, member), member) === undefined);
|
|
4675
|
+
return fits ? undefined : "it is no value of the members the property's constraints leave";
|
|
4676
|
+
}
|
|
4677
|
+
return jsonClashOf(program, json, valueJsonTypesOf(program, property), property.type);
|
|
4678
|
+
}
|
|
4679
|
+
/** The first keyword of these declarations a JSON value breaks, for its own JSON type. */
|
|
4680
|
+
function keywordClashOf(program, json, sources) {
|
|
4681
|
+
for (const source of sources) {
|
|
4682
|
+
const broken = typeof json === "string" || typeof json === "number"
|
|
4683
|
+
? brokenConstraintOf(program, source, json)
|
|
4684
|
+
: Array.isArray(json)
|
|
4685
|
+
? brokenItemsOf(program, source, json.length)
|
|
4686
|
+
: undefined;
|
|
4687
|
+
if (broken !== undefined)
|
|
4688
|
+
return broken;
|
|
4689
|
+
}
|
|
4690
|
+
return undefined;
|
|
4691
|
+
}
|
|
4692
|
+
/** Why a JSON value does not fit a declared type, by kind and then member by member, or `undefined`. */
|
|
4693
|
+
function jsonClashOf(program, json, types, type) {
|
|
4694
|
+
const kind = jsonTypeOfValue(json);
|
|
4695
|
+
const fits = types === undefined ||
|
|
4696
|
+
types.has(kind) ||
|
|
4697
|
+
(kind === "integer" && types.has("number")) ||
|
|
4698
|
+
(kind === "number" && types.has("fraction"));
|
|
4699
|
+
if (!fits) {
|
|
4700
|
+
const expected = [...(types ?? [])].filter((each) => each !== "null").join(" or ");
|
|
4701
|
+
return `it is a JSON ${kind}, and the value is a JSON ${expected}`;
|
|
4702
|
+
}
|
|
4703
|
+
const declared = nullableInnerOf(type) ?? type;
|
|
4704
|
+
/**
|
|
4705
|
+
* **A literal admits its own value and no other, and an enum or a union of literals its members'.**
|
|
4706
|
+
* Read by JSON type alone, a default a literal above refuses was filled in: `Leaf { kk?: string =
|
|
4707
|
+
* "abcd" }` named `k` with `@encodedName` over `Root { k?: "b" }`, which the compiler does not hold
|
|
4708
|
+
* `kk` to, and `M14 { a?: string = "a" }` merged into `Record<Color.red>`, were emitted as
|
|
4709
|
+
* `z.literal("b").default("abcd")`, refused when sent back, and the module failed to typecheck.
|
|
4710
|
+
*/
|
|
4711
|
+
const fixed = literalValuesOfType(declared);
|
|
4712
|
+
if (fixed !== undefined &&
|
|
4713
|
+
(typeof json === "string" || typeof json === "number" || typeof json === "boolean") &&
|
|
4714
|
+
!admitsValue(program, declared, json)) {
|
|
4715
|
+
return `it is ${JSON.stringify(json)}, and the value is ${fixed.map((value) => JSON.stringify(value)).join(" or ")}`;
|
|
4716
|
+
}
|
|
4717
|
+
// A declared scalar's keywords, and a named list model's, hold at every depth.
|
|
4718
|
+
if (declared.kind === "Scalar" || declared.kind === "Model") {
|
|
4719
|
+
const broken = keywordClashOf(program, json, [declared]);
|
|
4720
|
+
if (broken !== undefined)
|
|
4721
|
+
return `it breaks ${broken}`;
|
|
4722
|
+
}
|
|
4723
|
+
if (declared.kind === "Union" && kind !== "null") {
|
|
4724
|
+
const members = membersOf(declared).filter((member) => !isNull(member));
|
|
4725
|
+
const clashes = members.map((member) => jsonClashOf(program, json, jsonTypesOf(program, member), member));
|
|
4726
|
+
return clashes.includes(undefined) ? undefined : clashes[0];
|
|
4727
|
+
}
|
|
4728
|
+
if (declared.kind !== "Model")
|
|
4729
|
+
return undefined;
|
|
4730
|
+
const element = declared.indexer?.key.name === "integer" ? declared.indexer.value : undefined;
|
|
4731
|
+
if (Array.isArray(json) && element !== undefined) {
|
|
4732
|
+
for (const item of json) {
|
|
4733
|
+
const clash = jsonClashOf(program, item, jsonTypesOf(program, element), element);
|
|
4734
|
+
if (clash !== undefined)
|
|
4735
|
+
return clash;
|
|
4736
|
+
}
|
|
4737
|
+
return undefined;
|
|
4738
|
+
}
|
|
4739
|
+
if (kind !== "object")
|
|
4740
|
+
return undefined;
|
|
4741
|
+
const entries = Object.entries(json);
|
|
4742
|
+
// A record of values, each checked as its value type.
|
|
4743
|
+
if (declared.indexer?.key.name === "string" && declared.properties.size === 0) {
|
|
4744
|
+
const value = declared.indexer.value;
|
|
4745
|
+
for (const [, each] of entries) {
|
|
4746
|
+
const clash = jsonClashOf(program, each, jsonTypesOf(program, value), value);
|
|
4747
|
+
if (clash !== undefined)
|
|
4748
|
+
return clash;
|
|
4749
|
+
}
|
|
4750
|
+
return undefined;
|
|
4751
|
+
}
|
|
4752
|
+
/**
|
|
4753
|
+
* **Each key at its own position, the declarations above it included**, read where the model is
|
|
4754
|
+
* rendered. Held to its own declaration alone, `o?: KS = #{ k: "toolong" }` was filled in where
|
|
4755
|
+
* `KS extends KB` and `KB` declares `@maxLength(3) k`: `KS`'s `allOf` refuses it.
|
|
4756
|
+
*/
|
|
4757
|
+
return withVisibility(renderedVisibilityOf(program, declared, visibility), () => {
|
|
4758
|
+
const published = publishedPropertiesOf(program, declared).map((entry) => ({
|
|
4759
|
+
entry,
|
|
4760
|
+
key: rawPropertyKey(program, entry.property),
|
|
4761
|
+
position: positionOf(program, declared, entry),
|
|
4762
|
+
}));
|
|
4763
|
+
for (const [key, value] of entries) {
|
|
4764
|
+
const each = published.find((candidate) => candidate.key === key);
|
|
4765
|
+
if (each === undefined) {
|
|
4766
|
+
if (declared.indexer === undefined && isSealed(declared))
|
|
4767
|
+
return `it sets '${key}', which the value does not declare here`;
|
|
4768
|
+
continue;
|
|
4769
|
+
}
|
|
4770
|
+
const clash = valueClashOf(program, each.entry.property, each.position, value);
|
|
4771
|
+
if (clash !== undefined)
|
|
4772
|
+
return `at '${key}', ${clash}`;
|
|
4773
|
+
}
|
|
4774
|
+
const missing = published.find(({ entry, key, position }) => (position.required ||
|
|
4775
|
+
!(entry.property.optional || isOptionalAt(program, entry.property))) &&
|
|
4776
|
+
!entries.some(([sent]) => sent === key));
|
|
4777
|
+
return missing === undefined ? undefined : `it leaves out '${missing.key}', which is required`;
|
|
4778
|
+
});
|
|
4779
|
+
}
|
|
4780
|
+
/**
|
|
4781
|
+
* **Why a JSON value is no value of a merged schema**, read as the validator it renders reads it -
|
|
4782
|
+
* see {@link Merged}. A refusal is reported where it is found, so it clashes with nothing here.
|
|
4783
|
+
*/
|
|
4784
|
+
function mergedClashOf(program, json, merged) {
|
|
4785
|
+
const kind = jsonTypeOfValue(json);
|
|
4786
|
+
switch (merged.kind) {
|
|
4787
|
+
case "never":
|
|
4788
|
+
return "no value is valid where it is filled in";
|
|
4789
|
+
case "refused":
|
|
4790
|
+
return undefined;
|
|
4791
|
+
case "type": {
|
|
4792
|
+
const clash = withVisibility(merged.at ?? visibility, () => jsonClashOf(program, json, jsonTypesOf(program, merged.type), merged.type));
|
|
4793
|
+
if (clash !== undefined)
|
|
4794
|
+
return clash;
|
|
4795
|
+
const broken = keywordClashOf(program, json, merged.keywords);
|
|
4796
|
+
return broken === undefined ? undefined : `it breaks ${broken}`;
|
|
4797
|
+
}
|
|
4798
|
+
case "whole":
|
|
4799
|
+
return valueClashOf(program, merged.property, OWN_POSITION, json);
|
|
4800
|
+
case "nullable":
|
|
4801
|
+
return json === null ? undefined : mergedClashOf(program, json, merged.inner);
|
|
4802
|
+
case "all":
|
|
4803
|
+
for (const part of merged.parts) {
|
|
4804
|
+
const clash = mergedClashOf(program, json, part);
|
|
4805
|
+
if (clash !== undefined)
|
|
4806
|
+
return clash;
|
|
4807
|
+
}
|
|
4808
|
+
return undefined;
|
|
4809
|
+
case "union": {
|
|
4810
|
+
const clashes = merged.members.map((member) => mergedClashOf(program, json, member));
|
|
4811
|
+
const fits = clashes.filter((clash) => clash === undefined).length;
|
|
4812
|
+
if (merged.exclusive === true ? fits === 1 : fits > 0)
|
|
4813
|
+
return undefined;
|
|
4814
|
+
return fits > 1
|
|
4815
|
+
? "it is a value of more than one member of a '@oneOf' union"
|
|
4816
|
+
: (clashes[0] ?? "it is no value of the union");
|
|
4817
|
+
}
|
|
4818
|
+
case "array": {
|
|
4819
|
+
if (!Array.isArray(json))
|
|
4820
|
+
return `it is a JSON ${kind}, and the value is a JSON array`;
|
|
4821
|
+
const broken = keywordClashOf(program, json, merged.keywords);
|
|
4822
|
+
if (broken !== undefined)
|
|
4823
|
+
return `it breaks ${broken}`;
|
|
4824
|
+
for (const item of json) {
|
|
4825
|
+
const clash = mergedClashOf(program, item, merged.element);
|
|
4826
|
+
if (clash !== undefined)
|
|
4827
|
+
return clash;
|
|
4828
|
+
}
|
|
4829
|
+
return undefined;
|
|
4830
|
+
}
|
|
4831
|
+
case "object": {
|
|
4832
|
+
if (kind !== "object")
|
|
4833
|
+
return `it is a JSON ${kind}, and the value is a JSON object`;
|
|
4834
|
+
const entries = Object.entries(json);
|
|
4835
|
+
for (const [key, value] of entries) {
|
|
4836
|
+
const member = merged.members.find((each) => each.key === key);
|
|
4837
|
+
if (member === undefined) {
|
|
4838
|
+
if (merged.rest === "sealed") {
|
|
4839
|
+
return `it sets '${key}', which the value does not declare here`;
|
|
4840
|
+
}
|
|
4841
|
+
const clash = typeof merged.rest === "string"
|
|
4842
|
+
? undefined
|
|
4843
|
+
: mergedClashOf(program, value, merged.rest);
|
|
4844
|
+
if (clash !== undefined)
|
|
4845
|
+
return `at '${key}', ${clash}`;
|
|
4846
|
+
continue;
|
|
4847
|
+
}
|
|
4848
|
+
const clash = member.property === undefined
|
|
4849
|
+
? member.position.value === undefined
|
|
4850
|
+
? undefined
|
|
4851
|
+
: mergedClashOf(program, value, member.position.value)
|
|
4852
|
+
: valueClashOf(program, member.property, member.position, value);
|
|
4853
|
+
if (clash !== undefined)
|
|
4854
|
+
return `at '${key}', ${clash}`;
|
|
4855
|
+
}
|
|
4856
|
+
// A merged member's position says whether any declaration of the key requires it.
|
|
4857
|
+
const missing = merged.members.find((member) => member.position.required && !entries.some(([sent]) => sent === member.key));
|
|
4858
|
+
return missing === undefined
|
|
4859
|
+
? undefined
|
|
4860
|
+
: `it leaves out '${missing.key}', which is required`;
|
|
4861
|
+
}
|
|
4862
|
+
}
|
|
1411
4863
|
}
|
|
1412
|
-
/**
|
|
1413
|
-
|
|
4864
|
+
/**
|
|
4865
|
+
* **A default as the document publishes it**: openapi3's `serializeValueAsJson` - wire names for an
|
|
4866
|
+
* object's keys, a scalar constructor written through the property's encoding, so
|
|
4867
|
+
* `utcDateTime.fromISO("2020-01-01T00:00:00Z")` under `unixTimestamp` is `1577836800`. `undefined`
|
|
4868
|
+
* where it cannot be serialized, which openapi3 reports as `default-not-supported`.
|
|
4869
|
+
*/
|
|
4870
|
+
function defaultJsonOf(program, property) {
|
|
1414
4871
|
const value = property.defaultValue;
|
|
1415
4872
|
if (value === undefined)
|
|
1416
4873
|
return undefined;
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
return
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
4874
|
+
try {
|
|
4875
|
+
const json = serializeValueAsJson(program, value, property);
|
|
4876
|
+
return json === undefined ? undefined : { json };
|
|
4877
|
+
}
|
|
4878
|
+
catch {
|
|
4879
|
+
return undefined;
|
|
4880
|
+
}
|
|
4881
|
+
}
|
|
4882
|
+
function jsonTypeOfValue(json) {
|
|
4883
|
+
if (json === null)
|
|
4884
|
+
return "null";
|
|
4885
|
+
if (Array.isArray(json))
|
|
4886
|
+
return "array";
|
|
4887
|
+
if (typeof json === "number")
|
|
4888
|
+
return Number.isInteger(json) ? "integer" : "number";
|
|
4889
|
+
return typeof json;
|
|
4890
|
+
}
|
|
4891
|
+
/** A JSON value as a JS literal, every string through `literal` so the module stays ASCII. */
|
|
4892
|
+
function jsonLiteral(json) {
|
|
4893
|
+
if (typeof json === "string")
|
|
4894
|
+
return literal(json);
|
|
4895
|
+
if (Array.isArray(json))
|
|
4896
|
+
return `[${json.map(jsonLiteral).join(", ")}]`;
|
|
4897
|
+
if (json !== null && typeof json === "object") {
|
|
4898
|
+
return `{${Object.entries(json)
|
|
4899
|
+
.map(([key, value]) => `${literal(key)}: ${jsonLiteral(value)}`)
|
|
4900
|
+
.join(", ")}}`;
|
|
4901
|
+
}
|
|
4902
|
+
return String(json);
|
|
4903
|
+
}
|
|
4904
|
+
/** A property's declared default, as the JSON the document publishes filled in, or `undefined`. */
|
|
4905
|
+
function defaultOf(program, property, position) {
|
|
4906
|
+
const value = property.defaultValue;
|
|
4907
|
+
if (value === undefined)
|
|
4908
|
+
return undefined;
|
|
4909
|
+
const serialized = defaultJsonOf(program, property);
|
|
4910
|
+
if (serialized === undefined) {
|
|
4911
|
+
reportDiagnostic(program, {
|
|
4912
|
+
code: "unsupported-default",
|
|
4913
|
+
target: property,
|
|
4914
|
+
format: { why: `kind "${value.valueKind}"` },
|
|
4915
|
+
});
|
|
4916
|
+
/**
|
|
4917
|
+
* **No `.default(...)` at all, rather than a default this emitter invented.** The property keeps
|
|
4918
|
+
* its declared shape and loses only the fallback, which the diagnostic names.
|
|
4919
|
+
*/
|
|
4920
|
+
return undefined;
|
|
4921
|
+
}
|
|
4922
|
+
const clash = defaultClashOf(program, property, position, serialized.json);
|
|
4923
|
+
if (clash !== undefined) {
|
|
4924
|
+
if (firstReportOf(program, "invalid-default", property)) {
|
|
4925
|
+
reportDiagnostic(program, {
|
|
4926
|
+
code: "invalid-default",
|
|
4927
|
+
target: property,
|
|
4928
|
+
format: { name: property.name, why: clash },
|
|
4929
|
+
});
|
|
4930
|
+
}
|
|
4931
|
+
return undefined;
|
|
4932
|
+
}
|
|
4933
|
+
return filledDefaultOf(program, property.type, serialized.json);
|
|
4934
|
+
}
|
|
4935
|
+
/**
|
|
4936
|
+
* **Whether a default sets, at any depth, a declared key of an object whose typed indexer refuses the
|
|
4937
|
+
* key's value**, which no literal of the validator's type can do. Zod types `.catchall()` over every
|
|
4938
|
+
* key, declared ones included, so `ObjIdx { s?: string; ...Record<int32> }` is `{ [x: string]:
|
|
4939
|
+
* number; s?: string }`, and TypeScript holds `s` to both: `r?: ObjIdx = #{ s: "x" }`, which the
|
|
4940
|
+
* document and the validator accept, was emitted as `.default({"s": "x"})` and the module failed to
|
|
4941
|
+
* typecheck, `TS2769`, the default no value of the type. A key the indexer holds as well, `#{ k: 1 }`,
|
|
4942
|
+
* is one.
|
|
4943
|
+
*/
|
|
4944
|
+
function setsKeyItsIndexerRefuses(program, type, json) {
|
|
4945
|
+
const declared = nullableInnerOf(type) ?? type;
|
|
4946
|
+
if (json === null || typeof json !== "object")
|
|
4947
|
+
return false;
|
|
4948
|
+
if (declared.kind === "Union") {
|
|
4949
|
+
return membersOf(declared).some((member) => setsKeyItsIndexerRefuses(program, member, json));
|
|
4950
|
+
}
|
|
4951
|
+
if (declared.kind !== "Model")
|
|
4952
|
+
return false;
|
|
4953
|
+
if (Array.isArray(json)) {
|
|
4954
|
+
const element = declared.indexer?.key.name === "integer" ? declared.indexer.value : undefined;
|
|
4955
|
+
return (element !== undefined &&
|
|
4956
|
+
json.some((item) => setsKeyItsIndexerRefuses(program, element, item)));
|
|
4957
|
+
}
|
|
4958
|
+
return withVisibility(renderedVisibilityOf(program, declared, visibility), () => {
|
|
4959
|
+
const object = objectOfModel(program, declared);
|
|
4960
|
+
if (object.kind !== "object")
|
|
4961
|
+
return false;
|
|
4962
|
+
const rest = typeof object.rest === "string" ? undefined : object.rest;
|
|
4963
|
+
return Object.entries(json).some(([key, value]) => {
|
|
4964
|
+
const member = object.members.find((each) => each.key === key);
|
|
4965
|
+
if (member === undefined || object.dictionary) {
|
|
4966
|
+
return rest?.kind === "type" && setsKeyItsIndexerRefuses(program, rest.type, value);
|
|
4967
|
+
}
|
|
4968
|
+
if (rest !== undefined && mergedClashOf(program, value, rest) !== undefined)
|
|
4969
|
+
return true;
|
|
4970
|
+
return (member.property !== undefined &&
|
|
4971
|
+
setsKeyItsIndexerRefuses(program, member.property.type, value));
|
|
4972
|
+
});
|
|
4973
|
+
});
|
|
4974
|
+
}
|
|
4975
|
+
/**
|
|
4976
|
+
* **A default as the validator hands it over: the document's value, with the default of every key it
|
|
4977
|
+
* leaves out filled in, at any depth.** `.default()` hands its value over unparsed, so
|
|
4978
|
+
* `inner?: Inner = #{ b: "x" }` beside `Inner`'s own `a?: int32 = 1` gave the handler an `Inner` with
|
|
4979
|
+
* no `a`, and the module did not typecheck: the value is the validator's OUTPUT, where `a` is filled.
|
|
4980
|
+
* Filled here, it is the value a parse of the document's default produces.
|
|
4981
|
+
*/
|
|
4982
|
+
function filledDefaultOf(program, type, json) {
|
|
4983
|
+
const declared = nullableInnerOf(type) ?? type;
|
|
4984
|
+
if (declared.kind !== "Model" || json === null || typeof json !== "object")
|
|
4985
|
+
return json;
|
|
4986
|
+
const indexed = declared.indexer;
|
|
4987
|
+
if (Array.isArray(json)) {
|
|
4988
|
+
return indexed?.key.name === "integer"
|
|
4989
|
+
? json.map((item) => filledDefaultOf(program, indexed.value, item))
|
|
4990
|
+
: json;
|
|
4991
|
+
}
|
|
4992
|
+
const own = (target, key, value) => {
|
|
4993
|
+
// Defined as an own key, so a key named `__proto__` is a key and not the prototype.
|
|
4994
|
+
Object.defineProperty(target, key, {
|
|
4995
|
+
value,
|
|
4996
|
+
enumerable: true,
|
|
4997
|
+
writable: true,
|
|
4998
|
+
configurable: true,
|
|
4999
|
+
});
|
|
5000
|
+
};
|
|
5001
|
+
const filled = {};
|
|
5002
|
+
if (indexed?.key.name === "string" && declared.properties.size === 0) {
|
|
5003
|
+
for (const [key, value] of Object.entries(json)) {
|
|
5004
|
+
own(filled, key, filledDefaultOf(program, indexed.value, value));
|
|
5005
|
+
}
|
|
5006
|
+
return filled;
|
|
5007
|
+
}
|
|
5008
|
+
for (const [key, value] of Object.entries(json))
|
|
5009
|
+
own(filled, key, value);
|
|
5010
|
+
return withVisibility(renderedVisibilityOf(program, declared, visibility), () => {
|
|
5011
|
+
for (const entry of publishedPropertiesOf(program, declared)) {
|
|
5012
|
+
const key = rawPropertyKey(program, entry.property);
|
|
5013
|
+
if (Object.prototype.hasOwnProperty.call(filled, key)) {
|
|
5014
|
+
own(filled, key, filledDefaultOf(program, entry.property.type, filled[key]));
|
|
5015
|
+
continue;
|
|
5016
|
+
}
|
|
5017
|
+
if (!emitsDefault(program, entry.property, positionOf(program, declared, entry)))
|
|
5018
|
+
continue;
|
|
5019
|
+
const inner = defaultJsonOf(program, entry.property);
|
|
5020
|
+
if (inner !== undefined) {
|
|
5021
|
+
own(filled, key, filledDefaultOf(program, entry.property.type, inner.json));
|
|
5022
|
+
}
|
|
5023
|
+
}
|
|
5024
|
+
return filled;
|
|
1424
5025
|
});
|
|
1425
|
-
/**
|
|
1426
|
-
* **No `.default(...)` at all, rather than a default this emitter invented.** Returning
|
|
1427
|
-
* `UNREPRESENTABLE` here put a schema expression where a value belongs. The property keeps its
|
|
1428
|
-
* declared shape and loses only the fallback, which the diagnostic names.
|
|
1429
|
-
*/
|
|
1430
|
-
return undefined;
|
|
1431
5026
|
}
|
|
1432
5027
|
/** The non-null half of a two-variant `T | null` union, or `undefined` if it is not one. */
|
|
1433
|
-
function nullableInnerOf(type) {
|
|
5028
|
+
export function nullableInnerOf(type) {
|
|
1434
5029
|
if (type.kind !== "Union")
|
|
1435
5030
|
return undefined;
|
|
1436
5031
|
const variants = [...type.variants.values()].map((variant) => variant.type);
|
|
@@ -1501,12 +5096,31 @@ export function typeToZod(program, type) {
|
|
|
1501
5096
|
* occurrences still go through {@link typeToZod} and do resolve - which is what makes a recursive
|
|
1502
5097
|
* model reference itself instead of inlining itself forever.
|
|
1503
5098
|
*/
|
|
5099
|
+
/**
|
|
5100
|
+
* **An instance of a list template is refused**: `model Page<T> is T[]` made `Page<Item>`, which
|
|
5101
|
+
* openapi3 emits as a template instance - an object - and publishes as `type: object` with the
|
|
5102
|
+
* element as `additionalProperties`, a dictionary where the spec declares a list. No validator agrees
|
|
5103
|
+
* with both. A named list per element type, `model Items is Item[]`, or `Item[]` itself, is a list in
|
|
5104
|
+
* the document too.
|
|
5105
|
+
*/
|
|
5106
|
+
export function listTemplateInstanceWhy(program, type) {
|
|
5107
|
+
return type.templateMapper !== undefined &&
|
|
5108
|
+
type.indexer?.key.name === "integer" &&
|
|
5109
|
+
!isArrayLiteral(program, type)
|
|
5110
|
+
? "an instance of a list template, which openapi3 publishes as an object of its elements, not a list"
|
|
5111
|
+
: undefined;
|
|
5112
|
+
}
|
|
5113
|
+
export const LIST_TEMPLATE_REMEDY = "Declare a named list model per element type, `model Items is Item[]`, or use `Item[]` itself.";
|
|
1504
5114
|
export function typeToZodBody(program, type) {
|
|
1505
5115
|
switch (type.kind) {
|
|
1506
5116
|
case "Scalar":
|
|
1507
5117
|
return scalarToZod(program, type);
|
|
1508
|
-
case "Model":
|
|
5118
|
+
case "Model": {
|
|
5119
|
+
const why = listTemplateInstanceWhy(program, type);
|
|
5120
|
+
if (why !== undefined)
|
|
5121
|
+
return refuse(program, type, why, LIST_TEMPLATE_REMEDY);
|
|
1509
5122
|
return modelToZod(program, type);
|
|
5123
|
+
}
|
|
1510
5124
|
case "Enum":
|
|
1511
5125
|
return enumToZod(program, type);
|
|
1512
5126
|
case "Union":
|
|
@@ -1536,9 +5150,26 @@ export function typeToZodBody(program, type) {
|
|
|
1536
5150
|
if (type.name === "unknown")
|
|
1537
5151
|
return "z.unknown()";
|
|
1538
5152
|
if (type.name === "void" || type.name === "never") {
|
|
1539
|
-
return refuse(program, type, `\`${type.name}\` has no runtime representation`, "Give the position a type a value can inhabit. A `never`
|
|
5153
|
+
return refuse(program, type, `\`${type.name}\` has no runtime representation`, "Give the position a type a value can inhabit. A `never` property or parameter is dropped to match the document, and `Record<never>` seals the model, so none of those reaches here.");
|
|
1540
5154
|
}
|
|
1541
5155
|
return refuse(program, type, `intrinsic "${type.name}"`, "This emitter has no rule for that intrinsic. Replace it with a declared scalar or model.");
|
|
5156
|
+
/**
|
|
5157
|
+
* **openapi3 publishes a tuple as `prefixItems`, with no `items` or `minItems` beside it**:
|
|
5158
|
+
* any length, each position checked where it is present, anything past the last. That is a
|
|
5159
|
+
* tuple whose every position is optional and whose rest is `unknown` - `z.tuple([...],
|
|
5160
|
+
* z.unknown())`, which `z.toJSONSchema` gives back as the same `prefixItems`. Refused as a
|
|
5161
|
+
* parameter - see `parameterPosition`. Refused outright, an OAuth2 flow model, whose `scopes`
|
|
5162
|
+
* is a tuple and which openapi3 publishes as a component, stopped a service compiling.
|
|
5163
|
+
*/
|
|
5164
|
+
case "Tuple": {
|
|
5165
|
+
if (parameterPosition)
|
|
5166
|
+
return refuse(program, type, TUPLE_PARAMETER.why, TUPLE_PARAMETER.remedy);
|
|
5167
|
+
const positions = type.values.map((value) => `${typeToZod(program, value)}.exactOptional()`);
|
|
5168
|
+
// `z.tuple` takes at least one position; with none, `prefixItems: []` is any list.
|
|
5169
|
+
return positions.length === 0
|
|
5170
|
+
? "z.array(z.unknown())"
|
|
5171
|
+
: `z.tuple([${positions.join(", ")}], z.unknown())`;
|
|
5172
|
+
}
|
|
1542
5173
|
default:
|
|
1543
5174
|
return refuse(program, type, "no rule for this kind", "Replace it with a model, scalar, enum or union, which are the kinds this emitter walks.");
|
|
1544
5175
|
}
|