typespec-http-zod 0.30.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 +31 -0
- package/dist/src/api.js +1443 -360
- package/dist/src/constraints.d.ts +31 -2
- package/dist/src/constraints.js +242 -46
- package/dist/src/lib.d.ts +136 -13
- package/dist/src/lib.js +167 -18
- 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 +267 -92
- package/dist/src/uri-template.d.ts +110 -8
- package/dist/src/uri-template.js +251 -46
- package/dist/src/zod.d.ts +715 -39
- package/dist/src/zod.js +4029 -547
- package/package.json +1 -1
package/dist/src/api.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget,
|
|
2
|
-
import { getAuthenticationForOperation,
|
|
1
|
+
import { emitFile, getDiscriminatedUnion, getDiscriminator, getDoc, getDocData, getEncode, getLocationContext, getSummary, getTypeName, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolvePath, } from "@typespec/compiler";
|
|
2
|
+
import { getAuthenticationForOperation, isOrExtendsHttpFile, Visibility, } from "@typespec/http";
|
|
3
3
|
import { getOperationId, resolveOperationId } from "@typespec/openapi";
|
|
4
|
+
import { firstReportOf } from "./constraints.js";
|
|
4
5
|
import { reportDiagnostic } from "./lib.js";
|
|
5
6
|
import { literal } from "./literal.js";
|
|
6
|
-
import {
|
|
7
|
+
import { beginServiceView, parameterVisibilityOf, publishedComponents, publishedTypes, requestVisibilityOf, unreachedTypes, withServiceView, } from "./published.js";
|
|
8
|
+
import { isSingleObjectLiteral, SchemaRegistry, TypeRegistry } from "./registry.js";
|
|
7
9
|
import { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
|
|
8
10
|
import { propertyTypeToTs } from "./types.js";
|
|
9
11
|
import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
|
|
10
12
|
import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
|
|
11
|
-
import { EXPLODED_QUERY_SOURCE, explodedQueryCall, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, } from "./uri-template.js";
|
|
13
|
+
import { EXPLODED_QUERY_SOURCE, explodedQueryCall, eachReader, NO_READERS, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, WIRE_READ_OBJECT_SOURCE, WIRE_READ_AS_SOURCE, WIRE_READ_SOURCE, WIRE_ITSELF_SOURCE, WIRE_READERS_SOURCE, wireItselfCall, wireReadObjectCall, } from "./uri-template.js";
|
|
12
14
|
export { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
|
|
13
15
|
export { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
|
|
14
|
-
import { collectExternalImports, encodedTypeOf,
|
|
16
|
+
import { collectExternalImports, encodedTypeOf, externalReferenceOf, holdsRawBytes, isExclusive, isTypelessMember, nullableInnerOf, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, objectOfModel, reachesItselfThroughUnions, refuseExternalReference, refuseSelfReachingUnion, atItem, bindingIndexerOf, currentVisibility, dispatchedSubtypesOf, readsFromText, renderedVisibilityOf, rootVisibilityOf, withContractsPackage, withSealedObjects, withMetadataAnnotationsIgnored, withMultipartBinary, withParameterPosition, UNREPRESENTABLE, withVisibility, } from "./zod.js";
|
|
15
17
|
/**
|
|
16
18
|
* `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
|
|
17
19
|
* service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
|
|
@@ -322,15 +324,18 @@ function resolveResponses(program, operation) {
|
|
|
322
324
|
const entry = byStatus.get(key) ?? {
|
|
323
325
|
status,
|
|
324
326
|
body: undefined,
|
|
327
|
+
ignoresMetadata: false,
|
|
325
328
|
contentTypes: new Set(),
|
|
326
329
|
headers: [],
|
|
327
330
|
streamed: false,
|
|
328
331
|
file: false,
|
|
329
332
|
};
|
|
330
333
|
for (const content of response.responses) {
|
|
334
|
+
reportNeverBody(program, operation, content.body, `${key} response`);
|
|
331
335
|
const body = presentBody(content.body);
|
|
332
336
|
if (entry.body === undefined && body?.bodyKind === "single") {
|
|
333
337
|
entry.body = body.type;
|
|
338
|
+
entry.ignoresMetadata = ignoresMetadata(body);
|
|
334
339
|
}
|
|
335
340
|
/**
|
|
336
341
|
* **An `Http.File` response's body is its contents, served as bytes.** The same reading
|
|
@@ -360,11 +365,12 @@ function resolveResponses(program, operation) {
|
|
|
360
365
|
return {
|
|
361
366
|
status: entry.status,
|
|
362
367
|
body: entry.body,
|
|
368
|
+
ignoresMetadata: entry.ignoresMetadata,
|
|
363
369
|
contentTypes,
|
|
364
370
|
headers: entry.headers,
|
|
365
371
|
binary: entry.file ||
|
|
366
372
|
(entry.body !== undefined &&
|
|
367
|
-
isBinaryPart(entry.body) &&
|
|
373
|
+
isBinaryPart(program, entry.body) &&
|
|
368
374
|
isRawBinaryMediaType(contentTypes)),
|
|
369
375
|
streamed: entry.streamed,
|
|
370
376
|
textual: !entry.file && entry.body !== undefined && isStringType(entry.body),
|
|
@@ -376,13 +382,17 @@ function inPrecedenceOrder(responses) {
|
|
|
376
382
|
return responses.toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
|
|
377
383
|
statusOrderOf(a.status) - statusOrderOf(b.status));
|
|
378
384
|
}
|
|
385
|
+
/** A response header's contract type: a header, as a parameter is - see `withParameterPosition`. */
|
|
386
|
+
function headerTypeOf(program, property) {
|
|
387
|
+
return withParameterPosition(true, () => propertyTypeToTs(program, property));
|
|
388
|
+
}
|
|
379
389
|
/**
|
|
380
390
|
* Every response the operation declares, rendered as Zod, in OpenAPI's precedence order.
|
|
381
391
|
*
|
|
382
392
|
* Every header is rendered before any body, and bodies in precedence order, which is the order this
|
|
383
393
|
* has always declared components in: the emitted files do not move because the resolution moved.
|
|
384
394
|
*
|
|
385
|
-
* Call inside `withVisibility(
|
|
395
|
+
* Call inside `withVisibility(Visibility.Read, ...)`: a response is read at `Read`, and
|
|
386
396
|
* resolving it under a request visibility declares components under the wrong suffix.
|
|
387
397
|
*/
|
|
388
398
|
function responsesOf(program, operation, registry) {
|
|
@@ -396,7 +406,7 @@ function responsesOf(program, operation, registry) {
|
|
|
396
406
|
* alone published `string`, so a handler satisfying its own result type was refused by its
|
|
397
407
|
* own header check and the arm could not be returned without a cast.
|
|
398
408
|
*/
|
|
399
|
-
type:
|
|
409
|
+
type: headerTypeOf(program, property),
|
|
400
410
|
/**
|
|
401
411
|
* **The Zod expression, which used to be discarded here.** Every REQUEST parameter goes
|
|
402
412
|
* through `expressionForProperty` a few hundred lines below; a response header went
|
|
@@ -414,7 +424,7 @@ function responsesOf(program, operation, registry) {
|
|
|
414
424
|
? undefined
|
|
415
425
|
: response.binary
|
|
416
426
|
? "z.unknown()"
|
|
417
|
-
: registry.expressionFor(response.body),
|
|
427
|
+
: withMetadataAnnotationsIgnored(response.ignoresMetadata, () => registry.expressionFor(response.body, true)),
|
|
418
428
|
contentTypes: response.contentTypes,
|
|
419
429
|
headers,
|
|
420
430
|
binary: response.binary,
|
|
@@ -607,6 +617,27 @@ export function errorBodyOf(program, service) {
|
|
|
607
617
|
function bodyTypeOf(type) {
|
|
608
618
|
return isVoidType(type) || isNeverType(type) ? undefined : type;
|
|
609
619
|
}
|
|
620
|
+
/**
|
|
621
|
+
* **A body declared `never` is refused**, as `never-body`, once per declaration.
|
|
622
|
+
*
|
|
623
|
+
* `never` has no values, so the body cannot exist - and `@typespec/openapi3`, whose body paths test
|
|
624
|
+
* only for `void`, publishes it as `content: { "application/json": { schema: {} } }`: any value. A
|
|
625
|
+
* validator can agree with that or with the spec, not both, so the declaration is refused the way
|
|
626
|
+
* `undeclared-discriminator` refuses a discriminator the document cannot state. `void` says "no
|
|
627
|
+
* body", and openapi3 publishes it as none.
|
|
628
|
+
*/
|
|
629
|
+
function reportNeverBody(program, operation, body, position) {
|
|
630
|
+
if (body?.bodyKind !== "single" || !isNeverType(body.type))
|
|
631
|
+
return;
|
|
632
|
+
const target = body.property ?? operation.operation;
|
|
633
|
+
if (!firstReportOf(program, "never-body", target))
|
|
634
|
+
return;
|
|
635
|
+
reportDiagnostic(program, {
|
|
636
|
+
code: "never-body",
|
|
637
|
+
target,
|
|
638
|
+
format: { operationId: operationIdOf(program, operation.operation), position },
|
|
639
|
+
});
|
|
640
|
+
}
|
|
610
641
|
/**
|
|
611
642
|
* A declared body as the wire carries it: `undefined` where it is `void` or `never`, so no media
|
|
612
643
|
* type is listed for it either.
|
|
@@ -616,9 +647,8 @@ function bodyTypeOf(type) {
|
|
|
616
647
|
* the media types off it, so a 409 with no body was published as `contentTypes:
|
|
617
648
|
* ["application/json"]` and `schema: undefined`, which a consumer reads as a body it cannot validate,
|
|
618
649
|
* and a `void` request body reached the Zod walk and refused the compile. openapi3 publishes no
|
|
619
|
-
* `content` for `void`.
|
|
620
|
-
*
|
|
621
|
-
* no body here, and `reference.md` records the divergence.
|
|
650
|
+
* `content` for `void`. A `never` body is refused, as `never-body`, so no artefact states a body the
|
|
651
|
+
* document publishes as any value; see {@link reportNeverBody}.
|
|
622
652
|
*/
|
|
623
653
|
function presentBody(body) {
|
|
624
654
|
return body?.bodyKind === "single" && bodyTypeOf(body.type) === undefined ? undefined : body;
|
|
@@ -627,6 +657,19 @@ function presentBody(body) {
|
|
|
627
657
|
function requestBodyParameterOf(operation) {
|
|
628
658
|
return presentBody(operation.parameters.body);
|
|
629
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* **The operation's parameters as the document publishes them: every one but a `never`.** openapi3
|
|
662
|
+
* leaves a parameter of type `never` out of the operation (`getParameterOrRef`), required or not and
|
|
663
|
+
* wherever it is, so `?a=1` beside `@query a?: never` is an extra parameter the document accepts.
|
|
664
|
+
* Read whole, each was validated as `z.never()`, which refused it, was typed `a?: never` in the
|
|
665
|
+
* handler's input, and was reported as `unsupported-type` at `<unknown location>:1:1` under a remedy
|
|
666
|
+
* saying a `never` could not get there. A `never` body property is dropped the same way - see
|
|
667
|
+
* `publishedPropertiesOf`. A `never` sharing its wire name with another parameter leaves the name
|
|
668
|
+
* to that one, which is the one the document publishes.
|
|
669
|
+
*/
|
|
670
|
+
function publishedParametersOf(operation) {
|
|
671
|
+
return operation.parameters.parameters.filter((parameter) => !isNeverType(parameter.param.type));
|
|
672
|
+
}
|
|
630
673
|
/**
|
|
631
674
|
* The media types this operation's success response can be served as.
|
|
632
675
|
*
|
|
@@ -661,18 +704,21 @@ function responseContentTypesOf(operation, status) {
|
|
|
661
704
|
* expanded reads this type, so none of them can disagree with the schema it feeds.
|
|
662
705
|
*/
|
|
663
706
|
function encodedParameterType(program, property) {
|
|
664
|
-
|
|
707
|
+
const type = encodedTypeOf(program, property) ?? property.type;
|
|
708
|
+
/**
|
|
709
|
+
* **A parameter cannot carry `null` on the wire, so `T | null` arrives as a `T`.** Read as the
|
|
710
|
+
* union, `?ids=1,2` for `ids?: int32[] | null` was never split or decoded, and a form-exploded
|
|
711
|
+
* `filter?: Filter | null` was never gathered, so its keys were stripped.
|
|
712
|
+
*/
|
|
713
|
+
return nullableInnerOf(type) ?? type;
|
|
665
714
|
}
|
|
666
715
|
/**
|
|
667
|
-
*
|
|
716
|
+
* An ARRAY parameter's element type, or `undefined` where it is not such an array.
|
|
668
717
|
*
|
|
669
|
-
* `?ids=1,2,3` splits into strings, so each element
|
|
718
|
+
* `?ids=1,2,3` splits into strings, so each element is read from text as a lone scalar is.
|
|
670
719
|
*/
|
|
671
|
-
function
|
|
672
|
-
|
|
673
|
-
return undefined;
|
|
674
|
-
const element = type.indexer?.value;
|
|
675
|
-
return element === undefined ? undefined : wireKindOf(program, element);
|
|
720
|
+
function elementOf(type) {
|
|
721
|
+
return isArrayType(type) && type.kind === "Model" ? type.indexer?.value : undefined;
|
|
676
722
|
}
|
|
677
723
|
/** Whether a type is an array - TypeSpec models one as a `Model` carrying an integer indexer. */
|
|
678
724
|
function isArrayType(type) {
|
|
@@ -708,25 +754,42 @@ function isArrayType(type) {
|
|
|
708
754
|
* {@link multipartSchemaOf} part for part, including `multi` as an array, `optional`, and a binary
|
|
709
755
|
* part as `unknown`, is what makes the two agree by construction rather than by coincidence.
|
|
710
756
|
*/
|
|
711
|
-
function multipartTsOf(body, registry) {
|
|
757
|
+
function multipartTsOf(program, body, registry) {
|
|
712
758
|
const entries = body.parts.map((part) => {
|
|
713
759
|
const type = part.body?.type;
|
|
714
760
|
const inner = type === undefined
|
|
715
761
|
? "unknown"
|
|
716
|
-
: isFilePart(type)
|
|
762
|
+
: isFilePart(program, type)
|
|
717
763
|
? MULTIPART_FILE
|
|
718
|
-
: isBinaryPart(type)
|
|
764
|
+
: isBinaryPart(program, type)
|
|
719
765
|
? "unknown"
|
|
720
|
-
: registry.expressionFor(type);
|
|
766
|
+
: withMetadataAnnotationsIgnored(ignoresMetadata(part.body), () => withMultipartBinary(holdsRawBytes(program, type), () => registry.expressionFor(type, true)));
|
|
721
767
|
const value = part.multi === true ? `(${inner})[]` : inner;
|
|
722
768
|
const optional = part.optional === true ? "?" : "";
|
|
723
769
|
return `\t${objectKey(part.name ?? "")}${optional}: ${value};`;
|
|
724
770
|
});
|
|
725
|
-
|
|
771
|
+
const shape = entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
772
|
+
// Declared under the component's name, as its validator is - see `multipartSchemaOf`.
|
|
773
|
+
const name = multipartNameOf(body);
|
|
774
|
+
return name === undefined || body.type === undefined
|
|
775
|
+
? shape
|
|
776
|
+
: registry.declareNamed(name, () => shape, body.type);
|
|
777
|
+
}
|
|
778
|
+
/** The component openapi3 publishes a multipart body as: its model's name, where it has one. */
|
|
779
|
+
function multipartNameOf(body) {
|
|
780
|
+
const named = body.type;
|
|
781
|
+
return named !== undefined && named.kind === "Model" && named.name !== ""
|
|
782
|
+
? named.name
|
|
783
|
+
: undefined;
|
|
726
784
|
}
|
|
727
785
|
function multipartSchemaOf(program, body, registry) {
|
|
728
786
|
const entries = body.parts.map((part) => {
|
|
729
787
|
const type = part.body?.type;
|
|
788
|
+
// `.exactOptional()` for the same reason as a model property: `parseBody` yields present keys
|
|
789
|
+
// only, so an absent part is an absent key rather than an explicit `undefined`.
|
|
790
|
+
const entry = (value) => `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.exactOptional()` : value},`;
|
|
791
|
+
if (isJsonListPart(program, part))
|
|
792
|
+
return entry(jsonListDecoded(jsonListOf(registry, part)));
|
|
730
793
|
/**
|
|
731
794
|
* **A form carries text, so a part that means a number or a boolean is decoded first**, by the
|
|
732
795
|
* same rule {@link wireDecoded} applies to path, query and header values: `"0.5"` becomes
|
|
@@ -737,18 +800,31 @@ function multipartSchemaOf(program, body, registry) {
|
|
|
737
800
|
*/
|
|
738
801
|
const inner = type === undefined
|
|
739
802
|
? "z.unknown()"
|
|
740
|
-
: isFilePart(type)
|
|
803
|
+
: isFilePart(program, type)
|
|
741
804
|
? `z.unknown().refine(${MULTIPART_FILE_REFINEMENT})`
|
|
742
|
-
: isBinaryPart(type)
|
|
805
|
+
: isBinaryPart(program, type)
|
|
743
806
|
? "z.unknown()"
|
|
744
|
-
:
|
|
745
|
-
?
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
807
|
+
: holdsRawBytes(program, type)
|
|
808
|
+
? /**
|
|
809
|
+
* Any value but what its other members refuse, and no text to read - unless the
|
|
810
|
+
* document publishes the part as JSON alone, `HttpPart<bytes | null>`, where its
|
|
811
|
+
* text is JSON and `x` is refused.
|
|
812
|
+
*/
|
|
813
|
+
withMultipartBinary(true, () => isJsonPart(part)
|
|
814
|
+
? jsonTextDecoded(registry.expressionFor(type, true))
|
|
815
|
+
: registry.expressionFor(type, true))
|
|
816
|
+
: withMetadataAnnotationsIgnored(ignoresMetadata(part.body), () => isJsonPart(part)
|
|
817
|
+
? jsonTextDecoded(registry.expressionFor(type, true))
|
|
818
|
+
: isJsonOrTextPart(part)
|
|
819
|
+
? jsonOrTextDecoded(registry.expressionFor(type, true), textPartDecoded(program, registry, type))
|
|
820
|
+
: textPartDecoded(program, registry, type));
|
|
821
|
+
if (part.multi !== true)
|
|
822
|
+
return entry(inner);
|
|
823
|
+
// `multi` is `HttpPart<T>[]` - the same part repeated, which the document publishes as a list,
|
|
824
|
+
// and one whose text may be JSON is that list in JSON as well - see `mayBeJsonList`.
|
|
825
|
+
return entry(isJsonOrTextPart(part)
|
|
826
|
+
? jsonOrTextDecoded(jsonListOf(registry, part), `z.array(${inner})`)
|
|
827
|
+
: `z.array(${inner})`);
|
|
752
828
|
});
|
|
753
829
|
const shape = entries.length === 0 ? "z.object({})" : `z.object({\n${entries.join("\n")}\n})`;
|
|
754
830
|
/**
|
|
@@ -756,9 +832,124 @@ function multipartSchemaOf(program, body, registry) {
|
|
|
756
832
|
* for it. Emitted inline the schema is correct and the component still has no validator, which the
|
|
757
833
|
* differential reports - rightly - as a contract the runtime cannot check.
|
|
758
834
|
*/
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
835
|
+
const name = multipartNameOf(body);
|
|
836
|
+
return name === undefined || body.type === undefined
|
|
837
|
+
? shape
|
|
838
|
+
: registry.declareNamed(name, () => shape, body.type);
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* **What a text part's value is read as: never `null`, which text does not spell**, so `T | null`
|
|
842
|
+
* is read as a `T`, as a parameter's is - see `encodedParameterType`. Read as the union, `h=1&h=2`
|
|
843
|
+
* for `int32[] | null` under `text/plain` reached `z.array(z.int32())` as `["1", "2"]` unread, and
|
|
844
|
+
* was refused where the document reads `[1, 2]`.
|
|
845
|
+
*/
|
|
846
|
+
function asText(type) {
|
|
847
|
+
return nullableInnerOf(type) ?? type;
|
|
848
|
+
}
|
|
849
|
+
/** The parts read from text - see {@link EmittedRoute.textParts}. */
|
|
850
|
+
function textPartsOf(program, body) {
|
|
851
|
+
return body.parts.flatMap((part) => {
|
|
852
|
+
const type = part.body?.type;
|
|
853
|
+
if (part.name === undefined || type === undefined)
|
|
854
|
+
return [];
|
|
855
|
+
if (isFilePart(program, type) || isBinaryPart(program, type) || holdsRawBytes(program, type)) {
|
|
856
|
+
return [];
|
|
857
|
+
}
|
|
858
|
+
return [{ name: part.name, json: isJsonPart(part) }];
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* **A text part of a union holding a list is one value, or several sent as as many parts**, read as a
|
|
863
|
+
* URL-encoded form's field is - see `readerOf`: `int32 | int32[]` under `text/plain` is `5` sent
|
|
864
|
+
* once and `[1, 2]` sent as `1` and `2`, and `int32 | string[]` reads a lone `a` as the list `["a"]`.
|
|
865
|
+
* Read against the whole union, as one text is, the parts reached it unread and were refused where
|
|
866
|
+
* the document reads `[1, 2]`. Rendered as a part's schema is, where a parameter's refuses a tuple.
|
|
867
|
+
*/
|
|
868
|
+
function textPartDecoded(program, registry, type) {
|
|
869
|
+
const declared = registry.expressionFor(type, true);
|
|
870
|
+
const inner = asText(type);
|
|
871
|
+
const forms = inner.kind === "Union" ? wireFormsOf(inner) : [];
|
|
872
|
+
const lists = forms.filter((each) => elementOf(each) !== undefined);
|
|
873
|
+
if (lists.length === 0)
|
|
874
|
+
return wireDecoded(program, registry, declared, inner);
|
|
875
|
+
const each = lists.map((list) => atItem(program, list, () => registry.expressionFor(elementOf(list), true)));
|
|
876
|
+
const one = forms.some((form) => elementOf(form) === undefined) ? declared : undefined;
|
|
877
|
+
return `z.preprocess((raw) => wireReadAs(raw, ${eachReader(each, one, declared)}), ${declared})`;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* **A part the document publishes as a list under a media type that is not JSON alone is that many
|
|
881
|
+
* parts**, one per element, however the spec declared it: `HttpPart<{ @header contentType:
|
|
882
|
+
* "text/plain"; @body body: int32[] }>` is published exactly as `HttpPart<int32>[]` is, `{ type:
|
|
883
|
+
* array, items: int32 }` with `contentType: text/plain`, and text spells no list. Read as one part,
|
|
884
|
+
* a lone `5` reached `z.array` as `"5"` and was refused, where the same part declared repeated read
|
|
885
|
+
* it as `[5]`. A JSON part's text may spell the whole list as well - see {@link isJsonListPart}.
|
|
886
|
+
*/
|
|
887
|
+
function repeatsAsText(part) {
|
|
888
|
+
const type = part.body?.type;
|
|
889
|
+
return (part.multi !== true &&
|
|
890
|
+
type !== undefined &&
|
|
891
|
+
!isJsonPart(part) &&
|
|
892
|
+
isArrayType(nullableInnerOf(type) ?? type));
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* **A JSON part the document publishes as a list is one list or its elements, however it is
|
|
896
|
+
* declared.** `HttpPart<int32[]>` and `HttpPart<{ @header contentType: "application/json"; @body
|
|
897
|
+
* body: int32 }>[]` are published alike, `{ type: array, items: int32 }` with `contentType:
|
|
898
|
+
* application/json`, and OAS 3.1 does not say whether such a part is sent once, as the list's JSON,
|
|
899
|
+
* or once per element, each element's. So the document admits both: `[1,2]` is the list, and `5`,
|
|
900
|
+
* or `1` and `2` sent as two parts, its elements. Read as declared, the first refused `5` and the
|
|
901
|
+
* two parts, and the second `[1,2]`. A file or raw bytes is no JSON to read.
|
|
902
|
+
*/
|
|
903
|
+
function isJsonListPart(program, part) {
|
|
904
|
+
const type = part.body?.type;
|
|
905
|
+
return (type !== undefined &&
|
|
906
|
+
isJsonPart(part) &&
|
|
907
|
+
!isFilePart(program, type) &&
|
|
908
|
+
!isBinaryPart(program, type) &&
|
|
909
|
+
!holdsRawBytes(program, type) &&
|
|
910
|
+
(part.multi === true || isArrayType(nullableInnerOf(type) ?? type)));
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Whether one text of a part the document publishes as a list may be the whole list in JSON: a JSON
|
|
914
|
+
* list part, or one that is text or JSON, published as a list - declared repeated, or as a list under
|
|
915
|
+
* a media type that is not JSON alone. See {@link isJsonListPart} and {@link repeatsAsText}.
|
|
916
|
+
*/
|
|
917
|
+
function mayBeJsonList(program, part) {
|
|
918
|
+
return (isJsonListPart(program, part) ||
|
|
919
|
+
(isJsonOrTextPart(part) && (part.multi === true || repeatsAsText(part))));
|
|
920
|
+
}
|
|
921
|
+
/** The schema of the list a part is published as, read from JSON: a repeated part's, its elements'. */
|
|
922
|
+
function jsonListOf(registry, part) {
|
|
923
|
+
const declared = withMetadataAnnotationsIgnored(ignoresMetadata(part.body), () => registry.expressionFor(part.body.type, true));
|
|
924
|
+
return part.multi === true ? `z.array(${declared})` : declared;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* **A repeated part sent once is the one-element list the document describes**, and a form hands it
|
|
928
|
+
* over as the one value: `parseBody({ all: true })` makes a list only of a repeated key, so one file
|
|
929
|
+
* sent to a multi-file field reached `z.array` as a file and was refused. Boxed for the request
|
|
930
|
+
* only - the component stays the document's schema, where a lone value is no list - and only what a
|
|
931
|
+
* form delivers for one part, text or a file, so a value no form sends is judged as it is.
|
|
932
|
+
*
|
|
933
|
+
* A part repeats where the document says it does: declared `HttpPart<T>[]`, or published as a list
|
|
934
|
+
* under a text media type - see {@link repeatsAsText}. **One whose text may be JSON is boxed only
|
|
935
|
+
* where its text is not the JSON list the part accepts** - see {@link mayBeJsonList}: `[1,2]` is
|
|
936
|
+
* that list, and `5` the one element.
|
|
937
|
+
*/
|
|
938
|
+
function multipartRequestOf(program, registry, body, schema) {
|
|
939
|
+
const named = body.parts.filter((part) => part.name !== undefined);
|
|
940
|
+
const jsonOrText = named.filter((part) => mayBeJsonList(program, part));
|
|
941
|
+
const boxed = named.filter((part) => !mayBeJsonList(program, part) && (part.multi === true || repeatsAsText(part)));
|
|
942
|
+
if (boxed.length === 0 && jsonOrText.length === 0)
|
|
943
|
+
return schema;
|
|
944
|
+
const one = `typeof value === "string" || (value !== null && typeof value === "object" && !Array.isArray(value) && "arrayBuffer" in value)`;
|
|
945
|
+
const always = boxed.length === 0
|
|
946
|
+
? ""
|
|
947
|
+
: ` for (const key of ${literal(boxed.map((part) => part.name))}) { const value = boxed[key]; if (${one}) boxed[key] = [value]; }`;
|
|
948
|
+
const lists = jsonOrText.map((part) => `[${literal(part.name)}, ${jsonListOf(registry, part)}]`);
|
|
949
|
+
const unlessJson = lists.length === 0
|
|
950
|
+
? ""
|
|
951
|
+
: ` for (const [key, list] of [${lists.join(", ")}] as const) { const value = boxed[key]; if (typeof value === "string") { try { if (list.safeParse(JSON.parse(value)).success) continue; } catch { /* text, one element */ } } if (${one}) boxed[key] = [value]; }`;
|
|
952
|
+
return `z.preprocess((raw) => { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return raw; const boxed: globalThis.Record<string, unknown> = { ...raw };${always}${unlessJson} return boxed; }, ${schema})`;
|
|
762
953
|
}
|
|
763
954
|
/**
|
|
764
955
|
* The shape a multipart file part actually arrives as, spelled structurally.
|
|
@@ -773,7 +964,7 @@ function multipartSchemaOf(program, body, registry) {
|
|
|
773
964
|
* of the wire assertion, and a mapped type turns a method into a function-typed property, so
|
|
774
965
|
* spelling it this way on both sides is what keeps the two identical.
|
|
775
966
|
*/
|
|
776
|
-
const MULTIPART_FILE = "{ name: string; type: string; arrayBuffer: () => Promise<ArrayBuffer> }";
|
|
967
|
+
const MULTIPART_FILE = "{ name: string; type: string; arrayBuffer: () => globalThis.Promise<ArrayBuffer> }";
|
|
777
968
|
/**
|
|
778
969
|
* The check that makes {@link MULTIPART_FILE} established rather than asserted.
|
|
779
970
|
*
|
|
@@ -805,14 +996,9 @@ const MULTIPART_FILE_REFINEMENT = `(value): value is ${MULTIPART_FILE} => typeof
|
|
|
805
996
|
* string. Widening this to cover `bytes` would refuse payloads that pass today, on an inference the
|
|
806
997
|
* spec never made.
|
|
807
998
|
*/
|
|
808
|
-
function isFilePart(type) {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
for (let model = type; model !== undefined; model = model.baseModel) {
|
|
812
|
-
if (model.name === "File")
|
|
813
|
-
return true;
|
|
814
|
-
}
|
|
815
|
-
return false;
|
|
999
|
+
function isFilePart(program, type) {
|
|
1000
|
+
// `Http.File` as `@typespec/http` decides it - a spec's own `model File` is a JSON part.
|
|
1001
|
+
return type.kind === "Model" && isOrExtendsHttpFile(program, type);
|
|
816
1002
|
}
|
|
817
1003
|
/** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
|
|
818
1004
|
/**
|
|
@@ -833,10 +1019,413 @@ function isJsonPart(part) {
|
|
|
833
1019
|
* string passed on, so a part whose schema admits a string still refuses malformed JSON. A value that
|
|
834
1020
|
* is not text is left alone for the schema to judge.
|
|
835
1021
|
*/
|
|
1022
|
+
/**
|
|
1023
|
+
* **A part whose type mixes text with JSON** - `HttpPart<int32 | Address>` - which the document
|
|
1024
|
+
* publishes with `contentType: "text/plain, application/json"`: the client chooses per request, and
|
|
1025
|
+
* the form hands over the text either way. JSON is read where the text is JSON the schema accepts, and
|
|
1026
|
+
* the text is read as text otherwise; read as text alone, `{"city":"x"}` reached `Address` as a
|
|
1027
|
+
* string and was refused. Any JSON value, not an object alone: `"all"`, quoted, is the JSON string
|
|
1028
|
+
* `HttpPart<Nums | "all">` accepts, and ` 1` the number.
|
|
1029
|
+
*/
|
|
1030
|
+
function isJsonOrTextPart(part) {
|
|
1031
|
+
const contentTypes = part.body?.contentTypes ?? [];
|
|
1032
|
+
const json = (contentType) => /^application\/(?:[\w.+-]+\+)?json$/i.test(contentType);
|
|
1033
|
+
return contentTypes.some(json) && !contentTypes.every(json);
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* **A body sent as a URL-encoded form, read from the text it carries** - see
|
|
1037
|
+
* {@link EmittedRoute.formRequestSchema}. A form holds text, `n=5&b=true`, and the document's schema
|
|
1038
|
+
* describes the values it spells, so each field is read against its own schema as a parameter's is,
|
|
1039
|
+
* and a key sent more than once is the list it stands for. `only` where the form is the one media
|
|
1040
|
+
* type the body is declared under. `undefined` where no form is, or nothing in the body reads.
|
|
1041
|
+
*
|
|
1042
|
+
* **Each object the body can be is one reading of the form, and the body's whole schema judges
|
|
1043
|
+
* every reading**, as the document does: it accepts a form where some reading of its text is a value
|
|
1044
|
+
* of the schema it publishes. Read member by member against each member's own schema, a union was
|
|
1045
|
+
* rebuilt here beside the one the document publishes, and only a plain union of models ever was:
|
|
1046
|
+
* `DU` with no envelope, `DU | null`, `{ DA, string }`, `{ PU, DB }` and `DA | DB | DU` were
|
|
1047
|
+
* validated as JSON, so `kind=a&n=5` was refused, "expected number, received string", where the
|
|
1048
|
+
* document reads `{ kind: "a", n: 5 }`. A `@oneOf` judged member by member counted differently too:
|
|
1049
|
+
* `x=5` against `PA { x?: int32 }` and `PB { x?: string }` matched both, each by its own reading, and
|
|
1050
|
+
* was refused where each reading the document takes matches exactly one.
|
|
1051
|
+
*/
|
|
1052
|
+
function formBodyOf(program, registry, operation, body, type, declared) {
|
|
1053
|
+
const contentTypes = body?.contentTypes ?? [];
|
|
1054
|
+
const form = (contentType) => mediaTypeEssenceOf(contentType) === "application/x-www-form-urlencoded";
|
|
1055
|
+
if (!contentTypes.some(form))
|
|
1056
|
+
return undefined;
|
|
1057
|
+
const only = contentTypes.every(form);
|
|
1058
|
+
const readings = formReadingsOf(program, registry, type);
|
|
1059
|
+
if (!readings.some(readsText))
|
|
1060
|
+
return undefined;
|
|
1061
|
+
const ambiguous = ambiguousFormKeyOf(program, type);
|
|
1062
|
+
if (ambiguous !== undefined) {
|
|
1063
|
+
reportDiagnostic(program, {
|
|
1064
|
+
code: "ambiguous-form-reading",
|
|
1065
|
+
target: ambiguous.target,
|
|
1066
|
+
format: {
|
|
1067
|
+
operation: operation.operation.name,
|
|
1068
|
+
union: getTypeName(ambiguous.union),
|
|
1069
|
+
member: getTypeName(ambiguous.member),
|
|
1070
|
+
other: getTypeName(ambiguous.other),
|
|
1071
|
+
key: ambiguous.key,
|
|
1072
|
+
first: ambiguous.first,
|
|
1073
|
+
second: ambiguous.second,
|
|
1074
|
+
},
|
|
1075
|
+
});
|
|
1076
|
+
return { schema: UNREPRESENTABLE, only };
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* **A reading that reads nothing is the form as sent, and stays one**: `kind=c&n=5` is a `DC`
|
|
1080
|
+
* whose `n` is a string, and read only as `DA` reads it - `n` the number `5` - it was refused by
|
|
1081
|
+
* `DC | DA`, which accepts it as text.
|
|
1082
|
+
*/
|
|
1083
|
+
const read = [
|
|
1084
|
+
...new Set(readings.map((readers) => readsText(readers) ? `z.preprocess(${wireReadObjectCall(readers)}, ${declared})` : declared)),
|
|
1085
|
+
];
|
|
1086
|
+
return {
|
|
1087
|
+
schema: read.length === 1 ? read[0] : `z.union([${read.join(", ")}])`,
|
|
1088
|
+
only,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* **Where a `@oneOf` sent as a URL-encoded form depends on a reading no validator can choose.** The
|
|
1093
|
+
* document accepts a form where some reading of its text matches exactly one member: each key sent
|
|
1094
|
+
* once is a value its text spells - a number, a boolean, the text itself - or the one-element list of
|
|
1095
|
+
* one. A member reads each key as the first of those its schema accepts, and the whole union judges
|
|
1096
|
+
* that reading - see `formBodyOf`. That agrees with the document unless a member reads a key one way
|
|
1097
|
+
* that another member accepts, while accepting another reading the other refuses, and the other's
|
|
1098
|
+
* own reading of the form leaves a rival matching too: then which reading matches one member alone
|
|
1099
|
+
* depends on every other key and member. `x=5` against `M { x?: int32 | string }` and
|
|
1100
|
+
* `N { x?: int32 }` is `{ x: "5" }`, `M` alone, but read as `5` it matched both and was refused;
|
|
1101
|
+
* open, `x=all` against `A { x?: int32 | "all" }` and `B { y?: boolean }` is `{ x: ["all"] }`, `B`
|
|
1102
|
+
* alone. Finding that reading means trying every combination of every key's readings, exponential in
|
|
1103
|
+
* the keys, and a record's keys are the sender's to choose - so the body is refused instead, as
|
|
1104
|
+
* `ambiguous-form-reading`. Where no member tells another's readings apart, or the other's reading
|
|
1105
|
+
* rules every rival out by a key the rival requires - a `kind` literal - some member's reading is
|
|
1106
|
+
* the document's, and the body is validated. See {@link tellsApart}.
|
|
1107
|
+
*
|
|
1108
|
+
* A discriminated union is no such `oneOf`: its members' discriminators differ, so no reading
|
|
1109
|
+
* matches two of them. Nor is a union a form cannot be more than one object of.
|
|
1110
|
+
*/
|
|
1111
|
+
function ambiguousFormKeyOf(program, type) {
|
|
1112
|
+
const visit = (each, via) => {
|
|
1113
|
+
if (each.kind !== "Union" || via.has(each))
|
|
1114
|
+
return undefined;
|
|
1115
|
+
const members = [...each.variants.values()].map((variant) => variant.type);
|
|
1116
|
+
if (isExclusive(program, each) && getDiscriminatedUnion(program, each)[0] === undefined) {
|
|
1117
|
+
const groups = members
|
|
1118
|
+
.map((member) => modelsOf(member, new Set()).map((model) => keyReadingsOf(program, model)))
|
|
1119
|
+
.filter((group) => group.length > 0);
|
|
1120
|
+
for (const [i, group] of groups.entries()) {
|
|
1121
|
+
for (const [j, other] of groups.entries()) {
|
|
1122
|
+
if (i === j)
|
|
1123
|
+
continue;
|
|
1124
|
+
const rivals = groups.filter((_, k) => k !== j).flat();
|
|
1125
|
+
for (const first of group) {
|
|
1126
|
+
for (const second of other) {
|
|
1127
|
+
const found = tellsApart(first, second, rivals);
|
|
1128
|
+
if (found !== undefined)
|
|
1129
|
+
return { union: each, ...found };
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
for (const member of members) {
|
|
1136
|
+
const found = visit(member, new Set([...via, each]));
|
|
1137
|
+
if (found !== undefined)
|
|
1138
|
+
return found;
|
|
1139
|
+
}
|
|
1140
|
+
return undefined;
|
|
1141
|
+
};
|
|
1142
|
+
return visit(type, new Set());
|
|
1143
|
+
}
|
|
1144
|
+
/** The objects a member of a union is: itself, or each a union of them reaches. */
|
|
1145
|
+
function modelsOf(type, seen) {
|
|
1146
|
+
if (type.kind === "Model")
|
|
1147
|
+
return isArrayType(type) ? [] : [type];
|
|
1148
|
+
if (type.kind !== "Union" || seen.has(type))
|
|
1149
|
+
return [];
|
|
1150
|
+
seen.add(type);
|
|
1151
|
+
return [...type.variants.values()].flatMap((variant) => modelsOf(variant.type, seen));
|
|
1152
|
+
}
|
|
1153
|
+
const SPELLED = 1;
|
|
1154
|
+
const ITSELF = 2;
|
|
1155
|
+
const SPELLED_LIST = 4;
|
|
1156
|
+
const ITSELF_LIST = 8;
|
|
1157
|
+
const NO_READING = { numeric: 0, boolean: 0, other: 0 };
|
|
1158
|
+
const EVERY_READING = { numeric: 15, boolean: 15, other: ITSELF | ITSELF_LIST };
|
|
1159
|
+
/** Each class of text, and the value a text of it spells. */
|
|
1160
|
+
const TEXT_CLASSES = [
|
|
1161
|
+
["numeric", "number"],
|
|
1162
|
+
["boolean", "boolean"],
|
|
1163
|
+
["other", ""],
|
|
1164
|
+
];
|
|
1165
|
+
/** A model's reading of a key: its own, or every undeclared key's. */
|
|
1166
|
+
function keyOf(readings, key) {
|
|
1167
|
+
return (key === undefined ? undefined : readings.keys.get(key)) ?? readings.rest;
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* The reading a model's reader takes of a text of one class: the first its schema accepts - see
|
|
1171
|
+
* `wireRead` - or the text as sent where it reads none. `0` where the reader takes one the schema
|
|
1172
|
+
* refuses, which nothing here relies on.
|
|
1173
|
+
*/
|
|
1174
|
+
function choiceOf(reading, kind) {
|
|
1175
|
+
return reading.read ? lowestBit(reading.readings[kind]) : ITSELF;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* **A key `member` reads one way where `other` accepts that reading and refuses another `member`
|
|
1179
|
+
* accepts** - and `other`'s own reading of the form no rival of it accepts, so the validator reads
|
|
1180
|
+
* the form only as the two do - see {@link ambiguousFormKeyOf}. Where every rival requires a key
|
|
1181
|
+
* whose reading by `other` it refuses, `other`'s reading matches `other` alone, and the form is
|
|
1182
|
+
* accepted as the document accepts it.
|
|
1183
|
+
*/
|
|
1184
|
+
function tellsApart(member, other, rivals) {
|
|
1185
|
+
const rescued = rivals.every((rival) => [...rival.keys].some(([key, reading]) => reading.required &&
|
|
1186
|
+
TEXT_CLASSES.every(([kind]) => {
|
|
1187
|
+
const choice = choiceOf(keyOf(other, key), kind);
|
|
1188
|
+
return (reading.readings[kind] === 0 ||
|
|
1189
|
+
(choice !== 0 && (reading.readings[kind] & choice) === 0));
|
|
1190
|
+
})));
|
|
1191
|
+
if (rescued)
|
|
1192
|
+
return undefined;
|
|
1193
|
+
const keys = [...new Set([...member.keys.keys(), ...other.keys.keys()])];
|
|
1194
|
+
for (const key of [...keys, undefined]) {
|
|
1195
|
+
const mine = keyOf(member, key);
|
|
1196
|
+
const yours = keyOf(other, key);
|
|
1197
|
+
for (const [kind, spelled] of TEXT_CLASSES) {
|
|
1198
|
+
const accepted = mine.readings[kind];
|
|
1199
|
+
const choice = choiceOf(mine, kind) & accepted;
|
|
1200
|
+
const refused = accepted & ~yours.readings[kind];
|
|
1201
|
+
if (choice === 0 || (yours.readings[kind] & choice) === 0 || refused === 0)
|
|
1202
|
+
continue;
|
|
1203
|
+
return {
|
|
1204
|
+
member: member.model,
|
|
1205
|
+
other: other.model,
|
|
1206
|
+
target: mine.property ?? yours.property ?? member.model,
|
|
1207
|
+
key: key === undefined ? "a key neither declares" : `'${key}'`,
|
|
1208
|
+
first: describeReading(choice, spelled),
|
|
1209
|
+
second: describeReading(lowestBit(refused), spelled),
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return undefined;
|
|
1214
|
+
}
|
|
1215
|
+
function lowestBit(mask) {
|
|
1216
|
+
return mask & -mask;
|
|
1217
|
+
}
|
|
1218
|
+
function describeReading(bit, spelled) {
|
|
1219
|
+
switch (bit) {
|
|
1220
|
+
case SPELLED:
|
|
1221
|
+
return `the ${spelled} it spells`;
|
|
1222
|
+
case ITSELF:
|
|
1223
|
+
return "itself";
|
|
1224
|
+
case SPELLED_LIST:
|
|
1225
|
+
return `the list of the ${spelled} it spells`;
|
|
1226
|
+
default:
|
|
1227
|
+
return "the list of itself";
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
/** Each key a model declares, read as its validator holds it, and every other key's reading. */
|
|
1231
|
+
function keyReadingsOf(program, model) {
|
|
1232
|
+
return withVisibility(renderedVisibilityOf(program, model, currentVisibility()), () => {
|
|
1233
|
+
const object = objectOfModel(program, model);
|
|
1234
|
+
const keys = new Map();
|
|
1235
|
+
const none = { readings: NO_READING, read: false, required: false };
|
|
1236
|
+
if (object.kind !== "object")
|
|
1237
|
+
return { model, keys, rest: none };
|
|
1238
|
+
for (const member of object.members) {
|
|
1239
|
+
if (member.property === undefined)
|
|
1240
|
+
continue;
|
|
1241
|
+
keys.set(member.key, {
|
|
1242
|
+
readings: formReadingsOfType(program, encodedParameterType(program, member.property)),
|
|
1243
|
+
read: true,
|
|
1244
|
+
required: member.property.optional !== true,
|
|
1245
|
+
property: member.property,
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
const rest = object.rest;
|
|
1249
|
+
return {
|
|
1250
|
+
model,
|
|
1251
|
+
keys,
|
|
1252
|
+
rest: rest === "sealed"
|
|
1253
|
+
? none
|
|
1254
|
+
: typeof rest === "string"
|
|
1255
|
+
? { readings: EVERY_READING, read: false, required: false }
|
|
1256
|
+
: rest.kind === "type"
|
|
1257
|
+
? { readings: formReadingsOfType(program, rest.type), read: true, required: false }
|
|
1258
|
+
: rest.kind === "never"
|
|
1259
|
+
? none
|
|
1260
|
+
: { readings: EVERY_READING, read: true, required: false },
|
|
1261
|
+
};
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* **The readings of a key's text a value of `type` accepts** - see {@link FormReadings}. A string
|
|
1266
|
+
* accepts any text as itself; a string literal its own text; a number or a boolean the value a text
|
|
1267
|
+
* spells; a list the one-element list of what its element accepts; a value of any type every
|
|
1268
|
+
* reading. A scalar that reads no number or boolean - a date, `bytes` - is text, as a string is.
|
|
1269
|
+
*/
|
|
1270
|
+
function formReadingsOfType(program, type) {
|
|
1271
|
+
let numeric = 0;
|
|
1272
|
+
let boolean = 0;
|
|
1273
|
+
let other = 0;
|
|
1274
|
+
const seen = new Set();
|
|
1275
|
+
// A literal's own text, in the class of text it is.
|
|
1276
|
+
const literalText = (value, itself) => {
|
|
1277
|
+
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value))
|
|
1278
|
+
numeric |= itself;
|
|
1279
|
+
else if (value === "true" || value === "false")
|
|
1280
|
+
boolean |= itself;
|
|
1281
|
+
else
|
|
1282
|
+
other |= itself;
|
|
1283
|
+
};
|
|
1284
|
+
const visit = (each, list) => {
|
|
1285
|
+
if (seen.has(each))
|
|
1286
|
+
return;
|
|
1287
|
+
seen.add(each);
|
|
1288
|
+
const [spelled, itself] = list ? [SPELLED_LIST, ITSELF_LIST] : [SPELLED, ITSELF];
|
|
1289
|
+
if (each.kind === "UnionVariant")
|
|
1290
|
+
return visit(each.type, list);
|
|
1291
|
+
if (each.kind === "Union") {
|
|
1292
|
+
for (const variant of each.variants.values())
|
|
1293
|
+
visit(variant.type, list);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const element = elementOf(each);
|
|
1297
|
+
if (element !== undefined) {
|
|
1298
|
+
// A list of lists is no text a key spells.
|
|
1299
|
+
if (!list)
|
|
1300
|
+
visit(element, true);
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
if (isTypelessMember(program, each)) {
|
|
1304
|
+
numeric |= spelled | itself;
|
|
1305
|
+
boolean |= spelled | itself;
|
|
1306
|
+
other |= itself;
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
switch (each.kind) {
|
|
1310
|
+
case "String":
|
|
1311
|
+
return literalText(each.value, itself);
|
|
1312
|
+
case "Number":
|
|
1313
|
+
numeric |= spelled;
|
|
1314
|
+
return;
|
|
1315
|
+
case "Boolean":
|
|
1316
|
+
boolean |= spelled;
|
|
1317
|
+
return;
|
|
1318
|
+
case "Enum":
|
|
1319
|
+
for (const member of each.members.values())
|
|
1320
|
+
visit(member, list);
|
|
1321
|
+
return;
|
|
1322
|
+
case "EnumMember":
|
|
1323
|
+
if (typeof each.value === "number")
|
|
1324
|
+
numeric |= spelled;
|
|
1325
|
+
else
|
|
1326
|
+
literalText(each.value ?? each.name, itself);
|
|
1327
|
+
return;
|
|
1328
|
+
case "Scalar":
|
|
1329
|
+
if (readsFromText(program, each)) {
|
|
1330
|
+
if (isBooleanScalar(each))
|
|
1331
|
+
boolean |= spelled;
|
|
1332
|
+
else
|
|
1333
|
+
numeric |= spelled;
|
|
1334
|
+
}
|
|
1335
|
+
else {
|
|
1336
|
+
numeric |= itself;
|
|
1337
|
+
boolean |= itself;
|
|
1338
|
+
other |= itself;
|
|
1339
|
+
}
|
|
1340
|
+
return;
|
|
1341
|
+
default:
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
visit(type, false);
|
|
1346
|
+
return { numeric, boolean, other };
|
|
1347
|
+
}
|
|
1348
|
+
/** Whether a scalar is `boolean`, or declared on it. */
|
|
1349
|
+
function isBooleanScalar(scalar) {
|
|
1350
|
+
for (let each = scalar; each !== undefined; each = each.baseScalar) {
|
|
1351
|
+
if (each.name === "boolean")
|
|
1352
|
+
return true;
|
|
1353
|
+
}
|
|
1354
|
+
return false;
|
|
1355
|
+
}
|
|
1356
|
+
/** Whether readers read anything, or leave every value the text it is. */
|
|
1357
|
+
function readsText(readers) {
|
|
1358
|
+
return readers.values !== null || readers.fields !== null || readers.by !== undefined;
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* **Every object a URL-encoded form can stand for in a body of this type, as the readers of its
|
|
1362
|
+
* text**: a model's own, and each model a union reaches, a union nested in it included. A member that
|
|
1363
|
+
* is no object - `string` or `int32` beside a model, a list, `null` - is never what a form is, so it
|
|
1364
|
+
* adds no reading, and its schema refuses the object as the document's does. A form is never `null`.
|
|
1365
|
+
*/
|
|
1366
|
+
function formReadingsOf(program, registry, type) {
|
|
1367
|
+
if (type.kind === "Model") {
|
|
1368
|
+
return isArrayType(type)
|
|
1369
|
+
? []
|
|
1370
|
+
: [withParameterPosition(true, () => readersOf(program, registry, type, true))];
|
|
1371
|
+
}
|
|
1372
|
+
if (type.kind !== "Union")
|
|
1373
|
+
return [];
|
|
1374
|
+
const variants = [...type.variants.values()].map((variant) => variant.type);
|
|
1375
|
+
const [discriminated] = getDiscriminatedUnion(program, type);
|
|
1376
|
+
if (discriminated === undefined) {
|
|
1377
|
+
return variants.flatMap((variant) => formReadingsOf(program, registry, variant));
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* **A discriminated union is read as the variant its discriminator names**, as a discriminated
|
|
1381
|
+
* base is - see `readersOf` - so a key two variants declare differently is read as the one sent.
|
|
1382
|
+
* With no envelope the variant is the object. With one, the object is its envelope, `{ kind, value
|
|
1383
|
+
* }`, and `value` is read as the variant: `kind=i&value=5` is `{ kind: "i", value: 5 }` where the
|
|
1384
|
+
* variant is an `int32`, and `value=5` under `kind=l` the list `[5]`.
|
|
1385
|
+
*/
|
|
1386
|
+
const { envelope, discriminatorPropertyName, envelopePropertyName } = discriminated.options;
|
|
1387
|
+
const byVariant = [...discriminated.variants].map(([value, variant]) => {
|
|
1388
|
+
if (envelope === "none") {
|
|
1389
|
+
return [value, formReadingsOf(program, registry, variant)[0] ?? NO_READERS];
|
|
1390
|
+
}
|
|
1391
|
+
const reader = readerOf(program, registry, variant, undefined, true);
|
|
1392
|
+
return [
|
|
1393
|
+
value,
|
|
1394
|
+
reader === null ? NO_READERS : { values: null, fields: { [envelopePropertyName]: reader } },
|
|
1395
|
+
];
|
|
1396
|
+
});
|
|
1397
|
+
/**
|
|
1398
|
+
* A variant that is a discriminated base dispatches on a discriminator of its own, which one
|
|
1399
|
+
* dispatch does not reach: each variant is then a reading of its own, judged by the whole union.
|
|
1400
|
+
*/
|
|
1401
|
+
if (byVariant.some(([, readers]) => readers.by !== undefined)) {
|
|
1402
|
+
return variants.flatMap((variant) => formReadingsOf(program, registry, variant));
|
|
1403
|
+
}
|
|
1404
|
+
if (!byVariant.some(([, readers]) => readsText(readers)))
|
|
1405
|
+
return [NO_READERS];
|
|
1406
|
+
return [
|
|
1407
|
+
{
|
|
1408
|
+
values: null,
|
|
1409
|
+
fields: null,
|
|
1410
|
+
by: { property: discriminatorPropertyName, readers: Object.fromEntries(byVariant) },
|
|
1411
|
+
},
|
|
1412
|
+
];
|
|
1413
|
+
}
|
|
1414
|
+
function jsonOrTextDecoded(declared, asText) {
|
|
1415
|
+
return `z.preprocess((raw) => { if (typeof raw !== "string") return raw; try { const parsed: unknown = JSON.parse(raw); if (${declared}.safeParse(parsed).success) return parsed; } catch { /* text, read as text */ } return raw; }, ${asText})`;
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* **A JSON list part, decoded from one text as the whole list or from several as its elements** -
|
|
1419
|
+
* see {@link isJsonListPart}. A lone text is the list here, as the component publishes it; the
|
|
1420
|
+
* request boxes one that is not into the one element it is, before this applies.
|
|
1421
|
+
*/
|
|
1422
|
+
function jsonListDecoded(declared) {
|
|
1423
|
+
return `z.preprocess((raw, ctx) => { try { if (typeof raw === "string") return JSON.parse(raw); if (Array.isArray(raw)) return raw.map((each: unknown) => (typeof each === "string" ? JSON.parse(each) : each)); } catch { ctx.addIssue({ code: "custom", message: "Invalid JSON in a multipart part" }); return z.NEVER; } return raw; }, ${declared})`;
|
|
1424
|
+
}
|
|
836
1425
|
function jsonTextDecoded(declared) {
|
|
837
1426
|
return `z.preprocess((raw, ctx) => { if (typeof raw !== "string") return raw; try { return JSON.parse(raw); } catch { ctx.addIssue({ code: "custom", message: "Invalid JSON in a multipart part" }); return z.NEVER; } }, ${declared})`;
|
|
838
1427
|
}
|
|
839
|
-
function isBinaryPart(type) {
|
|
1428
|
+
function isBinaryPart(program, type) {
|
|
840
1429
|
if (type.kind === "Scalar") {
|
|
841
1430
|
for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
|
|
842
1431
|
if (scalar.name === "bytes")
|
|
@@ -844,13 +1433,7 @@ function isBinaryPart(type) {
|
|
|
844
1433
|
}
|
|
845
1434
|
return false;
|
|
846
1435
|
}
|
|
847
|
-
|
|
848
|
-
return false;
|
|
849
|
-
for (let model = type; model !== undefined; model = model.baseModel) {
|
|
850
|
-
if (model.name === "File")
|
|
851
|
-
return true;
|
|
852
|
-
}
|
|
853
|
-
return false;
|
|
1436
|
+
return type.kind === "Model" && isOrExtendsHttpFile(program, type);
|
|
854
1437
|
}
|
|
855
1438
|
/**
|
|
856
1439
|
* Whether a body served under these media types is RAW BINARY rather than a JSON value.
|
|
@@ -884,6 +1467,17 @@ function isParseableMediaType(type) {
|
|
|
884
1467
|
essence === "application/x-www-form-urlencoded" ||
|
|
885
1468
|
essence.startsWith("multipart/"));
|
|
886
1469
|
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Whether a body is rendered with its metadata annotations ignored, as openapi3 renders it: an
|
|
1472
|
+
* explicit `@body` whose type carries them - see `withMetadataAnnotationsIgnored`.
|
|
1473
|
+
*/
|
|
1474
|
+
function ignoresMetadata(body) {
|
|
1475
|
+
return (body !== undefined &&
|
|
1476
|
+
"isExplicit" in body &&
|
|
1477
|
+
body.isExplicit === true &&
|
|
1478
|
+
"containsMetadataAnnotations" in body &&
|
|
1479
|
+
body.containsMetadataAnnotations === true);
|
|
1480
|
+
}
|
|
887
1481
|
/** The declared request body type, if the operation takes one. */
|
|
888
1482
|
function requestBodyOf(operation) {
|
|
889
1483
|
const body = requestBodyParameterOf(operation);
|
|
@@ -923,6 +1517,11 @@ const COLLECTION_DELIMITERS = {
|
|
|
923
1517
|
"ArrayEncoding.commaDelimited": ",",
|
|
924
1518
|
"ArrayEncoding.newlineDelimited": "\n",
|
|
925
1519
|
};
|
|
1520
|
+
/** A parameter's array encoding - the `style` openapi3 publishes for it - or `undefined`. */
|
|
1521
|
+
function arrayEncodingOf(program, property) {
|
|
1522
|
+
const encoding = getEncode(program, property)?.encoding;
|
|
1523
|
+
return encoding !== undefined && encoding in COLLECTION_DELIMITERS ? encoding : undefined;
|
|
1524
|
+
}
|
|
926
1525
|
/**
|
|
927
1526
|
* Whether the parameter crosses the wire as a list, which is what makes a delimiter meaningful.
|
|
928
1527
|
*
|
|
@@ -932,8 +1531,34 @@ const COLLECTION_DELIMITERS = {
|
|
|
932
1531
|
* schema, so every request was refused. Only a query parameter keeps its list under an array
|
|
933
1532
|
* encoding, and only there is a split undone.
|
|
934
1533
|
*/
|
|
935
|
-
function isCollectionParameter(
|
|
936
|
-
return isArrayType(
|
|
1534
|
+
function isCollectionParameter(wire) {
|
|
1535
|
+
return isArrayType(wire);
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* **The forms a parameter's text can take: one per shape its type's members have.** A union of a
|
|
1539
|
+
* scalar and a list - `int32 | string[]` - is `5` or `a,b` on the wire, and each is undone its own
|
|
1540
|
+
* way: the scalar members read as text, each list split, each record or model expanded. Undone only
|
|
1541
|
+
* as the scalar it might be, `a,b` was refused. A type of one shape is one form, itself.
|
|
1542
|
+
*/
|
|
1543
|
+
function wireFormsOf(type) {
|
|
1544
|
+
if (type.kind !== "Union")
|
|
1545
|
+
return [type];
|
|
1546
|
+
const members = [];
|
|
1547
|
+
const visit = (each) => {
|
|
1548
|
+
if (each.kind === "UnionVariant")
|
|
1549
|
+
return visit(each.type);
|
|
1550
|
+
if (each.kind === "Union")
|
|
1551
|
+
return [...each.variants.values()].forEach((v) => visit(v.type));
|
|
1552
|
+
if (!(each.kind === "Intrinsic" && each.name === "null"))
|
|
1553
|
+
members.push(each);
|
|
1554
|
+
};
|
|
1555
|
+
visit(type);
|
|
1556
|
+
const shaped = members.filter((member) => expansionShapeOf(member) !== "scalar");
|
|
1557
|
+
if (shaped.length === 0)
|
|
1558
|
+
return [type];
|
|
1559
|
+
const scalar = members.some((member) => expansionShapeOf(member) === "scalar");
|
|
1560
|
+
// The union itself stands for its scalar members: a scalar's form, read against the whole schema.
|
|
1561
|
+
return [...(scalar ? [type] : []), ...shaped];
|
|
937
1562
|
}
|
|
938
1563
|
/**
|
|
939
1564
|
* Whether the values arrive one per OCCURRENCE (RFC 6570 explode) rather than joined into one.
|
|
@@ -944,10 +1569,16 @@ function isCollectionParameter(program, property) {
|
|
|
944
1569
|
* refused a conformant request while accepting the same list sent twice, which no document
|
|
945
1570
|
* describes. Found by the first consumer's `?topics=ip` (#1).
|
|
946
1571
|
*/
|
|
947
|
-
function isExplodedCollection(program, parameter) {
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1572
|
+
function isExplodedCollection(program, parameter, wire) {
|
|
1573
|
+
/**
|
|
1574
|
+
* **A query only.** A header is `simple` style, which joins a list with commas exploded or not -
|
|
1575
|
+
* explode changes how a `simple` OBJECT is written, never a list - so an exploded header list
|
|
1576
|
+
* boxed whole was `["1,2"]`, refused where the document reads `[1, 2]`.
|
|
1577
|
+
*/
|
|
1578
|
+
return (isCollectionParameter(wire) &&
|
|
1579
|
+
parameter.type === "query" &&
|
|
1580
|
+
parameter.explode === true &&
|
|
1581
|
+
arrayEncodingOf(program, parameter.param) === undefined);
|
|
951
1582
|
}
|
|
952
1583
|
/**
|
|
953
1584
|
* The delimiter a parameter's values were joined with, or `undefined` when they were not joined.
|
|
@@ -956,17 +1587,24 @@ function isExplodedCollection(program, parameter) {
|
|
|
956
1587
|
* `form` style, which OpenAPI joins with a comma, and a header list is `simple` style, also comma.
|
|
957
1588
|
* Those are the two that looked correct because the document and the validator agreed.
|
|
958
1589
|
*/
|
|
959
|
-
function collectionDelimiterOf(program, parameter) {
|
|
960
|
-
if (!isCollectionParameter(
|
|
961
|
-
return undefined;
|
|
962
|
-
if (parameter.type !== "query" && parameter.type !== "header")
|
|
1590
|
+
function collectionDelimiterOf(program, parameter, wire) {
|
|
1591
|
+
if (!isCollectionParameter(wire))
|
|
963
1592
|
return undefined;
|
|
964
|
-
|
|
1593
|
+
// A cookie is `form` with `explode: false` in the document, which joins a list with commas.
|
|
1594
|
+
if (parameter.type !== "query" && parameter.type !== "header" && parameter.type !== "cookie") {
|
|
965
1595
|
return undefined;
|
|
966
|
-
const encoding = getEncode(program, parameter.param)?.encoding;
|
|
967
|
-
if (encoding !== undefined && encoding in COLLECTION_DELIMITERS) {
|
|
968
|
-
return COLLECTION_DELIMITERS[encoding];
|
|
969
1596
|
}
|
|
1597
|
+
/**
|
|
1598
|
+
* **An array encoding is a `style`, and it wins over `explode`.** openapi3 writes the encoding's
|
|
1599
|
+
* style - `pipeDelimited` - and writes `explode: true` only by leaving it out, which for every
|
|
1600
|
+
* style but `form` means NOT exploded. So the document joins the list with the encoding's
|
|
1601
|
+
* delimiter, `?xp=1|2`, explode or not; boxed as one occurrence, that was refused.
|
|
1602
|
+
*/
|
|
1603
|
+
const encoding = arrayEncodingOf(program, parameter.param);
|
|
1604
|
+
if (encoding !== undefined)
|
|
1605
|
+
return COLLECTION_DELIMITERS[encoding];
|
|
1606
|
+
if (parameter.type === "query" && parameter.explode === true)
|
|
1607
|
+
return undefined;
|
|
970
1608
|
return ",";
|
|
971
1609
|
}
|
|
972
1610
|
/**
|
|
@@ -997,26 +1635,25 @@ function collectionDelimiterOf(program, parameter) {
|
|
|
997
1635
|
* the document justifies. Same rule as the collection split above - decode the wire form, then apply
|
|
998
1636
|
* the document's schema to the result, never instead of it.
|
|
999
1637
|
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
* with
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
if (kind === "boolean")
|
|
1010
|
-
return `z.preprocess(${BOOLEAN}, ${declared})`;
|
|
1638
|
+
* **Read against the schema, not by a kind decided from the type** - see `WIRE_READ_SOURCE`. A
|
|
1639
|
+
* kind could not say `int32 | boolean`, a numeric enum, or `int32 | "all"`, where `"5"` is `5` and
|
|
1640
|
+
* `"all"` is text; each of them refused a value the document accepts. Only a type with a value text
|
|
1641
|
+
* cannot be read at all - every string - is left without a reader, and its validator is unchanged.
|
|
1642
|
+
*/
|
|
1643
|
+
function wireDecoded(program, registry, declared, type) {
|
|
1644
|
+
if (readsFromText(program, type)) {
|
|
1645
|
+
return `z.preprocess((raw) => wireRead(raw, ${declared}), ${declared})`;
|
|
1646
|
+
}
|
|
1011
1647
|
/**
|
|
1012
|
-
* A list whose ELEMENTS are
|
|
1013
|
-
* repeated `?id=1&id=2
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1648
|
+
* A list whose ELEMENTS are read from text - `?ids=1,2,3` after the collection split, or a
|
|
1649
|
+
* repeated `?id=1&id=2` - each against the element's own schema, as a lone scalar is. A non-array
|
|
1650
|
+
* value is left alone; the split that produces the array runs outside this and may not have
|
|
1651
|
+
* happened.
|
|
1016
1652
|
*/
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1653
|
+
const element = elementOf(type);
|
|
1654
|
+
if (element !== undefined && readsFromText(program, element)) {
|
|
1655
|
+
const schema = atItem(program, type, () => registry.expressionFor(element));
|
|
1656
|
+
return `z.preprocess((raw) => (Array.isArray(raw) ? raw.map((item) => wireRead(item, ${schema})) : raw), ${declared})`;
|
|
1020
1657
|
}
|
|
1021
1658
|
return declared;
|
|
1022
1659
|
}
|
|
@@ -1122,10 +1759,11 @@ literalQueryNames) {
|
|
|
1122
1759
|
let accept;
|
|
1123
1760
|
/** Form-exploded records and models, gathered back under their names around the query object. */
|
|
1124
1761
|
const gathered = [];
|
|
1125
|
-
const
|
|
1762
|
+
const parameters = publishedParametersOf(operation);
|
|
1763
|
+
const queryNames = parameters
|
|
1126
1764
|
.filter((parameter) => parameter.type === "query")
|
|
1127
1765
|
.map((parameter) => parameter.name);
|
|
1128
|
-
for (const parameter of
|
|
1766
|
+
for (const parameter of parameters) {
|
|
1129
1767
|
/**
|
|
1130
1768
|
* **`cookie` is here because `@typespec/http` puts it here**, and excluding it was silent.
|
|
1131
1769
|
* `HttpOperationParameter` discriminates on `"header" | "cookie" | "query" | "path"`, and this
|
|
@@ -1157,113 +1795,198 @@ literalQueryNames) {
|
|
|
1157
1795
|
* The TypeSpec property name is an authoring convenience that does not survive to the contract.
|
|
1158
1796
|
*/
|
|
1159
1797
|
/**
|
|
1160
|
-
* **Two parameters of one location and one wire name are one value on the wire
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
1163
|
-
*
|
|
1164
|
-
* `"1"` and `1` to merge, which it does not refuse but throws on. An object literal cannot
|
|
1165
|
-
* hold the key twice either. `shared-input-key` reports it.
|
|
1798
|
+
* **Two parameters of one location and one wire name are one value on the wire, and the
|
|
1799
|
+
* document publishes the first.** openapi3 writes one parameter per name and location, so
|
|
1800
|
+
* `@query("x") a: string` beside `@query("x") b: int32` is published as `x: string`, and `?x=abc`
|
|
1801
|
+
* is valid. Intersected, as both declarations, it was refused. `shared-input-key` reports it.
|
|
1166
1802
|
*/
|
|
1167
|
-
const
|
|
1168
|
-
if (
|
|
1803
|
+
const first = parameters.find((other) => sameWireName(other, parameter));
|
|
1804
|
+
if (first !== parameter)
|
|
1169
1805
|
continue;
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
.
|
|
1173
|
-
.
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
* document publishes, so every constraint on the list - `minItems`, the element type - still
|
|
1178
|
-
* runs. Splitting in the handler instead would have left those unenforced.
|
|
1179
|
-
*/
|
|
1806
|
+
// A header another one names in other case is refused, as `never` - see `headerCaseCollisionOf`.
|
|
1807
|
+
if (headerCaseCollisionOf(program, operation, parameter)) {
|
|
1808
|
+
const refused = parameter.param.optional ? "z.never().exactOptional()" : "z.never()";
|
|
1809
|
+
add(parameter.type, parameter.name, refused);
|
|
1810
|
+
add("header-without-accept", parameter.name, refused);
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1180
1813
|
/**
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
* the document's schema then validates the result. Wrapping the other way round would hand the
|
|
1184
|
-
* element decoder an unsplit string.
|
|
1814
|
+
* A parameter published as a reference to another document states no schema - openapi3 writes
|
|
1815
|
+
* the reference and nothing else - and is refused, as a type published so is.
|
|
1185
1816
|
*/
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1188
|
-
|
|
1817
|
+
const external = externalReferenceOf(parameter.param);
|
|
1818
|
+
if (external !== undefined) {
|
|
1819
|
+
const refused = refuseExternalReference(program, parameter.param, external);
|
|
1820
|
+
const value = parameter.param.optional ? `${refused}.exactOptional()` : refused;
|
|
1821
|
+
add(parameter.type, parameter.name, value);
|
|
1822
|
+
if (parameter.type === "header")
|
|
1823
|
+
add("header-without-accept", parameter.name, value);
|
|
1824
|
+
continue;
|
|
1825
|
+
}
|
|
1826
|
+
// At the visibility its shared component is published at - see `parameterVisibilityOf`.
|
|
1827
|
+
withVisibility(parameterVisibilityOf(parameter.param) ?? currentVisibility(), () => {
|
|
1828
|
+
const declared = registry.expressionForProperty(parameter.param);
|
|
1189
1829
|
/**
|
|
1190
|
-
*
|
|
1191
|
-
* the
|
|
1192
|
-
*
|
|
1193
|
-
* eighteen such parameters being decoded.
|
|
1830
|
+
* Each form the value can take, undone its own way and validated against the WHOLE schema - see
|
|
1831
|
+
* `wireFormsOf`. The first form the schema accepts is the value, as the first reading `wireRead`
|
|
1832
|
+
* finds is.
|
|
1194
1833
|
*/
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1834
|
+
/**
|
|
1835
|
+
* `@encode` on the PROPERTY changes what crosses the wire, so the encoded type is the one to
|
|
1836
|
+
* read - see below.
|
|
1837
|
+
*/
|
|
1838
|
+
const wires = wireFormsOf(encodedParameterType(program, parameter.param));
|
|
1839
|
+
// The members a form-exploded query gathers back under its name - see `formOf`.
|
|
1840
|
+
const objectForms = parameter.type === "query" && parameter.explode === true
|
|
1841
|
+
? wires.filter((wire) => expansionShapeOf(wire) === "record")
|
|
1842
|
+
: [];
|
|
1843
|
+
/**
|
|
1844
|
+
* **A list or a scalar beside them is the parameter sent as itself**, `?x=1&x=2`, which the
|
|
1845
|
+
* objects' gatherer would take as a key of its own - see `WIRE_ITSELF_SOURCE`. Then, as for a
|
|
1846
|
+
* union of models, the query is gathered once and each member reads it its own way.
|
|
1847
|
+
*/
|
|
1848
|
+
const itself = objectForms.length > 0 && wires.length > objectForms.length;
|
|
1849
|
+
const gatheredOnce = objectForms.length > 1 || itself;
|
|
1850
|
+
const gatheredOthers = [
|
|
1851
|
+
...queryNames.filter((name) => name !== parameter.name),
|
|
1852
|
+
...literalQueryNames,
|
|
1853
|
+
];
|
|
1854
|
+
const formOf = (wire) => {
|
|
1855
|
+
const delimiter = collectionDelimiterOf(program, parameter, wire);
|
|
1856
|
+
/**
|
|
1857
|
+
* The wire form is decoded before it is validated, never instead of it. `z.preprocess` undoes
|
|
1858
|
+
* exactly the flattening the document's `style` describes and then applies the schema the
|
|
1859
|
+
* document publishes, so every constraint on the list - `minItems`, the element type - still
|
|
1860
|
+
* runs. Splitting in the handler instead would have left those unenforced.
|
|
1861
|
+
*/
|
|
1862
|
+
/**
|
|
1863
|
+
* Decoded BEFORE the split is wrapped around it, so the two nest in wire order: the outer
|
|
1864
|
+
* preprocess turns `"1,2,3"` into `["1","2","3"]`, the inner one turns those into numbers, and
|
|
1865
|
+
* the document's schema then validates the result. Wrapping the other way round would hand the
|
|
1866
|
+
* element decoder an unsplit string.
|
|
1867
|
+
*/
|
|
1868
|
+
const decoded = parameter.type === "header" && parameter.name.toLowerCase() === "content-type"
|
|
1869
|
+
? mediaTypeDecoded(declared)
|
|
1870
|
+
: wireDecoded(program, registry, declared, wire);
|
|
1225
1871
|
/**
|
|
1226
|
-
*
|
|
1227
|
-
*
|
|
1872
|
+
* The boxing half of the same principle: an exploded array's single occurrence arrives as a
|
|
1873
|
+
* bare string, so it is boxed into the one-element array the document describes before the
|
|
1874
|
+
* schema runs - every constraint on the list still applies to the result. Several
|
|
1875
|
+
* occurrences arrive already an array and pass through untouched, exactly as the delimiter
|
|
1876
|
+
* arm leaves a pre-split value alone.
|
|
1228
1877
|
*/
|
|
1229
|
-
const
|
|
1230
|
-
|
|
1878
|
+
const boxedOrSplit = isExplodedCollection(program, parameter, wire)
|
|
1879
|
+
? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
|
|
1880
|
+
: delimiter === undefined
|
|
1881
|
+
? decoded
|
|
1882
|
+
: `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${literal(delimiter)}) : raw), ${decoded})`;
|
|
1883
|
+
/**
|
|
1884
|
+
* **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
|
|
1885
|
+
* wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
|
|
1886
|
+
* run on that. See {@link uriExpansionCall}.
|
|
1887
|
+
*/
|
|
1888
|
+
const shape = expansionShapeOf(wire);
|
|
1889
|
+
// A key repeats only in a form-exploded query - see `readersOf`.
|
|
1890
|
+
const readers = readersOf(program, registry, wire, false);
|
|
1891
|
+
let expression = boxedOrSplit;
|
|
1892
|
+
if (parameter.type === "path") {
|
|
1893
|
+
const segment = decodedSegmentOf(program, parameter, segments, wire);
|
|
1894
|
+
if (segment !== undefined) {
|
|
1895
|
+
// A value of any type is the shape its expansion spells - see `URI_EXPANSION_SOURCE`.
|
|
1896
|
+
const any = shape === "scalar" && takesAnyValue(program, wire);
|
|
1897
|
+
expression = `z.preprocess(${uriExpansionCall({
|
|
1898
|
+
...segment,
|
|
1899
|
+
name: parameter.name,
|
|
1900
|
+
shape: any ? "any" : shape,
|
|
1901
|
+
readers: any ? { values: "z.unknown()", fields: null } : readers,
|
|
1902
|
+
})}, ${decoded})`;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
else if ((parameter.type === "header" || parameter.type === "cookie") &&
|
|
1906
|
+
shape === "record") {
|
|
1907
|
+
/**
|
|
1908
|
+
* **A header or a cookie carries a record or a model as text too**, and nothing read it: the
|
|
1909
|
+
* validator met `"a,1,b,2"` where the document's schema describes `{a: 1, b: 2}`, and refused
|
|
1910
|
+
* every value. A header is `simple` style - `a,1,b,2`, or `a=1,b=2` exploded - and a cookie is
|
|
1911
|
+
* `form` without explode, as openapi3 publishes it: the same expansion as a query's, undone
|
|
1912
|
+
* the same way.
|
|
1913
|
+
*/
|
|
1914
|
+
const explode = parameter.type === "header" && parameter.explode === true;
|
|
1915
|
+
expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode, shape, readers })}, ${decoded})`;
|
|
1916
|
+
}
|
|
1917
|
+
else if (parameter.type === "query" && shape === "record") {
|
|
1918
|
+
if (parameter.explode === true) {
|
|
1919
|
+
/**
|
|
1920
|
+
* `{?param*}` over an object expands to the object's own keys, so there is no `param` key for
|
|
1921
|
+
* this entry to read; the gatherer around the query object puts one back. A union of
|
|
1922
|
+
* models is gathered once, below, and each member reads the gathered values its own way.
|
|
1923
|
+
*/
|
|
1924
|
+
const repeated = readersOf(program, registry, wire, true);
|
|
1925
|
+
if (gatheredOnce) {
|
|
1926
|
+
expression = `z.preprocess(${wireReadObjectCall(repeated)}, ${expression})`;
|
|
1927
|
+
}
|
|
1928
|
+
else {
|
|
1929
|
+
const model = wire.kind === "Model" && wire.indexer === undefined ? wire : undefined;
|
|
1930
|
+
gathered.push(explodedQueryCall({
|
|
1931
|
+
name: parameter.name,
|
|
1932
|
+
others: gatheredOthers,
|
|
1933
|
+
keys: model === undefined ? undefined : explodedKeysOf(program, model),
|
|
1934
|
+
itself: false,
|
|
1935
|
+
readers: repeated,
|
|
1936
|
+
}));
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
else {
|
|
1940
|
+
expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode: false, shape, readers })}, ${decoded})`;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
else if (itself) {
|
|
1944
|
+
// What arrived under the name, where the objects' gatherer took the query.
|
|
1945
|
+
expression = `z.preprocess(${wireItselfCall()}, ${expression})`;
|
|
1946
|
+
}
|
|
1947
|
+
return expression;
|
|
1948
|
+
};
|
|
1949
|
+
/**
|
|
1950
|
+
* `@encode` on the PROPERTY changes what crosses the wire, so the encoded type is the one to
|
|
1951
|
+
* read. `@encode("unixTimestamp", int32) value: utcDateTime` is a number on the wire and a date
|
|
1952
|
+
* in the spec, and reading the declared type alone stopped eighteen such parameters being
|
|
1953
|
+
* decoded.
|
|
1954
|
+
*/
|
|
1955
|
+
const forms = wires.map(formOf);
|
|
1956
|
+
if (gatheredOnce) {
|
|
1957
|
+
// One gatherer for every member, over every key one of them declares - see `formOf`.
|
|
1958
|
+
const keyed = objectForms.map((wire) => wire.kind === "Model" && wire.indexer === undefined
|
|
1959
|
+
? explodedKeysOf(program, wire)
|
|
1960
|
+
: undefined);
|
|
1231
1961
|
gathered.push(explodedQueryCall({
|
|
1232
1962
|
name: parameter.name,
|
|
1233
|
-
others:
|
|
1234
|
-
keys:
|
|
1963
|
+
others: gatheredOthers,
|
|
1964
|
+
keys: keyed.includes(undefined)
|
|
1235
1965
|
? undefined
|
|
1236
|
-
:
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1240
|
-
booleans: properties
|
|
1241
|
-
.filter((property) => wireKindOf(program, encodedParameterType(program, property)) === "boolean")
|
|
1242
|
-
.map((property) => resolveEncodedName(program, property, "application/json")),
|
|
1243
|
-
values,
|
|
1966
|
+
: [...new Set(keyed.flatMap((keys) => keys ?? []))],
|
|
1967
|
+
itself,
|
|
1968
|
+
readers: NO_READERS,
|
|
1244
1969
|
}));
|
|
1245
1970
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1971
|
+
const expression = forms.length === 1 ? forms[0] : `z.union([${forms.join(", ")}])`;
|
|
1972
|
+
add(parameter.type, parameter.name, expression);
|
|
1973
|
+
/**
|
|
1974
|
+
* **On a route several operations share, `accept` SELECTS the operation - it is not a
|
|
1975
|
+
* constraint on one of them.** Each member declares its own literal, so validating against any
|
|
1976
|
+
* single member's would 400 every request asking for one of the others, and 400 is the wrong
|
|
1977
|
+
* answer twice over: the request is well-formed, and the contract's answer to "I cannot serve
|
|
1978
|
+
* what you asked for" is 406. `selectContentType` decides it instead, so the negotiated route
|
|
1979
|
+
* validates every OTHER header and leaves this one to the negotiation.
|
|
1980
|
+
*/
|
|
1981
|
+
if (parameter.type === "header" && parameter.name.toLowerCase() === "accept") {
|
|
1982
|
+
const declared = parameter.param.type;
|
|
1983
|
+
if (declared.kind === "String")
|
|
1984
|
+
accept = { name: parameter.name, value: declared.value };
|
|
1248
1985
|
}
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
* constraint on one of them.** Each member declares its own literal, so validating against any
|
|
1254
|
-
* single member's would 400 every request asking for one of the others, and 400 is the wrong
|
|
1255
|
-
* answer twice over: the request is well-formed, and the contract's answer to "I cannot serve
|
|
1256
|
-
* what you asked for" is 406. `selectContentType` decides it instead, so the negotiated route
|
|
1257
|
-
* validates every OTHER header and leaves this one to the negotiation.
|
|
1258
|
-
*/
|
|
1259
|
-
if (parameter.type === "header" && parameter.name.toLowerCase() === "accept") {
|
|
1260
|
-
const declared = parameter.param.type;
|
|
1261
|
-
if (declared.kind === "String")
|
|
1262
|
-
accept = { name: parameter.name, value: declared.value };
|
|
1263
|
-
}
|
|
1264
|
-
else if (parameter.type === "header") {
|
|
1265
|
-
add("header-without-accept", parameter.name, expression);
|
|
1266
|
-
}
|
|
1986
|
+
else if (parameter.type === "header") {
|
|
1987
|
+
add("header-without-accept", parameter.name, expression);
|
|
1988
|
+
}
|
|
1989
|
+
});
|
|
1267
1990
|
}
|
|
1268
1991
|
const fileContentType = fileContentTypeOf(operation);
|
|
1269
1992
|
if (fileContentType !== undefined) {
|
|
@@ -1300,32 +2023,159 @@ literalQueryNames) {
|
|
|
1300
2023
|
* string`, and validated by `z.string()`; shaped from the declared list it was expanded into
|
|
1301
2024
|
* `["a", "b"]` first, and every request was refused.
|
|
1302
2025
|
*/
|
|
1303
|
-
function decodedSegmentOf(program, parameter, segments) {
|
|
2026
|
+
function decodedSegmentOf(program, parameter, segments, wire) {
|
|
1304
2027
|
const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
|
|
1305
|
-
|
|
1306
|
-
|
|
2028
|
+
if (segment === undefined)
|
|
2029
|
+
return undefined;
|
|
2030
|
+
// Decoded where any form the value can take is - see `wireFormsOf`.
|
|
2031
|
+
const forms = wire === undefined ? wireFormsOf(encodedParameterType(program, parameter.param)) : [wire];
|
|
2032
|
+
return forms.some((form) => needsExpansionDecoding(segment, expansionShapeOf(form)))
|
|
1307
2033
|
? segment
|
|
1308
2034
|
: undefined;
|
|
1309
2035
|
}
|
|
2036
|
+
/**
|
|
2037
|
+
* **Whether a value of the type may be anything**: `unknown`, a scalar with no standard base, or a
|
|
2038
|
+
* union holding one - published as `{}` or with `{}` among its members - so its text may spell a list
|
|
2039
|
+
* or an object as well as a scalar.
|
|
2040
|
+
*/
|
|
2041
|
+
function takesAnyValue(program, type) {
|
|
2042
|
+
if (type.kind === "UnionVariant")
|
|
2043
|
+
return takesAnyValue(program, type.type);
|
|
2044
|
+
if (type.kind === "Union") {
|
|
2045
|
+
return [...type.variants.values()].some((variant) => takesAnyValue(program, variant.type));
|
|
2046
|
+
}
|
|
2047
|
+
return isTypelessMember(program, type);
|
|
2048
|
+
}
|
|
1310
2049
|
/** What a parameter's value decodes into once its expansion is undone. */
|
|
1311
2050
|
function expansionShapeOf(type) {
|
|
1312
2051
|
if (isArrayType(type))
|
|
1313
2052
|
return "list";
|
|
1314
2053
|
return type.kind === "Model" ? "record" : "scalar";
|
|
1315
2054
|
}
|
|
1316
|
-
/**
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
2055
|
+
/**
|
|
2056
|
+
* The schemas a record's or a model's values are read from text against - see `wireRead`: every
|
|
2057
|
+
* value of a record against its indexer's, each key of a model against its property's. A value
|
|
2058
|
+
* text cannot spell anything else, a string, needs none.
|
|
2059
|
+
*
|
|
2060
|
+
* `repeats` says whether a key may arrive more than once, a list as its repeated values: a
|
|
2061
|
+
* form-exploded query object, and a URL-encoded form. There a lone occurrence is boxed into the list
|
|
2062
|
+
* it stands for. Anywhere else a value is one piece of text, which spells no list: boxed in a header,
|
|
2063
|
+
* a cookie, a path or a non-exploded query, `l,1` was `{ l: [1] }` where the document reads
|
|
2064
|
+
* `{ l: "1" }` and refuses it, and `l,1,l,2` dropped the first value.
|
|
2065
|
+
*/
|
|
2066
|
+
function readersOf(program, registry, type, repeats) {
|
|
2067
|
+
if (type.kind !== "Model" || isArrayType(type))
|
|
2068
|
+
return NO_READERS;
|
|
2069
|
+
/**
|
|
2070
|
+
* **Every key no member names is read as the indexer that holds it, the topmost in the chain** -
|
|
2071
|
+
* see `bindingIndexerOf`. Read as the model's own, an inherited `...Record<int32>` read nothing and
|
|
2072
|
+
* a subtype's narrower indexer read what the base's, which the validator enforces, accepts.
|
|
2073
|
+
*/
|
|
2074
|
+
const binding = bindingIndexerOf(type);
|
|
2075
|
+
const values = binding === undefined || isNeverType(binding.value)
|
|
2076
|
+
? null
|
|
2077
|
+
: readerOf(program, registry, binding.value, undefined, repeats);
|
|
2078
|
+
/**
|
|
2079
|
+
* **Each key is read as the validator holds it**: the object the model is rendered as, each member
|
|
2080
|
+
* its most derived declaration at its position. Read first-wins from the base down, `Sub { x?:
|
|
2081
|
+
* string }` under `Base { x?: int32 | string }` read `"5"` as `5`, and `Sub` refused it.
|
|
2082
|
+
*
|
|
2083
|
+
* **Where an indexer reads every key, a declared key is read as its own property says** - text
|
|
2084
|
+
* included, so `s?: string` beside `...Record<int32 | string>` stays `"5"` rather than taking the
|
|
2085
|
+
* indexer's reading and becoming `5`.
|
|
2086
|
+
*/
|
|
2087
|
+
const fields = {};
|
|
2088
|
+
withVisibility(renderedVisibilityOf(program, type, currentVisibility()), () => {
|
|
2089
|
+
const object = objectOfModel(program, type);
|
|
2090
|
+
if (object.kind !== "object")
|
|
2091
|
+
return;
|
|
2092
|
+
for (const member of object.members) {
|
|
2093
|
+
if (member.property === undefined)
|
|
2094
|
+
continue;
|
|
2095
|
+
const reader = readerOf(program, registry, encodedParameterType(program, member.property), { property: member.property, position: member.position }, repeats, values !== null);
|
|
2096
|
+
if (reader !== null)
|
|
2097
|
+
fields[member.key] = reader;
|
|
2098
|
+
}
|
|
2099
|
+
});
|
|
2100
|
+
/**
|
|
2101
|
+
* **A discriminated base is read as the subtype its discriminator names**: a header `Snake`
|
|
2102
|
+
* carrying `kind,cobra,length,5` is a `Cobra`, whose `length` is a number, and `Pet` carrying
|
|
2103
|
+
* `kind,cat,x,5` a `Cat`, whose `x` is a string though `Dog`'s is a number. Read by one table for
|
|
2104
|
+
* every subtype, a key two of them declare was read as the first's.
|
|
2105
|
+
*/
|
|
2106
|
+
const discriminator = getDiscriminator(program, type);
|
|
2107
|
+
const dispatched = dispatchedSubtypesOf(program, type);
|
|
2108
|
+
return {
|
|
2109
|
+
values,
|
|
2110
|
+
fields: Object.keys(fields).length === 0 ? null : fields,
|
|
2111
|
+
...(discriminator === undefined || dispatched.length === 0
|
|
2112
|
+
? {}
|
|
2113
|
+
: {
|
|
2114
|
+
by: {
|
|
2115
|
+
property: discriminator.propertyName,
|
|
2116
|
+
readers: Object.fromEntries(dispatched.map(([value, subtype]) => [
|
|
2117
|
+
value,
|
|
2118
|
+
readersOf(program, registry, subtype, repeats),
|
|
2119
|
+
])),
|
|
2120
|
+
},
|
|
2121
|
+
}),
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
/**
|
|
2125
|
+
* The reader for one value of a record or a model, or `null` for none: a list boxed and read element
|
|
2126
|
+
* by element where a key repeats, a value text can spell as something else read against its schema -
|
|
2127
|
+
* the member's, at its position, where there is one - and text, which needs no reading unless
|
|
2128
|
+
* `always`.
|
|
2129
|
+
*/
|
|
2130
|
+
function readerOf(program, registry, type, member, repeats, always = false) {
|
|
2131
|
+
const whole = () => member === undefined
|
|
2132
|
+
? withParameterPosition(true, () => registry.expressionFor(type))
|
|
2133
|
+
: registry.expressionForProperty(member.property, member.position);
|
|
2134
|
+
// Text is never `null`, so a `| null` beside a list leaves the list to read.
|
|
2135
|
+
const inner = nullableInnerOf(type) ?? type;
|
|
2136
|
+
const element = elementOf(inner);
|
|
2137
|
+
if (element !== undefined) {
|
|
2138
|
+
// One piece of text spells no list, so it is left to the schema to refuse.
|
|
2139
|
+
if (!repeats)
|
|
2140
|
+
return always ? whole() : null;
|
|
2141
|
+
return eachReader(atItem(program, inner, () => withParameterPosition(true, () => registry.expressionFor(element))));
|
|
1327
2142
|
}
|
|
1328
|
-
|
|
2143
|
+
/**
|
|
2144
|
+
* **A union holding a list is one value or several**: `e?: int32 | int32[]` arrives as `?e=1` or
|
|
2145
|
+
* `?e=1&e=2`. Several are read as the list's elements; one is read against the whole type, which
|
|
2146
|
+
* takes it as the one value. Read as text alone, the repeated key reached the schema unread and was
|
|
2147
|
+
* refused where the document reads `[1, 2]`. Where a key does not repeat, it is only ever one.
|
|
2148
|
+
*/
|
|
2149
|
+
const forms = inner.kind === "Union" ? wireFormsOf(inner) : [];
|
|
2150
|
+
const lists = forms.filter((each) => elementOf(each) !== undefined);
|
|
2151
|
+
if (lists.length > 0) {
|
|
2152
|
+
if (!repeats)
|
|
2153
|
+
return whole();
|
|
2154
|
+
/**
|
|
2155
|
+
* **Every list member's elements, and the whole value to choose by**: `int32[] | string[]`
|
|
2156
|
+
* reads `?ll=1&ll=x` as the strings, the reading the whole accepts, where its first member's
|
|
2157
|
+
* elements made it `[1, "x"]`, and refused. One value is the one value only where a member is
|
|
2158
|
+
* not a list; `?ll=x` is the list `["x"]`.
|
|
2159
|
+
*/
|
|
2160
|
+
const each = lists.map((list) => atItem(program, list, () => withParameterPosition(true, () => registry.expressionFor(elementOf(list)))));
|
|
2161
|
+
const one = forms.some((each) => elementOf(each) === undefined) ? whole() : undefined;
|
|
2162
|
+
return eachReader(each, one, whole());
|
|
2163
|
+
}
|
|
2164
|
+
if (!always && !readsFromText(program, type))
|
|
2165
|
+
return null;
|
|
2166
|
+
return whole();
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* **The keys a form-exploded model is gathered by: the members its validator declares**, at the
|
|
2170
|
+
* visibility it is rendered at. Every property was a key, so `?secret=x` on a GET gathered a
|
|
2171
|
+
* create-only property the document does not publish there, and a sealed model refused the request
|
|
2172
|
+
* the document reads as `{}`.
|
|
2173
|
+
*/
|
|
2174
|
+
function explodedKeysOf(program, model) {
|
|
2175
|
+
return withVisibility(renderedVisibilityOf(program, model, currentVisibility()), () => {
|
|
2176
|
+
const object = objectOfModel(program, model);
|
|
2177
|
+
return object.kind === "object" ? object.members.map((member) => member.key) : [];
|
|
2178
|
+
});
|
|
1329
2179
|
}
|
|
1330
2180
|
/**
|
|
1331
2181
|
* **The media type an `Http.File` body declares, as the input it is.** A file's `contentType` is the
|
|
@@ -1340,7 +2190,7 @@ function fileContentTypeOf(operation) {
|
|
|
1340
2190
|
const body = requestBodyParameterOf(operation);
|
|
1341
2191
|
if (body?.bodyKind !== "file")
|
|
1342
2192
|
return undefined;
|
|
1343
|
-
const declared = operation.
|
|
2193
|
+
const declared = publishedParametersOf(operation).some((parameter) => parameter.type === "header" && parameter.name.toLowerCase() === "content-type");
|
|
1344
2194
|
return declared ? undefined : body.contentTypeProperty;
|
|
1345
2195
|
}
|
|
1346
2196
|
/**
|
|
@@ -1354,9 +2204,14 @@ function fileContentTypeOf(operation) {
|
|
|
1354
2204
|
* handler receives.
|
|
1355
2205
|
*/
|
|
1356
2206
|
/**
|
|
1357
|
-
* The keys a merged body would contribute to the handler's input: each
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
2207
|
+
* The keys a merged body would contribute to the handler's input: each key of the object the body's
|
|
2208
|
+
* validator declares, under its wire name, or each named part of a multipart body.
|
|
2209
|
+
*
|
|
2210
|
+
* **Read from that object, at the visibility and in the context it is rendered in**, so the two
|
|
2211
|
+
* cannot disagree. A read-only property is one openapi3 shares into a request that has nothing else
|
|
2212
|
+
* request-specific, as `readOnly` and still required under OAS 3.1's reading: read at the request
|
|
2213
|
+
* visibility it dropped out, so `@path accountId` beside a body requiring `accountId` was merged with
|
|
2214
|
+
* it and one of the two lost.
|
|
1360
2215
|
*/
|
|
1361
2216
|
function mergedBodyKeysOf(program, operation) {
|
|
1362
2217
|
const body = requestBodyParameterOf(operation);
|
|
@@ -1366,9 +2221,55 @@ function mergedBodyKeysOf(program, operation) {
|
|
|
1366
2221
|
const type = requestBodyOf(operation);
|
|
1367
2222
|
if (type?.kind !== "Model")
|
|
1368
2223
|
return [];
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
2224
|
+
const requested = requestVisibilityOf(program, operation);
|
|
2225
|
+
return withVisibility(rootVisibilityOf(program, type, requested), () => withMetadataAnnotationsIgnored(ignoresMetadata(body), () => {
|
|
2226
|
+
const object = objectOfModel(program, type);
|
|
2227
|
+
return object.kind === "object" ? object.members.map((member) => member.key) : [];
|
|
2228
|
+
}));
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* **Whether two parameters name one value on the wire**: one location and one name - a header's name
|
|
2232
|
+
* read without its case, as HTTP reads it (RFC 9110 section 5.1).
|
|
2233
|
+
*/
|
|
2234
|
+
function sameWireName(one, other) {
|
|
2235
|
+
return (one.type === other.type &&
|
|
2236
|
+
(one.type === "header"
|
|
2237
|
+
? one.name.toLowerCase() === other.name.toLowerCase()
|
|
2238
|
+
: one.name === other.name));
|
|
2239
|
+
}
|
|
2240
|
+
/**
|
|
2241
|
+
* **Two headers whose names differ only in case are one header**, and openapi3 publishes them as two
|
|
2242
|
+
* parameters of the one value - `x-foo: int32` and `X-Foo: string` - which every value has to
|
|
2243
|
+
* satisfy at once and a server hands over once. `header-case-collision`, an error, reported once per
|
|
2244
|
+
* pair, and both validate nothing; validated as the last, `X-Foo: abc` was served where the
|
|
2245
|
+
* document refuses it.
|
|
2246
|
+
*/
|
|
2247
|
+
const headerCaseCollisionsReported = new WeakMap();
|
|
2248
|
+
function headerCaseCollisionOf(program, operation, parameter) {
|
|
2249
|
+
if (parameter.type !== "header")
|
|
2250
|
+
return false;
|
|
2251
|
+
const other = publishedParametersOf(operation).find((each) => each.type === "header" &&
|
|
2252
|
+
each.name !== parameter.name &&
|
|
2253
|
+
each.name.toLowerCase() === parameter.name.toLowerCase());
|
|
2254
|
+
if (other === undefined)
|
|
2255
|
+
return false;
|
|
2256
|
+
const names = [parameter.name, other.name].toSorted();
|
|
2257
|
+
const reported = headerCaseCollisionsReported.get(program) ?? new Set();
|
|
2258
|
+
headerCaseCollisionsReported.set(program, reported);
|
|
2259
|
+
const key = `${operation.operation.name}\u0000${names.join("\u0000")}`;
|
|
2260
|
+
if (!reported.has(key)) {
|
|
2261
|
+
reported.add(key);
|
|
2262
|
+
reportDiagnostic(program, {
|
|
2263
|
+
code: "header-case-collision",
|
|
2264
|
+
target: parameter.param,
|
|
2265
|
+
format: {
|
|
2266
|
+
operation: operation.operation.name,
|
|
2267
|
+
first: names[0] ?? "",
|
|
2268
|
+
second: names[1] ?? "",
|
|
2269
|
+
},
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
return true;
|
|
1372
2273
|
}
|
|
1373
2274
|
const INPUT_LOCATION_NOUNS = {
|
|
1374
2275
|
path: "path parameter",
|
|
@@ -1395,7 +2296,7 @@ function sharedInputKeysOf(program, operation) {
|
|
|
1395
2296
|
if (resolved !== undefined)
|
|
1396
2297
|
return resolved;
|
|
1397
2298
|
const inputs = [];
|
|
1398
|
-
for (const parameter of operation
|
|
2299
|
+
for (const parameter of publishedParametersOf(operation)) {
|
|
1399
2300
|
if (parameter.type === "path" ||
|
|
1400
2301
|
parameter.type === "query" ||
|
|
1401
2302
|
parameter.type === "header" ||
|
|
@@ -1440,7 +2341,13 @@ function sharedInputKeysOf(program, operation) {
|
|
|
1440
2341
|
if (reported.has(said))
|
|
1441
2342
|
return;
|
|
1442
2343
|
reported.add(said);
|
|
1443
|
-
reportDiagnostic(program, {
|
|
2344
|
+
reportDiagnostic(program, {
|
|
2345
|
+
code: "shared-input-key",
|
|
2346
|
+
// One location, one value on the wire: the document publishes the first declaration.
|
|
2347
|
+
messageId: first.location === input.location ? "repeated" : "default",
|
|
2348
|
+
target: input.target,
|
|
2349
|
+
format,
|
|
2350
|
+
});
|
|
1444
2351
|
});
|
|
1445
2352
|
sharedInputKeys.set(operation, shared);
|
|
1446
2353
|
return shared;
|
|
@@ -1448,6 +2355,7 @@ function sharedInputKeysOf(program, operation) {
|
|
|
1448
2355
|
const sharedInputKeys = new WeakMap();
|
|
1449
2356
|
const sharedInputKeysReported = new WeakMap();
|
|
1450
2357
|
function requestBodyPlacementOf(program, operation) {
|
|
2358
|
+
reportNeverBody(program, operation, operation.parameters.body, "request");
|
|
1451
2359
|
const requestType = requestBodyOf(operation);
|
|
1452
2360
|
const bodyParameter = requestBodyParameterOf(operation);
|
|
1453
2361
|
const contentTypes = [...(bodyParameter?.contentTypes ?? [])];
|
|
@@ -1457,7 +2365,7 @@ function requestBodyPlacementOf(program, operation) {
|
|
|
1457
2365
|
* A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
|
|
1458
2366
|
* verify a different string and every delivery would fail authentication.
|
|
1459
2367
|
*/
|
|
1460
|
-
const bytes = requestType?.kind === "Scalar" && requestType
|
|
2368
|
+
const bytes = requestType?.kind === "Scalar" && program.checker.isStdType(requestType, "bytes");
|
|
1461
2369
|
/**
|
|
1462
2370
|
* **A body declared only under media types nothing can parse into it is handed over raw.** A
|
|
1463
2371
|
* model as `application/xml` has no serialisation this emitter can derive, and the server has no
|
|
@@ -1542,10 +2450,11 @@ function requestBodyPlacementOf(program, operation) {
|
|
|
1542
2450
|
* other.** `PUT /renames/{name}` with a body `{ name }` handed the handler one `name`, the body's,
|
|
1543
2451
|
* and the path's was lost with nothing reported - in `parameters/spread`'s composite request, and
|
|
1544
2452
|
* in any spec whose body repeats a path or query parameter. The document states the two
|
|
1545
|
-
* separately; named, the handler is given both.
|
|
1546
|
-
*
|
|
2453
|
+
* separately; named, the handler is given both. The body's keys are the keys its validator
|
|
2454
|
+
* declares - see `mergedBodyKeysOf` - so a read-only `id` shared into the request, still required
|
|
2455
|
+
* under OAS 3.1's reading, collides with `@path id` as any other key does.
|
|
1547
2456
|
*/
|
|
1548
|
-
const parameterNames = new Set(operation.
|
|
2457
|
+
const parameterNames = new Set(publishedParametersOf(operation).map((parameter) => parameter.name));
|
|
1549
2458
|
const collides = rawBodyProperty === undefined &&
|
|
1550
2459
|
mergedBodyKeysOf(program, operation).some((key) => parameterNames.has(key));
|
|
1551
2460
|
const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
|
|
@@ -1577,6 +2486,9 @@ function requestBodyPlacementOf(program, operation) {
|
|
|
1577
2486
|
};
|
|
1578
2487
|
}
|
|
1579
2488
|
export function collectRoutes(program, registry, service) {
|
|
2489
|
+
return withServiceView(program, service.namespace, () => collectServiceRoutes(program, registry, service));
|
|
2490
|
+
}
|
|
2491
|
+
function collectServiceRoutes(program, registry, service) {
|
|
1580
2492
|
const routes = [];
|
|
1581
2493
|
{
|
|
1582
2494
|
for (const operation of service.operations) {
|
|
@@ -1600,14 +2512,16 @@ export function collectRoutes(program, registry, service) {
|
|
|
1600
2512
|
*/
|
|
1601
2513
|
const streamed = streamedTypeOf(program, operation.operation.returnType);
|
|
1602
2514
|
if (streamed !== undefined) {
|
|
1603
|
-
withVisibility(
|
|
2515
|
+
withVisibility(Visibility.Read, () => registry.expressionFor(streamed));
|
|
1604
2516
|
}
|
|
1605
2517
|
// `@requestVisibility` when the operation states one, otherwise the verb's default - the
|
|
1606
2518
|
// same resolution `@typespec/openapi3` performs before emitting a request schema.
|
|
1607
|
-
const requestVisibility =
|
|
2519
|
+
const requestVisibility = requestVisibilityOf(program, operation);
|
|
1608
2520
|
const { rawBodyProperty, rawBodyBinary, rawBodyUnvalidated, bodyProperty, optionalBody } = requestBodyPlacementOf(program, operation);
|
|
1609
2521
|
const authentication = authenticationFor(program, operation);
|
|
1610
|
-
const template = routeTemplateOf(operation.uriTemplate,
|
|
2522
|
+
const template = routeTemplateOf(operation.uriTemplate,
|
|
2523
|
+
// Every path parameter, a `never` included: the template still holds its expression.
|
|
2524
|
+
operation.parameters.parameters
|
|
1611
2525
|
.filter((parameter) => parameter.type === "path")
|
|
1612
2526
|
.map((parameter) => ({
|
|
1613
2527
|
name: parameter.name,
|
|
@@ -1641,7 +2555,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1641
2555
|
.map((parameter) => parameter.name),
|
|
1642
2556
|
pathSegments: template.segments,
|
|
1643
2557
|
sharedInputKeys: sharedInputKeysOf(program, operation),
|
|
1644
|
-
decodedPathParameters: operation
|
|
2558
|
+
decodedPathParameters: publishedParametersOf(operation)
|
|
1645
2559
|
.filter((parameter) => decodedSegmentOf(program, parameter, template.segments) !== undefined)
|
|
1646
2560
|
.map((parameter) => parameter.name),
|
|
1647
2561
|
literalQuery: template.literalQuery,
|
|
@@ -1669,17 +2583,28 @@ export function collectRoutes(program, registry, service) {
|
|
|
1669
2583
|
* one and not the other, and `@visibility` can add or remove properties outright. Walking
|
|
1670
2584
|
* both at one visibility gives whichever was seen first to both.
|
|
1671
2585
|
*/
|
|
1672
|
-
|
|
2586
|
+
...withVisibility(requestVisibility, () => {
|
|
1673
2587
|
// Multipart first: its body has no single type to walk, and skipping it left every
|
|
1674
2588
|
// such route mounted with nothing checking the request at all.
|
|
1675
2589
|
const parameterBody = requestBodyParameterOf(operation);
|
|
1676
2590
|
if (parameterBody?.bodyKind === "multipart") {
|
|
1677
|
-
return
|
|
2591
|
+
return {
|
|
2592
|
+
requestSchema: multipartRequestOf(program, registry, parameterBody, multipartSchemaOf(program, parameterBody, registry)),
|
|
2593
|
+
textParts: textPartsOf(program, parameterBody),
|
|
2594
|
+
};
|
|
1678
2595
|
}
|
|
1679
|
-
|
|
2596
|
+
if (requestType === undefined)
|
|
2597
|
+
return { requestSchema: undefined };
|
|
2598
|
+
const declared = withMetadataAnnotationsIgnored(ignoresMetadata(parameterBody), () => registry.expressionFor(requestType, true));
|
|
2599
|
+
const form = formBodyOf(program, registry, operation, parameterBody, requestType, declared);
|
|
2600
|
+
return form === undefined
|
|
2601
|
+
? { requestSchema: declared }
|
|
2602
|
+
: form.only
|
|
2603
|
+
? { requestSchema: form.schema }
|
|
2604
|
+
: { requestSchema: declared, formRequestSchema: form.schema };
|
|
1680
2605
|
}),
|
|
1681
2606
|
...(() => {
|
|
1682
|
-
const split = withVisibility(
|
|
2607
|
+
const split = withVisibility(requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
|
|
1683
2608
|
return {
|
|
1684
2609
|
cookieSchema: split.cookie,
|
|
1685
2610
|
pathSchema: split.path,
|
|
@@ -1696,7 +2621,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1696
2621
|
* visibility declared six components under the wrong suffix - measured:
|
|
1697
2622
|
* type/model/visibility gained six divergences.
|
|
1698
2623
|
*/
|
|
1699
|
-
responses: withVisibility(
|
|
2624
|
+
responses: withVisibility(Visibility.Read, () => responsesOf(program, operation, registry)),
|
|
1700
2625
|
rawBodyProperty,
|
|
1701
2626
|
rawBodyBinary,
|
|
1702
2627
|
rawBodyUnvalidated,
|
|
@@ -1911,7 +2836,8 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
|
|
|
1911
2836
|
if (d.structural === undefined) {
|
|
1912
2837
|
return `\nexport const ${d.identifier} = ${compiled(d.source, compileSchemas)};\nexport type ${d.typeName} = z.infer<typeof ${d.identifier}>;\n`;
|
|
1913
2838
|
}
|
|
1914
|
-
|
|
2839
|
+
// One object literal is an interface; a union of them - an envelope's wrappers - is not.
|
|
2840
|
+
const declared = isSingleObjectLiteral(d.structural)
|
|
1915
2841
|
? `export interface ${d.typeName} ${d.structural}`
|
|
1916
2842
|
: `export type ${d.typeName} = ${d.structural};`;
|
|
1917
2843
|
/**
|
|
@@ -2033,11 +2959,27 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
|
|
|
2033
2959
|
* reason `z` is imported only when used: an unused declaration fails the lint a generated file has
|
|
2034
2960
|
* to pass. Declared rather than imported, so this file still depends on no runtime.
|
|
2035
2961
|
*/
|
|
2036
|
-
const referenced = identifiersIn(routeDeclarations.join(""));
|
|
2962
|
+
const referenced = identifiersIn([...declarations, ...routeDeclarations].join(""));
|
|
2963
|
+
// Both decoders read their values through `wireRead`, so it is declared beside either.
|
|
2964
|
+
const expands = referenced.has("uriExpansion") ||
|
|
2965
|
+
referenced.has("explodedQuery") ||
|
|
2966
|
+
referenced.has("wireReadObject");
|
|
2967
|
+
// And `wireReadAs` reads through `wireRead`, which a multipart text part calls on its own.
|
|
2968
|
+
const readsAs = expands || referenced.has("wireReadAs");
|
|
2969
|
+
if (readsAs || referenced.has("wireRead"))
|
|
2970
|
+
parts.push(WIRE_READ_SOURCE);
|
|
2971
|
+
if (readsAs)
|
|
2972
|
+
parts.push(WIRE_READ_AS_SOURCE);
|
|
2973
|
+
if (expands)
|
|
2974
|
+
parts.push(WIRE_READERS_SOURCE);
|
|
2037
2975
|
if (referenced.has("uriExpansion"))
|
|
2038
2976
|
parts.push(URI_EXPANSION_SOURCE);
|
|
2039
2977
|
if (referenced.has("explodedQuery"))
|
|
2040
2978
|
parts.push(EXPLODED_QUERY_SOURCE);
|
|
2979
|
+
if (referenced.has("wireReadObject"))
|
|
2980
|
+
parts.push(WIRE_READ_OBJECT_SOURCE);
|
|
2981
|
+
if (referenced.has("wireItself"))
|
|
2982
|
+
parts.push(WIRE_ITSELF_SOURCE);
|
|
2041
2983
|
if (routeDeclarations.length > 0) {
|
|
2042
2984
|
parts.push(`
|
|
2043
2985
|
/** One declared response: its status key, its body's schema, its media types and its headers. */
|
|
@@ -2063,14 +3005,55 @@ type ResponseArm = {
|
|
|
2063
3005
|
* keeps it, so the generated file does not fill up with aliases that alias nothing.
|
|
2064
3006
|
*/
|
|
2065
3007
|
const isIdentifier = (expression) => /^[A-Za-z_$][\w$]*$/.test(expression);
|
|
2066
|
-
|
|
3008
|
+
/**
|
|
3009
|
+
* The identifier a route's validator is exported under: the operation's id and the part it checks.
|
|
3010
|
+
*
|
|
3011
|
+
* **Never one of the helpers the module declares beside it.** An operation named `exploded` has a
|
|
3012
|
+
* query validator called `explodedQuery`, which is also the name of the function that gathers an
|
|
3013
|
+
* exploded query object, and the module did not parse. Such a name takes a trailing `_`, as a model
|
|
3014
|
+
* named after an emitted helper does.
|
|
3015
|
+
*/
|
|
3016
|
+
const schemaConst = (operationId, suffix, taken) => {
|
|
3017
|
+
/**
|
|
3018
|
+
* **And always an identifier, and never one already taken.** `@operationId("get-widget")` is a
|
|
3019
|
+
* valid id and not a valid name: `export const get-widgetQuery` did not parse. Each character no
|
|
3020
|
+
* identifier may hold becomes `_`, and a name another operation already took - `get_widget`
|
|
3021
|
+
* beside `get-widget` - takes one more.
|
|
3022
|
+
*/
|
|
3023
|
+
let name = `${operationId.replace(/[^\w$]/g, "_").replace(/^(\d)/, "_$1")}${suffix}`;
|
|
3024
|
+
while (EMITTED_FUNCTIONS.has(name) || taken.has(name))
|
|
3025
|
+
name = `${name}_`;
|
|
3026
|
+
taken.add(name);
|
|
3027
|
+
return name;
|
|
3028
|
+
};
|
|
3029
|
+
/**
|
|
3030
|
+
* Text written inside a block comment, which `*\/` would end: `@operationId("list*\/items")` is a
|
|
3031
|
+
* valid id, and the comment naming it closed early and left the rest of the line as code.
|
|
3032
|
+
*/
|
|
3033
|
+
function commentSafe(text) {
|
|
3034
|
+
// And printable ASCII, as every generated file is: a letter outside it is written as its escape.
|
|
3035
|
+
return text
|
|
3036
|
+
.replaceAll("*/", "*\\/")
|
|
3037
|
+
.replace(/[^\x20-\x7e]/gu, (each) => `\\u{${(each.codePointAt(0) ?? 0).toString(16)}}`);
|
|
3038
|
+
}
|
|
3039
|
+
/** The functions a schemas module may declare for itself - see `helperSources`. */
|
|
3040
|
+
const EMITTED_FUNCTIONS = new Set([
|
|
3041
|
+
"uriExpansion",
|
|
3042
|
+
"explodedQuery",
|
|
3043
|
+
"wireRead",
|
|
3044
|
+
"wireReadAs",
|
|
3045
|
+
"wireReaderOf",
|
|
3046
|
+
"wireReadObject",
|
|
3047
|
+
"wireItself",
|
|
3048
|
+
"wireSentAsItself",
|
|
3049
|
+
]);
|
|
2067
3050
|
/** `[the name to refer to it by, the `const` line to emit]` - the line is absent when none is needed. */
|
|
2068
|
-
function namedSchema(operationId, suffix, expression, compileSchemas) {
|
|
3051
|
+
function namedSchema(operationId, suffix, expression, compileSchemas, taken) {
|
|
2069
3052
|
if (expression === undefined)
|
|
2070
3053
|
return { name: undefined, declaration: undefined };
|
|
2071
3054
|
if (isIdentifier(expression))
|
|
2072
3055
|
return { name: expression, declaration: undefined };
|
|
2073
|
-
const name = schemaConst(operationId, suffix);
|
|
3056
|
+
const name = schemaConst(operationId, suffix, taken);
|
|
2074
3057
|
/**
|
|
2075
3058
|
* **Exported, because a schema nothing outside this file can name is a schema nothing can
|
|
2076
3059
|
* check.** These are the validators the server actually enforces on every request - the path,
|
|
@@ -2127,10 +3110,11 @@ function sharedSlots(routes) {
|
|
|
2127
3110
|
function nameRouteSchemas(routes, compileSchemas) {
|
|
2128
3111
|
const shared = sharedSlots(routes);
|
|
2129
3112
|
const declarations = [];
|
|
3113
|
+
const taken = new Set();
|
|
2130
3114
|
const names = new Map();
|
|
2131
3115
|
for (const route of routes) {
|
|
2132
3116
|
const declare = (suffix, expression) => {
|
|
2133
|
-
const resolved = namedSchema(route.operationId, suffix, expression, compileSchemas);
|
|
3117
|
+
const resolved = namedSchema(route.operationId, suffix, expression, compileSchemas, taken);
|
|
2134
3118
|
if (resolved.declaration !== undefined)
|
|
2135
3119
|
declarations.push(resolved.declaration);
|
|
2136
3120
|
return resolved.name;
|
|
@@ -2151,6 +3135,9 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
2151
3135
|
const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
|
|
2152
3136
|
// A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
|
|
2153
3137
|
const body = route.rawBodyProperty === undefined ? declare("Body", route.requestSchema) : undefined;
|
|
3138
|
+
const formBody = route.rawBodyProperty === undefined
|
|
3139
|
+
? declare("FormBody", route.formRequestSchema)
|
|
3140
|
+
: undefined;
|
|
2154
3141
|
/**
|
|
2155
3142
|
* One identifier per distinct body, suffixed by the status that first carries it. Two statuses
|
|
2156
3143
|
* sharing one inline body share its declaration rather than emitting it twice.
|
|
@@ -2180,7 +3167,7 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
2180
3167
|
}
|
|
2181
3168
|
return { status: response.status, schema: declaredBodies.get(response.schema), headers };
|
|
2182
3169
|
});
|
|
2183
|
-
const responses = schemaConst(route.operationId, "Responses");
|
|
3170
|
+
const responses = schemaConst(route.operationId, "Responses", taken);
|
|
2184
3171
|
declarations.push(`export const ${responses} = ${responseArmsOf(route, arms)} satisfies readonly ResponseArm[];`);
|
|
2185
3172
|
names.set(route.operationId, {
|
|
2186
3173
|
operationId: route.operationId,
|
|
@@ -2191,6 +3178,7 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
2191
3178
|
selectedAccept: negotiating ? route.accept : undefined,
|
|
2192
3179
|
cookie,
|
|
2193
3180
|
body,
|
|
3181
|
+
...(formBody === undefined ? {} : { formBody }),
|
|
2194
3182
|
arms,
|
|
2195
3183
|
responses,
|
|
2196
3184
|
});
|
|
@@ -2262,11 +3250,10 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2262
3250
|
* caller satisfying the published type sent read-only fields and a strict validator answered
|
|
2263
3251
|
* 400.
|
|
2264
3252
|
*
|
|
2265
|
-
*
|
|
2266
|
-
*
|
|
2267
|
-
* for the wrong program.
|
|
3253
|
+
* The predicates `propertyToTs` consults read the visibility `withVisibility` sets, and outside
|
|
3254
|
+
* one they read `Read`.
|
|
2268
3255
|
*/
|
|
2269
|
-
withVisibility(
|
|
3256
|
+
withVisibility(requestVisibilityOf(program, operation), () => {
|
|
2270
3257
|
const body = requestBodyOf(operation);
|
|
2271
3258
|
const placement = requestBodyPlacementOf(program, operation);
|
|
2272
3259
|
/** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
|
|
@@ -2278,7 +3265,7 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2278
3265
|
* beside the payload, they are arguments to the verification. Leaving them out of the wire
|
|
2279
3266
|
* type would describe an input the application cannot act on.
|
|
2280
3267
|
*/
|
|
2281
|
-
const extraProperties = operation
|
|
3268
|
+
const extraProperties = publishedParametersOf(operation)
|
|
2282
3269
|
.filter((parameter) => parameter.type === "path" ||
|
|
2283
3270
|
parameter.type === "query" ||
|
|
2284
3271
|
parameter.type === "header" ||
|
|
@@ -2290,9 +3277,16 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2290
3277
|
* `"x-thing"` here too: a consumer checking its handlers against these types otherwise
|
|
2291
3278
|
* fails to typecheck against a server that is correct.
|
|
2292
3279
|
*/
|
|
3280
|
+
// One value per name and location, as the document publishes the first declaration.
|
|
3281
|
+
.filter((parameter, index, all) => all.findIndex((other) => sameWireName(other, parameter)) === index)
|
|
2293
3282
|
.map((parameter) => ({
|
|
2294
3283
|
key: parameter.name,
|
|
2295
|
-
|
|
3284
|
+
// At the visibility its shared component is published at - see `parameterVisibilityOf`.
|
|
3285
|
+
// Refused as `never`, as its validator is - a header case collision, or a `@useRef`.
|
|
3286
|
+
line: headerCaseCollisionOf(program, operation, parameter) ||
|
|
3287
|
+
externalReferenceOf(parameter.param) !== undefined
|
|
3288
|
+
? `${objectKey(parameter.name)}${parameter.param.optional ? "?" : ""}: never;`
|
|
3289
|
+
: withVisibility(parameterVisibilityOf(parameter.param) ?? currentVisibility(), () => registry.expressionForProperty(parameter.param, parameter.name)),
|
|
2296
3290
|
}));
|
|
2297
3291
|
const fileContentType = fileContentTypeOf(operation);
|
|
2298
3292
|
if (fileContentType !== undefined) {
|
|
@@ -2326,15 +3320,16 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2326
3320
|
* it, though the input is the text or the bytes that arrived. 0.27.0 declared it, and a
|
|
2327
3321
|
* consumer importing it lost the import when the body became raw.
|
|
2328
3322
|
*/
|
|
2329
|
-
if (body !== undefined && declaresComponent(body))
|
|
2330
|
-
registry.expressionFor(body);
|
|
3323
|
+
if (body !== undefined && declaresComponent(body)) {
|
|
3324
|
+
withMetadataAnnotationsIgnored(ignoresMetadata(parameterBody), () => registry.expressionFor(body));
|
|
3325
|
+
}
|
|
2331
3326
|
}
|
|
2332
3327
|
else {
|
|
2333
3328
|
const expression = parameterBody?.bodyKind === "multipart"
|
|
2334
|
-
? multipartTsOf(
|
|
3329
|
+
? multipartTsOf(program, parameterBody, registry)
|
|
2335
3330
|
: body === undefined
|
|
2336
3331
|
? undefined
|
|
2337
|
-
: registry.expressionFor(body);
|
|
3332
|
+
: withMetadataAnnotationsIgnored(ignoresMetadata(parameterBody), () => registry.expressionFor(body, true));
|
|
2338
3333
|
if (parameterBody?.bodyKind !== "multipart")
|
|
2339
3334
|
bodyRef = expression;
|
|
2340
3335
|
if (expression !== undefined) {
|
|
@@ -2351,7 +3346,9 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2351
3346
|
entries.push({
|
|
2352
3347
|
operationId: operationIdOf(program, operation.operation),
|
|
2353
3348
|
bodyRef,
|
|
2354
|
-
input: parts.length === 0
|
|
3349
|
+
input: parts.length === 0
|
|
3350
|
+
? "globalThis.Record<string, never>"
|
|
3351
|
+
: `Simplify<${parts.join(" & ")}>`,
|
|
2355
3352
|
streamsBody,
|
|
2356
3353
|
});
|
|
2357
3354
|
});
|
|
@@ -2368,7 +3365,7 @@ function collectResponseTypes(program, registry, service) {
|
|
|
2368
3365
|
* components under the wrong suffix. Saying it here rather than relying on the default is what
|
|
2369
3366
|
* makes that impossible from either direction.
|
|
2370
3367
|
*/
|
|
2371
|
-
withVisibility(
|
|
3368
|
+
withVisibility(Visibility.Read, () => {
|
|
2372
3369
|
for (const operation of service.operations) {
|
|
2373
3370
|
/**
|
|
2374
3371
|
* **The same responses, and the same members, the server emitters render.** This walk used
|
|
@@ -2394,7 +3391,8 @@ function collectResponseTypes(program, registry, service) {
|
|
|
2394
3391
|
if (member.unvalidated &&
|
|
2395
3392
|
member.response.body !== undefined &&
|
|
2396
3393
|
declaresComponent(member.response.body)) {
|
|
2397
|
-
|
|
3394
|
+
const declared = member.response.body;
|
|
3395
|
+
withMetadataAnnotationsIgnored(member.response.ignoresMetadata, () => registry.expressionFor(declared));
|
|
2398
3396
|
}
|
|
2399
3397
|
switch (member.serving) {
|
|
2400
3398
|
case "none":
|
|
@@ -2408,9 +3406,10 @@ function collectResponseTypes(program, registry, service) {
|
|
|
2408
3406
|
case "unvalidated":
|
|
2409
3407
|
return "string";
|
|
2410
3408
|
default: {
|
|
2411
|
-
|
|
3409
|
+
const declared = member.response.body;
|
|
3410
|
+
if (declared === undefined)
|
|
2412
3411
|
return undefined;
|
|
2413
|
-
const ref =
|
|
3412
|
+
const ref = withMetadataAnnotationsIgnored(member.response.ignoresMetadata, () => registry.expressionFor(declared, true));
|
|
2414
3413
|
bodyRefs.push(ref);
|
|
2415
3414
|
return `Produced<${ref}>`;
|
|
2416
3415
|
}
|
|
@@ -2434,8 +3433,8 @@ function collectResponseTypes(program, registry, service) {
|
|
|
2434
3433
|
const every = headers.every(({ property }) => property.optional);
|
|
2435
3434
|
const rendered = headers
|
|
2436
3435
|
.map(({ name, property }) => property.optional
|
|
2437
|
-
? `readonly ${objectKey(name)}?: ${
|
|
2438
|
-
: `readonly ${objectKey(name)}: ${
|
|
3436
|
+
? `readonly ${objectKey(name)}?: ${headerTypeOf(program, property)} | undefined`
|
|
3437
|
+
: `readonly ${objectKey(name)}: ${headerTypeOf(program, property)}`)
|
|
2439
3438
|
.join("; ");
|
|
2440
3439
|
fields.push(`readonly headers${every ? "?" : ""}: { ${rendered} }`);
|
|
2441
3440
|
}
|
|
@@ -2493,9 +3492,9 @@ const BYTE_STREAM = `
|
|
|
2493
3492
|
*/
|
|
2494
3493
|
export interface ByteStream {
|
|
2495
3494
|
getReader(): {
|
|
2496
|
-
read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>;
|
|
3495
|
+
read(): globalThis.Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>;
|
|
2497
3496
|
releaseLock(): void;
|
|
2498
|
-
cancel?(reason?: unknown): Promise<void>;
|
|
3497
|
+
cancel?(reason?: unknown): globalThis.Promise<void>;
|
|
2499
3498
|
};
|
|
2500
3499
|
}
|
|
2501
3500
|
`;
|
|
@@ -2532,7 +3531,7 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
2532
3531
|
: `\nexport type ${declaration.name} = ${declaration.source};\n`;
|
|
2533
3532
|
});
|
|
2534
3533
|
const responseLookup = responses
|
|
2535
|
-
.map((entry) => `\t/** Every success body \`${entry.operationId}\` declares. */\n\t${entry.operationId}: ${entry.output};`)
|
|
3534
|
+
.map((entry) => `\t/** Every success body \`${commentSafe(entry.operationId)}\` declares. */\n\t${objectKey(entry.operationId)}: ${entry.output};`)
|
|
2536
3535
|
.join("\n");
|
|
2537
3536
|
/**
|
|
2538
3537
|
* **One member per declared response and media type, each pairing a status with its own body.**
|
|
@@ -2540,7 +3539,7 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
2540
3539
|
* over a service binding returns `WireResults[op]`, and a handler can hand it straight on.
|
|
2541
3540
|
*/
|
|
2542
3541
|
const resultLookup = responses
|
|
2543
|
-
.map((entry) => `\t/** Every response \`${entry.operationId}\` declares. */\n\t${entry.operationId}:${entry.result.length === 0 ? " never" : entry.result.map((member) => `\n\t\t| ${member}`).join("")};`)
|
|
3542
|
+
.map((entry) => `\t/** Every response \`${commentSafe(entry.operationId)}\` declares. */\n\t${objectKey(entry.operationId)}:${entry.result.length === 0 ? " never" : entry.result.map((member) => `\n\t\t| ${member}`).join("")};`)
|
|
2544
3543
|
.join("\n");
|
|
2545
3544
|
/**
|
|
2546
3545
|
* **Keyed by operation id, and the only name an operation's input has.**
|
|
@@ -2553,7 +3552,7 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
2553
3552
|
* `WireInputs["createUser"]` is the same type, and nothing a spec declares can collide with it.
|
|
2554
3553
|
*/
|
|
2555
3554
|
const lookup = entries
|
|
2556
|
-
.map((entry) => `\t/** The input for \`${entry.operationId}\`, as its handler receives it. */\n\t${entry.operationId}: ${entry.input.replaceAll("\n", "\n\t")};`)
|
|
3555
|
+
.map((entry) => `\t/** The input for \`${commentSafe(entry.operationId)}\`, as its handler receives it. */\n\t${objectKey(entry.operationId)}: ${entry.input.replaceAll("\n", "\n\t")};`)
|
|
2557
3556
|
.join("\n");
|
|
2558
3557
|
/**
|
|
2559
3558
|
* **Emitted only where an operation has something to flatten.**
|
|
@@ -2598,8 +3597,9 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
|
2598
3597
|
* one service: 2 contract methods returning \`readonly T[]\` and 35 readonly array properties, none
|
|
2599
3598
|
* of them expressible against the published shape.
|
|
2600
3599
|
*/
|
|
2601
|
-
export type Produced<T> = T extends readonly
|
|
2602
|
-
?
|
|
3600
|
+
export type Produced<T> = T extends readonly unknown[]
|
|
3601
|
+
? // Mapped over the list itself, so a tuple keeps its positions: \`[A?, ...unknown[]]\`.
|
|
3602
|
+
{ readonly [K in keyof T]: Produced<T[K]> }
|
|
2603
3603
|
: T extends object
|
|
2604
3604
|
? { readonly [K in keyof T]: Produced<T[K]> }
|
|
2605
3605
|
: T;
|
|
@@ -2700,7 +3700,7 @@ function renderWireAssertions(schemas, types, entries, responses, contractsPacka
|
|
|
2700
3700
|
}
|
|
2701
3701
|
if (paired.length === 0)
|
|
2702
3702
|
return undefined;
|
|
2703
|
-
const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.input<typeof ${pair.identifier}>>, Contracts.${pair.name}
|
|
3703
|
+
const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.input<typeof ${pair.identifier}>>, Flat<Contracts.${pair.name}>>>;`);
|
|
2704
3704
|
return `${generatedBanner()}
|
|
2705
3705
|
import type { z } from "zod";
|
|
2706
3706
|
import type * as Contracts from "${contractsPackage}";
|
|
@@ -2762,10 +3762,9 @@ type DeclaredKeyOf<T> = keyof {
|
|
|
2762
3762
|
|
|
2763
3763
|
type Declared<T> = T extends (...args: never[]) => unknown
|
|
2764
3764
|
? T
|
|
2765
|
-
: T extends readonly
|
|
2766
|
-
?
|
|
2767
|
-
|
|
2768
|
-
: readonly Declared<Element>[]
|
|
3765
|
+
: T extends readonly unknown[]
|
|
3766
|
+
? // Mapped over the list itself: a tuple keeps its positions, and \`readonly\` is kept as it is.
|
|
3767
|
+
{ [K in keyof T]: Declared<T[K]> }
|
|
2769
3768
|
: T extends object
|
|
2770
3769
|
? [DeclaredKeyOf<T>] extends [never]
|
|
2771
3770
|
? [T[keyof T]] extends [never]
|
|
@@ -2792,6 +3791,24 @@ type Declared<T> = T extends (...args: never[]) => unknown
|
|
|
2792
3791
|
}
|
|
2793
3792
|
: T;
|
|
2794
3793
|
|
|
3794
|
+
/**
|
|
3795
|
+
* **The contract type with its intersections written out**, as \`Declared\` writes out the validator's:
|
|
3796
|
+
* \`(A) & (Inner)\` - a redeclaration merged with a \`@oneOf\` union it cannot be merged into key by key
|
|
3797
|
+
* - and the one object type a mapping makes of it are one shape, which the identity check below tells
|
|
3798
|
+
* apart by spelling. Mapped and nothing dropped, so an index signature the contract states is still
|
|
3799
|
+
* compared.
|
|
3800
|
+
*/
|
|
3801
|
+
type Flat<T> = T extends (...args: never[]) => unknown
|
|
3802
|
+
? T
|
|
3803
|
+
: T extends object
|
|
3804
|
+
? [keyof T] extends [never]
|
|
3805
|
+
? // **Every object with no key is the one \`{}\`**, as \`Declared\` writes an empty object: an
|
|
3806
|
+
// empty interface beside an empty literal, \`M1 | {}\`, is otherwise two members where
|
|
3807
|
+
// the validator's side is one.
|
|
3808
|
+
{}
|
|
3809
|
+
: { [K in keyof T]: Flat<T[K]> }
|
|
3810
|
+
: T;
|
|
3811
|
+
|
|
2795
3812
|
/**
|
|
2796
3813
|
* **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
|
|
2797
3814
|
* type when the two disagree, and a type alias to an odd type compiles fine - 66 assertions that
|
|
@@ -2963,124 +3980,190 @@ export async function emitHttpZod(context) {
|
|
|
2963
3980
|
for (const snapshot of snapshots) {
|
|
2964
3981
|
beginOperationIds();
|
|
2965
3982
|
const service = snapshot.service;
|
|
2966
|
-
const serviceName = service.namespace.name;
|
|
2967
|
-
const perService = options.services?.[serviceName];
|
|
2968
|
-
const outputDir = perService?.["output-dir"] ??
|
|
2969
|
-
(distinctServices.size > 1
|
|
2970
|
-
? resolvePath(context.emitterOutputDir, serviceName)
|
|
2971
|
-
: context.emitterOutputDir);
|
|
2972
3983
|
/**
|
|
2973
|
-
* **
|
|
2974
|
-
*
|
|
2975
|
-
*
|
|
2976
|
-
*
|
|
2977
|
-
*
|
|
2978
|
-
*
|
|
2979
|
-
*
|
|
2980
|
-
* package specifier has to be set per service.
|
|
3984
|
+
* **Rendered whole before anything is written.** This service's view, its operation ids and
|
|
3985
|
+
* its banner are module state, and an `await` while they are in force lets another compile in
|
|
3986
|
+
* the same process run in between: the contract types were walked after `schemas.gen.ts` was
|
|
3987
|
+
* written, and with three fixtures compiled at once in one suite, one walked them under
|
|
3988
|
+
* another program's view - its components unsuffixed, `DerivedK` declared twice, its wire
|
|
3989
|
+
* contract unable to pair a validator with its type. Written after the scope closes, nothing
|
|
3990
|
+
* awaits inside it.
|
|
2981
3991
|
*/
|
|
2982
|
-
const
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
? resolvePath(sharedContractsDir, serviceName)
|
|
2987
|
-
: sharedContractsDir),
|
|
2988
|
-
contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
|
|
2989
|
-
sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
|
|
2990
|
-
compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
|
|
2991
|
-
keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
|
|
2992
|
-
regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
|
|
2993
|
-
};
|
|
2994
|
-
beginBanner(resolved.regenerateHint);
|
|
2995
|
-
const registry = new SchemaRegistry(context.program);
|
|
2996
|
-
const { result: { routes }, imports: externals, vocabularies, } = collectExternalImports(() => withContractsPackage(resolved.contractsPackage, () => withSealedObjects(resolved.sealObjectSchemas, () => {
|
|
2997
|
-
// Declarations first: a vocabulary is a fact about the contract, so it is collected from
|
|
2998
|
-
// what the service DECLARES rather than from what the Zod walk happens to touch. An enum
|
|
2999
|
-
// reachable only through an error body was silently absent otherwise.
|
|
3000
|
-
noteDeclaredVocabularies(service.namespace.enums.values());
|
|
3992
|
+
const writes = [];
|
|
3993
|
+
// Named and written as openapi3 names and writes this service's components - see `published.ts`.
|
|
3994
|
+
const restoreView = beginServiceView(context.program, service.namespace);
|
|
3995
|
+
try {
|
|
3001
3996
|
/**
|
|
3002
|
-
* A
|
|
3003
|
-
*
|
|
3004
|
-
*
|
|
3005
|
-
*
|
|
3006
|
-
*
|
|
3007
|
-
* `key-vocabularies` writes the keys beside the types. A name matching nothing is
|
|
3008
|
-
* reported rather than quietly emitting no tuple.
|
|
3997
|
+
* **A union that reaches itself through unions alone is refused before anything is walked,
|
|
3998
|
+
* and the service emits nothing.** Every other refusal is reported and walked past, so one
|
|
3999
|
+
* compile names them all; this one cannot be walked past, because every reading of it - the
|
|
4000
|
+
* schema, the type, a parameter's forms - recurses without end. The rest of the service is
|
|
4001
|
+
* read once the cycle is broken. See `reachesItselfThroughUnions`.
|
|
3009
4002
|
*/
|
|
3010
|
-
const
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
4003
|
+
const selfReaching = [...publishedTypes()].filter((type) => type.kind === "Union" &&
|
|
4004
|
+
type.name !== undefined &&
|
|
4005
|
+
reachesItselfThroughUnions(context.program, type));
|
|
4006
|
+
for (const union of selfReaching)
|
|
4007
|
+
refuseSelfReachingUnion(context.program, union);
|
|
4008
|
+
if (selfReaching.length > 0)
|
|
4009
|
+
continue;
|
|
4010
|
+
const serviceName = service.namespace.name;
|
|
4011
|
+
const perService = options.services?.[serviceName];
|
|
4012
|
+
const outputDir = perService?.["output-dir"] ??
|
|
4013
|
+
(distinctServices.size > 1
|
|
4014
|
+
? resolvePath(context.emitterOutputDir, serviceName)
|
|
4015
|
+
: context.emitterOutputDir);
|
|
3020
4016
|
/**
|
|
3021
|
-
*
|
|
3022
|
-
*
|
|
3023
|
-
*
|
|
3024
|
-
*
|
|
3025
|
-
*
|
|
3026
|
-
*
|
|
3027
|
-
*
|
|
3028
|
-
*
|
|
3029
|
-
* settled on rather than the one its own response declares. Each arm resolves its own
|
|
3030
|
-
* body now; registering this one only ensures it is declared.
|
|
4017
|
+
* **Split where two services would write one file, and nowhere else.** A shared contracts
|
|
4018
|
+
* directory was written once per service to one path, so the second service's
|
|
4019
|
+
* `requests.gen.ts` replaced the first's and a whole surface lost its contracts with no
|
|
4020
|
+
* diagnostic. Counted over the services that WRITE there: one service falling back to the
|
|
4021
|
+
* shared directory, beside others that name their own, collides with nothing, and splitting
|
|
4022
|
+
* it moved contracts a project already imported. A relative `contracts-package` is resolved
|
|
4023
|
+
* from each service's own `wire-contract.gen.ts`, so it names that service's contracts; a
|
|
4024
|
+
* package specifier has to be set per service.
|
|
3031
4025
|
*/
|
|
3032
|
-
const
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
4026
|
+
const sharedContractsDir = options["contracts-output-dir"];
|
|
4027
|
+
const resolved = {
|
|
4028
|
+
contractsOutputDir: perService?.["contracts-output-dir"] ??
|
|
4029
|
+
(sharedContractsDir !== undefined && sharingContracts.length > 1
|
|
4030
|
+
? resolvePath(sharedContractsDir, serviceName)
|
|
4031
|
+
: sharedContractsDir),
|
|
4032
|
+
contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
|
|
4033
|
+
sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
|
|
4034
|
+
compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
|
|
4035
|
+
keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
|
|
4036
|
+
regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
|
|
3038
4037
|
};
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
4038
|
+
beginBanner(resolved.regenerateHint);
|
|
4039
|
+
const registry = new SchemaRegistry(context.program);
|
|
4040
|
+
const { result: { routes }, imports: externals, vocabularies, } = collectExternalImports(() => withContractsPackage(resolved.contractsPackage, () => withSealedObjects(resolved.sealObjectSchemas, () => {
|
|
4041
|
+
// Declarations first: a vocabulary is a fact about the contract, so it is collected from
|
|
4042
|
+
// what the service DECLARES rather than from what the Zod walk happens to touch. An enum
|
|
4043
|
+
// reachable only through an error body was silently absent otherwise.
|
|
4044
|
+
noteDeclaredVocabularies(service.namespace.enums.values());
|
|
4045
|
+
/**
|
|
4046
|
+
* A closed key SET is a vocabulary too, and it cannot be an enum.
|
|
4047
|
+
*
|
|
4048
|
+
* TypeSpec has no key-constrained `Record`, so a model naming its permitted properties is
|
|
4049
|
+
* how the document states one - and a generated type cannot be iterated, which leaves a
|
|
4050
|
+
* consumer needing the set at run time with a hand-maintained copy. Naming the model in
|
|
4051
|
+
* `key-vocabularies` writes the keys beside the types. A name matching nothing is
|
|
4052
|
+
* reported rather than quietly emitting no tuple.
|
|
4053
|
+
*/
|
|
4054
|
+
const emittedKeyVocabularies = noteKeyVocabularies(context.program, service.namespace.models.values(), resolved.keyVocabularies);
|
|
4055
|
+
for (const name of resolved.keyVocabularies) {
|
|
4056
|
+
if (emittedKeyVocabularies.includes(name))
|
|
4057
|
+
continue;
|
|
4058
|
+
reportDiagnostic(context.program, {
|
|
4059
|
+
code: "unknown-key-vocabulary",
|
|
4060
|
+
target: service.namespace,
|
|
4061
|
+
format: { name, service: service.namespace.name },
|
|
4062
|
+
});
|
|
4063
|
+
}
|
|
4064
|
+
/**
|
|
4065
|
+
* The error body is registered INSIDE this walk, so its validator lands in
|
|
4066
|
+
* `schemas.gen.ts` alongside every other declaration. Nothing else reaches it: the Zod
|
|
4067
|
+
* walk follows success bodies and parameters, so without this a runtime would have no
|
|
4068
|
+
* generated schema to check the documents it sends against.
|
|
4069
|
+
*
|
|
4070
|
+
* **Its expression is registered and then deliberately discarded.** It used to be
|
|
4071
|
+
* returned and used as a fallback for any response arm whose own body could not be
|
|
4072
|
+
* resolved, which meant every catch-all answered with whichever shape `errorBodyOf` had
|
|
4073
|
+
* settled on rather than the one its own response declares. Each arm resolves its own
|
|
4074
|
+
* body now; registering this one only ensures it is declared.
|
|
4075
|
+
*/
|
|
4076
|
+
const errorBody = errorBodyOf(context.program, service);
|
|
4077
|
+
if (errorBody !== undefined) {
|
|
4078
|
+
withVisibility(Visibility.Read, () => registry.expressionFor(errorBody));
|
|
4079
|
+
}
|
|
4080
|
+
const routes = withStreamModule(streamModule, () => collectRoutes(context.program, registry, service));
|
|
4081
|
+
// And every type no operation reaches, as openapi3 publishes it - see `unreachedTypes`.
|
|
4082
|
+
withVisibility(Visibility.All, () => {
|
|
4083
|
+
for (const type of unreachedTypes())
|
|
4084
|
+
registry.expressionFor(type);
|
|
4085
|
+
});
|
|
4086
|
+
// And every component openapi3 writes that no route names - see `publishedComponents`.
|
|
4087
|
+
for (const { type, at, ignore, binary } of publishedComponents(context.program)) {
|
|
4088
|
+
withVisibility(at, () => withMetadataAnnotationsIgnored(ignore, () => withMultipartBinary(binary, () => registry.declare(type))));
|
|
4089
|
+
}
|
|
4090
|
+
return { routes };
|
|
4091
|
+
})));
|
|
3053
4092
|
/**
|
|
3054
|
-
* **
|
|
3055
|
-
*
|
|
3056
|
-
*
|
|
4093
|
+
* **Named AFTER the walk and BEFORE the file is rendered.** Naming mints identifiers that
|
|
4094
|
+
* `schemas.gen.ts` must declare, so it has to happen while the registry is complete and before
|
|
4095
|
+
* anything is written - and it has to happen HERE rather than in a server emitter, or the
|
|
4096
|
+
* validators it produces never reach this package's own output.
|
|
3057
4097
|
*/
|
|
3058
|
-
const {
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
await emitFile(context.program, {
|
|
3063
|
-
path: resolvePath(resolved.contractsOutputDir, "requests.gen.ts"),
|
|
3064
|
-
content: renderRequestTypes(entries, responses, types),
|
|
3065
|
-
});
|
|
3066
|
-
await emitFile(context.program, {
|
|
3067
|
-
path: resolvePath(resolved.contractsOutputDir, "vocabularies.gen.ts"),
|
|
3068
|
-
content: renderVocabularies(vocabularies),
|
|
4098
|
+
const { names, declarations } = nameRouteSchemas(routes, resolved.compileSchemas);
|
|
4099
|
+
writes.push({
|
|
4100
|
+
path: resolvePath(outputDir, "schemas.gen.ts"),
|
|
4101
|
+
content: renderSchemas(registry, externals, declarations, resolved.compileSchemas),
|
|
3069
4102
|
});
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
4103
|
+
if (resolved.contractsOutputDir !== undefined) {
|
|
4104
|
+
const types = new TypeRegistry(context.program, registry.held);
|
|
4105
|
+
/**
|
|
4106
|
+
* **Inside the same stream scope as the route walk**, because both read the same response
|
|
4107
|
+
* records: outside it `streamedTypeOf` cannot see `@typespec/streams`, so an SSE body was
|
|
4108
|
+
* stated as the string the document describes rather than the stream a server sends.
|
|
4109
|
+
*/
|
|
4110
|
+
/**
|
|
4111
|
+
* **And inside the same sealing**, because whether a model is published at all can depend
|
|
4112
|
+
* on it - a template instance extending a base openapi3 seals is refused on both sides,
|
|
4113
|
+
* `sealed-base-extended` - and the two walks have to agree about that.
|
|
4114
|
+
*/
|
|
4115
|
+
const { requests, assertions } = withSealedObjects(resolved.sealObjectSchemas, () => {
|
|
4116
|
+
const { entries, responses } = withStreamModule(streamModule, () => ({
|
|
4117
|
+
entries: collectRequestTypes(context.program, types, service),
|
|
4118
|
+
responses: collectResponseTypes(context.program, types, service),
|
|
4119
|
+
}));
|
|
4120
|
+
withVisibility(Visibility.All, () => {
|
|
4121
|
+
for (const type of unreachedTypes())
|
|
4122
|
+
types.expressionFor(type);
|
|
4123
|
+
});
|
|
4124
|
+
for (const { type, at, ignore, binary } of publishedComponents(context.program)) {
|
|
4125
|
+
withVisibility(at, () => withMetadataAnnotationsIgnored(ignore, () => withMultipartBinary(binary, () => types.declare(type))));
|
|
4126
|
+
}
|
|
4127
|
+
types.refuse(registry.refusedNames());
|
|
4128
|
+
return {
|
|
4129
|
+
requests: renderRequestTypes(entries, responses, types),
|
|
4130
|
+
assertions: renderWireAssertions(registry, types, entries, responses, resolved.contractsPackage),
|
|
4131
|
+
};
|
|
4132
|
+
});
|
|
4133
|
+
writes.push({
|
|
4134
|
+
path: resolvePath(resolved.contractsOutputDir, "requests.gen.ts"),
|
|
4135
|
+
content: requests,
|
|
3075
4136
|
});
|
|
4137
|
+
writes.push({
|
|
4138
|
+
path: resolvePath(resolved.contractsOutputDir, "vocabularies.gen.ts"),
|
|
4139
|
+
content: renderVocabularies(vocabularies),
|
|
4140
|
+
});
|
|
4141
|
+
if (assertions !== undefined) {
|
|
4142
|
+
writes.push({
|
|
4143
|
+
path: resolvePath(outputDir, "wire-contract.gen.ts"),
|
|
4144
|
+
content: assertions,
|
|
4145
|
+
});
|
|
4146
|
+
}
|
|
3076
4147
|
}
|
|
4148
|
+
// Still inside this service's id scope, so each operation resolves to the id its route carries.
|
|
4149
|
+
const operations = new Map(service.operations.map((operation) => [
|
|
4150
|
+
operationIdOf(context.program, operation.operation),
|
|
4151
|
+
operation,
|
|
4152
|
+
]));
|
|
4153
|
+
emitted.push({
|
|
4154
|
+
service,
|
|
4155
|
+
routes,
|
|
4156
|
+
schemaNames: names,
|
|
4157
|
+
operations,
|
|
4158
|
+
outputDir,
|
|
4159
|
+
options: resolved,
|
|
4160
|
+
});
|
|
4161
|
+
}
|
|
4162
|
+
finally {
|
|
4163
|
+
restoreView();
|
|
3077
4164
|
}
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
operationIdOf(context.program, operation.operation),
|
|
3081
|
-
operation,
|
|
3082
|
-
]));
|
|
3083
|
-
emitted.push({ service, routes, schemaNames: names, operations, outputDir, options: resolved });
|
|
4165
|
+
for (const write of writes)
|
|
4166
|
+
await emitFile(context.program, write);
|
|
3084
4167
|
}
|
|
3085
4168
|
return emitted;
|
|
3086
4169
|
}
|