typespec-http-zod 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/src/api.d.ts +31 -0
- package/dist/src/api.js +1475 -362
- package/dist/src/constraints.d.ts +31 -2
- package/dist/src/constraints.js +242 -46
- package/dist/src/lib.d.ts +163 -13
- package/dist/src/lib.js +207 -18
- package/dist/src/openapi-versions.d.ts +20 -0
- package/dist/src/openapi-versions.js +31 -0
- package/dist/src/published.d.ts +139 -0
- package/dist/src/published.js +798 -0
- package/dist/src/registry.d.ts +79 -45
- package/dist/src/registry.js +788 -200
- 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 +764 -39
- package/dist/src/zod.js +4313 -541
- package/package.json +1 -1
package/dist/src/api.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
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, getVisibilitySuffix, 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";
|
|
6
|
+
import { openApiVersionsOf, publishesOpenApi30AndLater } from "./openapi-versions.js";
|
|
5
7
|
import { literal } from "./literal.js";
|
|
6
|
-
import {
|
|
8
|
+
import { beginServiceView, parameterVisibilityOf, publishedComponents, publishedTypes, requestVisibilityOf, unreachedTypes, withServiceView, } from "./published.js";
|
|
9
|
+
import { isSingleObjectLiteral, SchemaRegistry, TypeRegistry } from "./registry.js";
|
|
7
10
|
import { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
|
|
8
11
|
import { propertyTypeToTs } from "./types.js";
|
|
9
12
|
import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
|
|
10
13
|
import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
|
|
11
|
-
import { EXPLODED_QUERY_SOURCE, explodedQueryCall, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, } from "./uri-template.js";
|
|
14
|
+
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
15
|
export { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
|
|
13
16
|
export { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
|
|
14
|
-
import { collectExternalImports, encodedTypeOf,
|
|
17
|
+
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, requiredReadOnlyIn, reportUnpublishable, withRequestReading, withMultipartBinary, withParameterPosition, UNREPRESENTABLE, withVisibility, } from "./zod.js";
|
|
15
18
|
/**
|
|
16
19
|
* `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
|
|
17
20
|
* service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
|
|
@@ -322,15 +325,18 @@ function resolveResponses(program, operation) {
|
|
|
322
325
|
const entry = byStatus.get(key) ?? {
|
|
323
326
|
status,
|
|
324
327
|
body: undefined,
|
|
328
|
+
ignoresMetadata: false,
|
|
325
329
|
contentTypes: new Set(),
|
|
326
330
|
headers: [],
|
|
327
331
|
streamed: false,
|
|
328
332
|
file: false,
|
|
329
333
|
};
|
|
330
334
|
for (const content of response.responses) {
|
|
335
|
+
reportNeverBody(program, operation, content.body, `${key} response`);
|
|
331
336
|
const body = presentBody(content.body);
|
|
332
337
|
if (entry.body === undefined && body?.bodyKind === "single") {
|
|
333
338
|
entry.body = body.type;
|
|
339
|
+
entry.ignoresMetadata = ignoresMetadata(body);
|
|
334
340
|
}
|
|
335
341
|
/**
|
|
336
342
|
* **An `Http.File` response's body is its contents, served as bytes.** The same reading
|
|
@@ -360,11 +366,12 @@ function resolveResponses(program, operation) {
|
|
|
360
366
|
return {
|
|
361
367
|
status: entry.status,
|
|
362
368
|
body: entry.body,
|
|
369
|
+
ignoresMetadata: entry.ignoresMetadata,
|
|
363
370
|
contentTypes,
|
|
364
371
|
headers: entry.headers,
|
|
365
372
|
binary: entry.file ||
|
|
366
373
|
(entry.body !== undefined &&
|
|
367
|
-
isBinaryPart(entry.body) &&
|
|
374
|
+
isBinaryPart(program, entry.body) &&
|
|
368
375
|
isRawBinaryMediaType(contentTypes)),
|
|
369
376
|
streamed: entry.streamed,
|
|
370
377
|
textual: !entry.file && entry.body !== undefined && isStringType(entry.body),
|
|
@@ -376,13 +383,17 @@ function inPrecedenceOrder(responses) {
|
|
|
376
383
|
return responses.toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status) ||
|
|
377
384
|
statusOrderOf(a.status) - statusOrderOf(b.status));
|
|
378
385
|
}
|
|
386
|
+
/** A response header's contract type: a header, as a parameter is - see `withParameterPosition`. */
|
|
387
|
+
function headerTypeOf(program, property) {
|
|
388
|
+
return withParameterPosition(true, () => propertyTypeToTs(program, property));
|
|
389
|
+
}
|
|
379
390
|
/**
|
|
380
391
|
* Every response the operation declares, rendered as Zod, in OpenAPI's precedence order.
|
|
381
392
|
*
|
|
382
393
|
* Every header is rendered before any body, and bodies in precedence order, which is the order this
|
|
383
394
|
* has always declared components in: the emitted files do not move because the resolution moved.
|
|
384
395
|
*
|
|
385
|
-
* Call inside `withVisibility(
|
|
396
|
+
* Call inside `withVisibility(Visibility.Read, ...)`: a response is read at `Read`, and
|
|
386
397
|
* resolving it under a request visibility declares components under the wrong suffix.
|
|
387
398
|
*/
|
|
388
399
|
function responsesOf(program, operation, registry) {
|
|
@@ -396,7 +407,7 @@ function responsesOf(program, operation, registry) {
|
|
|
396
407
|
* alone published `string`, so a handler satisfying its own result type was refused by its
|
|
397
408
|
* own header check and the arm could not be returned without a cast.
|
|
398
409
|
*/
|
|
399
|
-
type:
|
|
410
|
+
type: headerTypeOf(program, property),
|
|
400
411
|
/**
|
|
401
412
|
* **The Zod expression, which used to be discarded here.** Every REQUEST parameter goes
|
|
402
413
|
* through `expressionForProperty` a few hundred lines below; a response header went
|
|
@@ -414,7 +425,7 @@ function responsesOf(program, operation, registry) {
|
|
|
414
425
|
? undefined
|
|
415
426
|
: response.binary
|
|
416
427
|
? "z.unknown()"
|
|
417
|
-
: registry.expressionFor(response.body),
|
|
428
|
+
: withMetadataAnnotationsIgnored(response.ignoresMetadata, () => registry.expressionFor(response.body, true)),
|
|
418
429
|
contentTypes: response.contentTypes,
|
|
419
430
|
headers,
|
|
420
431
|
binary: response.binary,
|
|
@@ -607,6 +618,54 @@ export function errorBodyOf(program, service) {
|
|
|
607
618
|
function bodyTypeOf(type) {
|
|
608
619
|
return isVoidType(type) || isNeverType(type) ? undefined : type;
|
|
609
620
|
}
|
|
621
|
+
/**
|
|
622
|
+
* **A body declared `never` is refused**, as `never-body`, once per declaration.
|
|
623
|
+
*
|
|
624
|
+
* `never` has no values, so the body cannot exist - and `@typespec/openapi3`, whose body paths test
|
|
625
|
+
* only for `void`, publishes it as `content: { "application/json": { schema: {} } }`: any value. A
|
|
626
|
+
* validator can agree with that or with the spec, not both, so the declaration is refused the way
|
|
627
|
+
* `undeclared-discriminator` refuses a discriminator the document cannot state. `void` says "no
|
|
628
|
+
* body", and openapi3 publishes it as none.
|
|
629
|
+
*/
|
|
630
|
+
/**
|
|
631
|
+
* **A request holding a required read-only property, where openapi3 publishes 3.0 and a later
|
|
632
|
+
* version** - see `ambiguous-read-only`. The 3.0 document lets the request leave it out and the later
|
|
633
|
+
* one does not, so no request validator agrees with both.
|
|
634
|
+
*/
|
|
635
|
+
function reportAmbiguousReadOnly(program, type, requested) {
|
|
636
|
+
if (!publishesOpenApi30AndLater(program))
|
|
637
|
+
return;
|
|
638
|
+
const property = requiredReadOnlyIn(program, type, rootVisibilityOf(program, type, requested), new Set());
|
|
639
|
+
if (property === undefined || !firstReportOf(program, "ambiguous-read-only", type))
|
|
640
|
+
return;
|
|
641
|
+
reportDiagnostic(program, {
|
|
642
|
+
code: "ambiguous-read-only",
|
|
643
|
+
target: type,
|
|
644
|
+
format: {
|
|
645
|
+
type: getTypeName(type),
|
|
646
|
+
property: property.model === undefined
|
|
647
|
+
? property.name
|
|
648
|
+
: `${getTypeName(property.model)}.${property.name}`,
|
|
649
|
+
later: openApiVersionsOf(program)
|
|
650
|
+
.filter((version) => !version.startsWith("3.0."))
|
|
651
|
+
.map((version) => `OpenAPI ${version}`)
|
|
652
|
+
.join(" and "),
|
|
653
|
+
view: `${getVisibilitySuffix(requested, Visibility.Read)}<${getTypeName(property.model ?? type)}>`,
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
function reportNeverBody(program, operation, body, position) {
|
|
658
|
+
if (body?.bodyKind !== "single" || !isNeverType(body.type))
|
|
659
|
+
return;
|
|
660
|
+
const target = body.property ?? operation.operation;
|
|
661
|
+
if (!firstReportOf(program, "never-body", target))
|
|
662
|
+
return;
|
|
663
|
+
reportDiagnostic(program, {
|
|
664
|
+
code: "never-body",
|
|
665
|
+
target,
|
|
666
|
+
format: { operationId: operationIdOf(program, operation.operation), position },
|
|
667
|
+
});
|
|
668
|
+
}
|
|
610
669
|
/**
|
|
611
670
|
* A declared body as the wire carries it: `undefined` where it is `void` or `never`, so no media
|
|
612
671
|
* type is listed for it either.
|
|
@@ -616,9 +675,8 @@ function bodyTypeOf(type) {
|
|
|
616
675
|
* the media types off it, so a 409 with no body was published as `contentTypes:
|
|
617
676
|
* ["application/json"]` and `schema: undefined`, which a consumer reads as a body it cannot validate,
|
|
618
677
|
* 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.
|
|
678
|
+
* `content` for `void`. A `never` body is refused, as `never-body`, so no artefact states a body the
|
|
679
|
+
* document publishes as any value; see {@link reportNeverBody}.
|
|
622
680
|
*/
|
|
623
681
|
function presentBody(body) {
|
|
624
682
|
return body?.bodyKind === "single" && bodyTypeOf(body.type) === undefined ? undefined : body;
|
|
@@ -627,6 +685,19 @@ function presentBody(body) {
|
|
|
627
685
|
function requestBodyParameterOf(operation) {
|
|
628
686
|
return presentBody(operation.parameters.body);
|
|
629
687
|
}
|
|
688
|
+
/**
|
|
689
|
+
* **The operation's parameters as the document publishes them: every one but a `never`.** openapi3
|
|
690
|
+
* leaves a parameter of type `never` out of the operation (`getParameterOrRef`), required or not and
|
|
691
|
+
* wherever it is, so `?a=1` beside `@query a?: never` is an extra parameter the document accepts.
|
|
692
|
+
* Read whole, each was validated as `z.never()`, which refused it, was typed `a?: never` in the
|
|
693
|
+
* handler's input, and was reported as `unsupported-type` at `<unknown location>:1:1` under a remedy
|
|
694
|
+
* saying a `never` could not get there. A `never` body property is dropped the same way - see
|
|
695
|
+
* `publishedPropertiesOf`. A `never` sharing its wire name with another parameter leaves the name
|
|
696
|
+
* to that one, which is the one the document publishes.
|
|
697
|
+
*/
|
|
698
|
+
function publishedParametersOf(operation) {
|
|
699
|
+
return operation.parameters.parameters.filter((parameter) => !isNeverType(parameter.param.type));
|
|
700
|
+
}
|
|
630
701
|
/**
|
|
631
702
|
* The media types this operation's success response can be served as.
|
|
632
703
|
*
|
|
@@ -661,18 +732,21 @@ function responseContentTypesOf(operation, status) {
|
|
|
661
732
|
* expanded reads this type, so none of them can disagree with the schema it feeds.
|
|
662
733
|
*/
|
|
663
734
|
function encodedParameterType(program, property) {
|
|
664
|
-
|
|
735
|
+
const type = encodedTypeOf(program, property) ?? property.type;
|
|
736
|
+
/**
|
|
737
|
+
* **A parameter cannot carry `null` on the wire, so `T | null` arrives as a `T`.** Read as the
|
|
738
|
+
* union, `?ids=1,2` for `ids?: int32[] | null` was never split or decoded, and a form-exploded
|
|
739
|
+
* `filter?: Filter | null` was never gathered, so its keys were stripped.
|
|
740
|
+
*/
|
|
741
|
+
return nullableInnerOf(type) ?? type;
|
|
665
742
|
}
|
|
666
743
|
/**
|
|
667
|
-
*
|
|
744
|
+
* An ARRAY parameter's element type, or `undefined` where it is not such an array.
|
|
668
745
|
*
|
|
669
|
-
* `?ids=1,2,3` splits into strings, so each element
|
|
746
|
+
* `?ids=1,2,3` splits into strings, so each element is read from text as a lone scalar is.
|
|
670
747
|
*/
|
|
671
|
-
function
|
|
672
|
-
|
|
673
|
-
return undefined;
|
|
674
|
-
const element = type.indexer?.value;
|
|
675
|
-
return element === undefined ? undefined : wireKindOf(program, element);
|
|
748
|
+
function elementOf(type) {
|
|
749
|
+
return isArrayType(type) && type.kind === "Model" ? type.indexer?.value : undefined;
|
|
676
750
|
}
|
|
677
751
|
/** Whether a type is an array - TypeSpec models one as a `Model` carrying an integer indexer. */
|
|
678
752
|
function isArrayType(type) {
|
|
@@ -708,25 +782,42 @@ function isArrayType(type) {
|
|
|
708
782
|
* {@link multipartSchemaOf} part for part, including `multi` as an array, `optional`, and a binary
|
|
709
783
|
* part as `unknown`, is what makes the two agree by construction rather than by coincidence.
|
|
710
784
|
*/
|
|
711
|
-
function multipartTsOf(body, registry) {
|
|
785
|
+
function multipartTsOf(program, body, registry) {
|
|
712
786
|
const entries = body.parts.map((part) => {
|
|
713
787
|
const type = part.body?.type;
|
|
714
788
|
const inner = type === undefined
|
|
715
789
|
? "unknown"
|
|
716
|
-
: isFilePart(type)
|
|
790
|
+
: isFilePart(program, type)
|
|
717
791
|
? MULTIPART_FILE
|
|
718
|
-
: isBinaryPart(type)
|
|
792
|
+
: isBinaryPart(program, type)
|
|
719
793
|
? "unknown"
|
|
720
|
-
: registry.expressionFor(type);
|
|
794
|
+
: withMetadataAnnotationsIgnored(ignoresMetadata(part.body), () => withMultipartBinary(holdsRawBytes(program, type), () => registry.expressionFor(type, true)));
|
|
721
795
|
const value = part.multi === true ? `(${inner})[]` : inner;
|
|
722
796
|
const optional = part.optional === true ? "?" : "";
|
|
723
797
|
return `\t${objectKey(part.name ?? "")}${optional}: ${value};`;
|
|
724
798
|
});
|
|
725
|
-
|
|
799
|
+
const shape = entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
800
|
+
// Declared under the component's name, as its validator is - see `multipartSchemaOf`.
|
|
801
|
+
const name = multipartNameOf(body);
|
|
802
|
+
return name === undefined || body.type === undefined
|
|
803
|
+
? shape
|
|
804
|
+
: registry.declareNamed(name, () => shape, body.type);
|
|
805
|
+
}
|
|
806
|
+
/** The component openapi3 publishes a multipart body as: its model's name, where it has one. */
|
|
807
|
+
function multipartNameOf(body) {
|
|
808
|
+
const named = body.type;
|
|
809
|
+
return named !== undefined && named.kind === "Model" && named.name !== ""
|
|
810
|
+
? named.name
|
|
811
|
+
: undefined;
|
|
726
812
|
}
|
|
727
813
|
function multipartSchemaOf(program, body, registry) {
|
|
728
814
|
const entries = body.parts.map((part) => {
|
|
729
815
|
const type = part.body?.type;
|
|
816
|
+
// `.exactOptional()` for the same reason as a model property: `parseBody` yields present keys
|
|
817
|
+
// only, so an absent part is an absent key rather than an explicit `undefined`.
|
|
818
|
+
const entry = (value) => `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.exactOptional()` : value},`;
|
|
819
|
+
if (isJsonListPart(program, part))
|
|
820
|
+
return entry(jsonListDecoded(jsonListOf(registry, part)));
|
|
730
821
|
/**
|
|
731
822
|
* **A form carries text, so a part that means a number or a boolean is decoded first**, by the
|
|
732
823
|
* same rule {@link wireDecoded} applies to path, query and header values: `"0.5"` becomes
|
|
@@ -737,18 +828,31 @@ function multipartSchemaOf(program, body, registry) {
|
|
|
737
828
|
*/
|
|
738
829
|
const inner = type === undefined
|
|
739
830
|
? "z.unknown()"
|
|
740
|
-
: isFilePart(type)
|
|
831
|
+
: isFilePart(program, type)
|
|
741
832
|
? `z.unknown().refine(${MULTIPART_FILE_REFINEMENT})`
|
|
742
|
-
: isBinaryPart(type)
|
|
833
|
+
: isBinaryPart(program, type)
|
|
743
834
|
? "z.unknown()"
|
|
744
|
-
:
|
|
745
|
-
?
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
835
|
+
: holdsRawBytes(program, type)
|
|
836
|
+
? /**
|
|
837
|
+
* Any value but what its other members refuse, and no text to read - unless the
|
|
838
|
+
* document publishes the part as JSON alone, `HttpPart<bytes | null>`, where its
|
|
839
|
+
* text is JSON and `x` is refused.
|
|
840
|
+
*/
|
|
841
|
+
withMultipartBinary(true, () => isJsonPart(part)
|
|
842
|
+
? jsonTextDecoded(registry.expressionFor(type, true))
|
|
843
|
+
: registry.expressionFor(type, true))
|
|
844
|
+
: withMetadataAnnotationsIgnored(ignoresMetadata(part.body), () => isJsonPart(part)
|
|
845
|
+
? jsonTextDecoded(registry.expressionFor(type, true))
|
|
846
|
+
: isJsonOrTextPart(part)
|
|
847
|
+
? jsonOrTextDecoded(registry.expressionFor(type, true), textPartDecoded(program, registry, type))
|
|
848
|
+
: textPartDecoded(program, registry, type));
|
|
849
|
+
if (part.multi !== true)
|
|
850
|
+
return entry(inner);
|
|
851
|
+
// `multi` is `HttpPart<T>[]` - the same part repeated, which the document publishes as a list,
|
|
852
|
+
// and one whose text may be JSON is that list in JSON as well - see `mayBeJsonList`.
|
|
853
|
+
return entry(isJsonOrTextPart(part)
|
|
854
|
+
? jsonOrTextDecoded(jsonListOf(registry, part), `z.array(${inner})`)
|
|
855
|
+
: `z.array(${inner})`);
|
|
752
856
|
});
|
|
753
857
|
const shape = entries.length === 0 ? "z.object({})" : `z.object({\n${entries.join("\n")}\n})`;
|
|
754
858
|
/**
|
|
@@ -756,9 +860,124 @@ function multipartSchemaOf(program, body, registry) {
|
|
|
756
860
|
* for it. Emitted inline the schema is correct and the component still has no validator, which the
|
|
757
861
|
* differential reports - rightly - as a contract the runtime cannot check.
|
|
758
862
|
*/
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
863
|
+
const name = multipartNameOf(body);
|
|
864
|
+
return name === undefined || body.type === undefined
|
|
865
|
+
? shape
|
|
866
|
+
: registry.declareNamed(name, () => shape, body.type);
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* **What a text part's value is read as: never `null`, which text does not spell**, so `T | null`
|
|
870
|
+
* is read as a `T`, as a parameter's is - see `encodedParameterType`. Read as the union, `h=1&h=2`
|
|
871
|
+
* for `int32[] | null` under `text/plain` reached `z.array(z.int32())` as `["1", "2"]` unread, and
|
|
872
|
+
* was refused where the document reads `[1, 2]`.
|
|
873
|
+
*/
|
|
874
|
+
function asText(type) {
|
|
875
|
+
return nullableInnerOf(type) ?? type;
|
|
876
|
+
}
|
|
877
|
+
/** The parts read from text - see {@link EmittedRoute.textParts}. */
|
|
878
|
+
function textPartsOf(program, body) {
|
|
879
|
+
return body.parts.flatMap((part) => {
|
|
880
|
+
const type = part.body?.type;
|
|
881
|
+
if (part.name === undefined || type === undefined)
|
|
882
|
+
return [];
|
|
883
|
+
if (isFilePart(program, type) || isBinaryPart(program, type) || holdsRawBytes(program, type)) {
|
|
884
|
+
return [];
|
|
885
|
+
}
|
|
886
|
+
return [{ name: part.name, json: isJsonPart(part) }];
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* **A text part of a union holding a list is one value, or several sent as as many parts**, read as a
|
|
891
|
+
* URL-encoded form's field is - see `readerOf`: `int32 | int32[]` under `text/plain` is `5` sent
|
|
892
|
+
* once and `[1, 2]` sent as `1` and `2`, and `int32 | string[]` reads a lone `a` as the list `["a"]`.
|
|
893
|
+
* Read against the whole union, as one text is, the parts reached it unread and were refused where
|
|
894
|
+
* the document reads `[1, 2]`. Rendered as a part's schema is, where a parameter's refuses a tuple.
|
|
895
|
+
*/
|
|
896
|
+
function textPartDecoded(program, registry, type) {
|
|
897
|
+
const declared = registry.expressionFor(type, true);
|
|
898
|
+
const inner = asText(type);
|
|
899
|
+
const forms = inner.kind === "Union" ? wireFormsOf(inner) : [];
|
|
900
|
+
const lists = forms.filter((each) => elementOf(each) !== undefined);
|
|
901
|
+
if (lists.length === 0)
|
|
902
|
+
return wireDecoded(program, registry, declared, inner);
|
|
903
|
+
const each = lists.map((list) => atItem(program, list, () => registry.expressionFor(elementOf(list), true)));
|
|
904
|
+
const one = forms.some((form) => elementOf(form) === undefined) ? declared : undefined;
|
|
905
|
+
return `z.preprocess((raw) => wireReadAs(raw, ${eachReader(each, one, declared)}), ${declared})`;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* **A part the document publishes as a list under a media type that is not JSON alone is that many
|
|
909
|
+
* parts**, one per element, however the spec declared it: `HttpPart<{ @header contentType:
|
|
910
|
+
* "text/plain"; @body body: int32[] }>` is published exactly as `HttpPart<int32>[]` is, `{ type:
|
|
911
|
+
* array, items: int32 }` with `contentType: text/plain`, and text spells no list. Read as one part,
|
|
912
|
+
* a lone `5` reached `z.array` as `"5"` and was refused, where the same part declared repeated read
|
|
913
|
+
* it as `[5]`. A JSON part's text may spell the whole list as well - see {@link isJsonListPart}.
|
|
914
|
+
*/
|
|
915
|
+
function repeatsAsText(part) {
|
|
916
|
+
const type = part.body?.type;
|
|
917
|
+
return (part.multi !== true &&
|
|
918
|
+
type !== undefined &&
|
|
919
|
+
!isJsonPart(part) &&
|
|
920
|
+
isArrayType(nullableInnerOf(type) ?? type));
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* **A JSON part the document publishes as a list is one list or its elements, however it is
|
|
924
|
+
* declared.** `HttpPart<int32[]>` and `HttpPart<{ @header contentType: "application/json"; @body
|
|
925
|
+
* body: int32 }>[]` are published alike, `{ type: array, items: int32 }` with `contentType:
|
|
926
|
+
* application/json`, and OAS 3.1 does not say whether such a part is sent once, as the list's JSON,
|
|
927
|
+
* or once per element, each element's. So the document admits both: `[1,2]` is the list, and `5`,
|
|
928
|
+
* or `1` and `2` sent as two parts, its elements. Read as declared, the first refused `5` and the
|
|
929
|
+
* two parts, and the second `[1,2]`. A file or raw bytes is no JSON to read.
|
|
930
|
+
*/
|
|
931
|
+
function isJsonListPart(program, part) {
|
|
932
|
+
const type = part.body?.type;
|
|
933
|
+
return (type !== undefined &&
|
|
934
|
+
isJsonPart(part) &&
|
|
935
|
+
!isFilePart(program, type) &&
|
|
936
|
+
!isBinaryPart(program, type) &&
|
|
937
|
+
!holdsRawBytes(program, type) &&
|
|
938
|
+
(part.multi === true || isArrayType(nullableInnerOf(type) ?? type)));
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Whether one text of a part the document publishes as a list may be the whole list in JSON: a JSON
|
|
942
|
+
* list part, or one that is text or JSON, published as a list - declared repeated, or as a list under
|
|
943
|
+
* a media type that is not JSON alone. See {@link isJsonListPart} and {@link repeatsAsText}.
|
|
944
|
+
*/
|
|
945
|
+
function mayBeJsonList(program, part) {
|
|
946
|
+
return (isJsonListPart(program, part) ||
|
|
947
|
+
(isJsonOrTextPart(part) && (part.multi === true || repeatsAsText(part))));
|
|
948
|
+
}
|
|
949
|
+
/** The schema of the list a part is published as, read from JSON: a repeated part's, its elements'. */
|
|
950
|
+
function jsonListOf(registry, part) {
|
|
951
|
+
const declared = withMetadataAnnotationsIgnored(ignoresMetadata(part.body), () => registry.expressionFor(part.body.type, true));
|
|
952
|
+
return part.multi === true ? `z.array(${declared})` : declared;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* **A repeated part sent once is the one-element list the document describes**, and a form hands it
|
|
956
|
+
* over as the one value: `parseBody({ all: true })` makes a list only of a repeated key, so one file
|
|
957
|
+
* sent to a multi-file field reached `z.array` as a file and was refused. Boxed for the request
|
|
958
|
+
* only - the component stays the document's schema, where a lone value is no list - and only what a
|
|
959
|
+
* form delivers for one part, text or a file, so a value no form sends is judged as it is.
|
|
960
|
+
*
|
|
961
|
+
* A part repeats where the document says it does: declared `HttpPart<T>[]`, or published as a list
|
|
962
|
+
* under a text media type - see {@link repeatsAsText}. **One whose text may be JSON is boxed only
|
|
963
|
+
* where its text is not the JSON list the part accepts** - see {@link mayBeJsonList}: `[1,2]` is
|
|
964
|
+
* that list, and `5` the one element.
|
|
965
|
+
*/
|
|
966
|
+
function multipartRequestOf(program, registry, body, schema) {
|
|
967
|
+
const named = body.parts.filter((part) => part.name !== undefined);
|
|
968
|
+
const jsonOrText = named.filter((part) => mayBeJsonList(program, part));
|
|
969
|
+
const boxed = named.filter((part) => !mayBeJsonList(program, part) && (part.multi === true || repeatsAsText(part)));
|
|
970
|
+
if (boxed.length === 0 && jsonOrText.length === 0)
|
|
971
|
+
return schema;
|
|
972
|
+
const one = `typeof value === "string" || (value !== null && typeof value === "object" && !Array.isArray(value) && "arrayBuffer" in value)`;
|
|
973
|
+
const always = boxed.length === 0
|
|
974
|
+
? ""
|
|
975
|
+
: ` for (const key of ${literal(boxed.map((part) => part.name))}) { const value = boxed[key]; if (${one}) boxed[key] = [value]; }`;
|
|
976
|
+
const lists = jsonOrText.map((part) => `[${literal(part.name)}, ${jsonListOf(registry, part)}]`);
|
|
977
|
+
const unlessJson = lists.length === 0
|
|
978
|
+
? ""
|
|
979
|
+
: ` 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]; }`;
|
|
980
|
+
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
981
|
}
|
|
763
982
|
/**
|
|
764
983
|
* The shape a multipart file part actually arrives as, spelled structurally.
|
|
@@ -773,7 +992,7 @@ function multipartSchemaOf(program, body, registry) {
|
|
|
773
992
|
* of the wire assertion, and a mapped type turns a method into a function-typed property, so
|
|
774
993
|
* spelling it this way on both sides is what keeps the two identical.
|
|
775
994
|
*/
|
|
776
|
-
const MULTIPART_FILE = "{ name: string; type: string; arrayBuffer: () => Promise<ArrayBuffer> }";
|
|
995
|
+
const MULTIPART_FILE = "{ name: string; type: string; arrayBuffer: () => globalThis.Promise<ArrayBuffer> }";
|
|
777
996
|
/**
|
|
778
997
|
* The check that makes {@link MULTIPART_FILE} established rather than asserted.
|
|
779
998
|
*
|
|
@@ -805,14 +1024,9 @@ const MULTIPART_FILE_REFINEMENT = `(value): value is ${MULTIPART_FILE} => typeof
|
|
|
805
1024
|
* string. Widening this to cover `bytes` would refuse payloads that pass today, on an inference the
|
|
806
1025
|
* spec never made.
|
|
807
1026
|
*/
|
|
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;
|
|
1027
|
+
function isFilePart(program, type) {
|
|
1028
|
+
// `Http.File` as `@typespec/http` decides it - a spec's own `model File` is a JSON part.
|
|
1029
|
+
return type.kind === "Model" && isOrExtendsHttpFile(program, type);
|
|
816
1030
|
}
|
|
817
1031
|
/** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
|
|
818
1032
|
/**
|
|
@@ -833,10 +1047,413 @@ function isJsonPart(part) {
|
|
|
833
1047
|
* string passed on, so a part whose schema admits a string still refuses malformed JSON. A value that
|
|
834
1048
|
* is not text is left alone for the schema to judge.
|
|
835
1049
|
*/
|
|
1050
|
+
/**
|
|
1051
|
+
* **A part whose type mixes text with JSON** - `HttpPart<int32 | Address>` - which the document
|
|
1052
|
+
* publishes with `contentType: "text/plain, application/json"`: the client chooses per request, and
|
|
1053
|
+
* the form hands over the text either way. JSON is read where the text is JSON the schema accepts, and
|
|
1054
|
+
* the text is read as text otherwise; read as text alone, `{"city":"x"}` reached `Address` as a
|
|
1055
|
+
* string and was refused. Any JSON value, not an object alone: `"all"`, quoted, is the JSON string
|
|
1056
|
+
* `HttpPart<Nums | "all">` accepts, and ` 1` the number.
|
|
1057
|
+
*/
|
|
1058
|
+
function isJsonOrTextPart(part) {
|
|
1059
|
+
const contentTypes = part.body?.contentTypes ?? [];
|
|
1060
|
+
const json = (contentType) => /^application\/(?:[\w.+-]+\+)?json$/i.test(contentType);
|
|
1061
|
+
return contentTypes.some(json) && !contentTypes.every(json);
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* **A body sent as a URL-encoded form, read from the text it carries** - see
|
|
1065
|
+
* {@link EmittedRoute.formRequestSchema}. A form holds text, `n=5&b=true`, and the document's schema
|
|
1066
|
+
* describes the values it spells, so each field is read against its own schema as a parameter's is,
|
|
1067
|
+
* and a key sent more than once is the list it stands for. `only` where the form is the one media
|
|
1068
|
+
* type the body is declared under. `undefined` where no form is, or nothing in the body reads.
|
|
1069
|
+
*
|
|
1070
|
+
* **Each object the body can be is one reading of the form, and the body's whole schema judges
|
|
1071
|
+
* every reading**, as the document does: it accepts a form where some reading of its text is a value
|
|
1072
|
+
* of the schema it publishes. Read member by member against each member's own schema, a union was
|
|
1073
|
+
* rebuilt here beside the one the document publishes, and only a plain union of models ever was:
|
|
1074
|
+
* `DU` with no envelope, `DU | null`, `{ DA, string }`, `{ PU, DB }` and `DA | DB | DU` were
|
|
1075
|
+
* validated as JSON, so `kind=a&n=5` was refused, "expected number, received string", where the
|
|
1076
|
+
* document reads `{ kind: "a", n: 5 }`. A `@oneOf` judged member by member counted differently too:
|
|
1077
|
+
* `x=5` against `PA { x?: int32 }` and `PB { x?: string }` matched both, each by its own reading, and
|
|
1078
|
+
* was refused where each reading the document takes matches exactly one.
|
|
1079
|
+
*/
|
|
1080
|
+
function formBodyOf(program, registry, operation, body, type, declared) {
|
|
1081
|
+
const contentTypes = body?.contentTypes ?? [];
|
|
1082
|
+
const form = (contentType) => mediaTypeEssenceOf(contentType) === "application/x-www-form-urlencoded";
|
|
1083
|
+
if (!contentTypes.some(form))
|
|
1084
|
+
return undefined;
|
|
1085
|
+
const only = contentTypes.every(form);
|
|
1086
|
+
const readings = formReadingsOf(program, registry, type);
|
|
1087
|
+
if (!readings.some(readsText))
|
|
1088
|
+
return undefined;
|
|
1089
|
+
const ambiguous = ambiguousFormKeyOf(program, type);
|
|
1090
|
+
if (ambiguous !== undefined) {
|
|
1091
|
+
reportDiagnostic(program, {
|
|
1092
|
+
code: "ambiguous-form-reading",
|
|
1093
|
+
target: ambiguous.target,
|
|
1094
|
+
format: {
|
|
1095
|
+
operation: operation.operation.name,
|
|
1096
|
+
union: getTypeName(ambiguous.union),
|
|
1097
|
+
member: getTypeName(ambiguous.member),
|
|
1098
|
+
other: getTypeName(ambiguous.other),
|
|
1099
|
+
key: ambiguous.key,
|
|
1100
|
+
first: ambiguous.first,
|
|
1101
|
+
second: ambiguous.second,
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
return { schema: UNREPRESENTABLE, only };
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* **A reading that reads nothing is the form as sent, and stays one**: `kind=c&n=5` is a `DC`
|
|
1108
|
+
* whose `n` is a string, and read only as `DA` reads it - `n` the number `5` - it was refused by
|
|
1109
|
+
* `DC | DA`, which accepts it as text.
|
|
1110
|
+
*/
|
|
1111
|
+
const read = [
|
|
1112
|
+
...new Set(readings.map((readers) => readsText(readers) ? `z.preprocess(${wireReadObjectCall(readers)}, ${declared})` : declared)),
|
|
1113
|
+
];
|
|
1114
|
+
return {
|
|
1115
|
+
schema: read.length === 1 ? read[0] : `z.union([${read.join(", ")}])`,
|
|
1116
|
+
only,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* **Where a `@oneOf` sent as a URL-encoded form depends on a reading no validator can choose.** The
|
|
1121
|
+
* document accepts a form where some reading of its text matches exactly one member: each key sent
|
|
1122
|
+
* once is a value its text spells - a number, a boolean, the text itself - or the one-element list of
|
|
1123
|
+
* one. A member reads each key as the first of those its schema accepts, and the whole union judges
|
|
1124
|
+
* that reading - see `formBodyOf`. That agrees with the document unless a member reads a key one way
|
|
1125
|
+
* that another member accepts, while accepting another reading the other refuses, and the other's
|
|
1126
|
+
* own reading of the form leaves a rival matching too: then which reading matches one member alone
|
|
1127
|
+
* depends on every other key and member. `x=5` against `M { x?: int32 | string }` and
|
|
1128
|
+
* `N { x?: int32 }` is `{ x: "5" }`, `M` alone, but read as `5` it matched both and was refused;
|
|
1129
|
+
* open, `x=all` against `A { x?: int32 | "all" }` and `B { y?: boolean }` is `{ x: ["all"] }`, `B`
|
|
1130
|
+
* alone. Finding that reading means trying every combination of every key's readings, exponential in
|
|
1131
|
+
* the keys, and a record's keys are the sender's to choose - so the body is refused instead, as
|
|
1132
|
+
* `ambiguous-form-reading`. Where no member tells another's readings apart, or the other's reading
|
|
1133
|
+
* rules every rival out by a key the rival requires - a `kind` literal - some member's reading is
|
|
1134
|
+
* the document's, and the body is validated. See {@link tellsApart}.
|
|
1135
|
+
*
|
|
1136
|
+
* A discriminated union is no such `oneOf`: its members' discriminators differ, so no reading
|
|
1137
|
+
* matches two of them. Nor is a union a form cannot be more than one object of.
|
|
1138
|
+
*/
|
|
1139
|
+
function ambiguousFormKeyOf(program, type) {
|
|
1140
|
+
const visit = (each, via) => {
|
|
1141
|
+
if (each.kind !== "Union" || via.has(each))
|
|
1142
|
+
return undefined;
|
|
1143
|
+
const members = [...each.variants.values()].map((variant) => variant.type);
|
|
1144
|
+
if (isExclusive(program, each) && getDiscriminatedUnion(program, each)[0] === undefined) {
|
|
1145
|
+
const groups = members
|
|
1146
|
+
.map((member) => modelsOf(member, new Set()).map((model) => keyReadingsOf(program, model)))
|
|
1147
|
+
.filter((group) => group.length > 0);
|
|
1148
|
+
for (const [i, group] of groups.entries()) {
|
|
1149
|
+
for (const [j, other] of groups.entries()) {
|
|
1150
|
+
if (i === j)
|
|
1151
|
+
continue;
|
|
1152
|
+
const rivals = groups.filter((_, k) => k !== j).flat();
|
|
1153
|
+
for (const first of group) {
|
|
1154
|
+
for (const second of other) {
|
|
1155
|
+
const found = tellsApart(first, second, rivals);
|
|
1156
|
+
if (found !== undefined)
|
|
1157
|
+
return { union: each, ...found };
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
for (const member of members) {
|
|
1164
|
+
const found = visit(member, new Set([...via, each]));
|
|
1165
|
+
if (found !== undefined)
|
|
1166
|
+
return found;
|
|
1167
|
+
}
|
|
1168
|
+
return undefined;
|
|
1169
|
+
};
|
|
1170
|
+
return visit(type, new Set());
|
|
1171
|
+
}
|
|
1172
|
+
/** The objects a member of a union is: itself, or each a union of them reaches. */
|
|
1173
|
+
function modelsOf(type, seen) {
|
|
1174
|
+
if (type.kind === "Model")
|
|
1175
|
+
return isArrayType(type) ? [] : [type];
|
|
1176
|
+
if (type.kind !== "Union" || seen.has(type))
|
|
1177
|
+
return [];
|
|
1178
|
+
seen.add(type);
|
|
1179
|
+
return [...type.variants.values()].flatMap((variant) => modelsOf(variant.type, seen));
|
|
1180
|
+
}
|
|
1181
|
+
const SPELLED = 1;
|
|
1182
|
+
const ITSELF = 2;
|
|
1183
|
+
const SPELLED_LIST = 4;
|
|
1184
|
+
const ITSELF_LIST = 8;
|
|
1185
|
+
const NO_READING = { numeric: 0, boolean: 0, other: 0 };
|
|
1186
|
+
const EVERY_READING = { numeric: 15, boolean: 15, other: ITSELF | ITSELF_LIST };
|
|
1187
|
+
/** Each class of text, and the value a text of it spells. */
|
|
1188
|
+
const TEXT_CLASSES = [
|
|
1189
|
+
["numeric", "number"],
|
|
1190
|
+
["boolean", "boolean"],
|
|
1191
|
+
["other", ""],
|
|
1192
|
+
];
|
|
1193
|
+
/** A model's reading of a key: its own, or every undeclared key's. */
|
|
1194
|
+
function keyOf(readings, key) {
|
|
1195
|
+
return (key === undefined ? undefined : readings.keys.get(key)) ?? readings.rest;
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* The reading a model's reader takes of a text of one class: the first its schema accepts - see
|
|
1199
|
+
* `wireRead` - or the text as sent where it reads none. `0` where the reader takes one the schema
|
|
1200
|
+
* refuses, which nothing here relies on.
|
|
1201
|
+
*/
|
|
1202
|
+
function choiceOf(reading, kind) {
|
|
1203
|
+
return reading.read ? lowestBit(reading.readings[kind]) : ITSELF;
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* **A key `member` reads one way where `other` accepts that reading and refuses another `member`
|
|
1207
|
+
* accepts** - and `other`'s own reading of the form no rival of it accepts, so the validator reads
|
|
1208
|
+
* the form only as the two do - see {@link ambiguousFormKeyOf}. Where every rival requires a key
|
|
1209
|
+
* whose reading by `other` it refuses, `other`'s reading matches `other` alone, and the form is
|
|
1210
|
+
* accepted as the document accepts it.
|
|
1211
|
+
*/
|
|
1212
|
+
function tellsApart(member, other, rivals) {
|
|
1213
|
+
const rescued = rivals.every((rival) => [...rival.keys].some(([key, reading]) => reading.required &&
|
|
1214
|
+
TEXT_CLASSES.every(([kind]) => {
|
|
1215
|
+
const choice = choiceOf(keyOf(other, key), kind);
|
|
1216
|
+
return (reading.readings[kind] === 0 ||
|
|
1217
|
+
(choice !== 0 && (reading.readings[kind] & choice) === 0));
|
|
1218
|
+
})));
|
|
1219
|
+
if (rescued)
|
|
1220
|
+
return undefined;
|
|
1221
|
+
const keys = [...new Set([...member.keys.keys(), ...other.keys.keys()])];
|
|
1222
|
+
for (const key of [...keys, undefined]) {
|
|
1223
|
+
const mine = keyOf(member, key);
|
|
1224
|
+
const yours = keyOf(other, key);
|
|
1225
|
+
for (const [kind, spelled] of TEXT_CLASSES) {
|
|
1226
|
+
const accepted = mine.readings[kind];
|
|
1227
|
+
const choice = choiceOf(mine, kind) & accepted;
|
|
1228
|
+
const refused = accepted & ~yours.readings[kind];
|
|
1229
|
+
if (choice === 0 || (yours.readings[kind] & choice) === 0 || refused === 0)
|
|
1230
|
+
continue;
|
|
1231
|
+
return {
|
|
1232
|
+
member: member.model,
|
|
1233
|
+
other: other.model,
|
|
1234
|
+
target: mine.property ?? yours.property ?? member.model,
|
|
1235
|
+
key: key === undefined ? "a key neither declares" : `'${key}'`,
|
|
1236
|
+
first: describeReading(choice, spelled),
|
|
1237
|
+
second: describeReading(lowestBit(refused), spelled),
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return undefined;
|
|
1242
|
+
}
|
|
1243
|
+
function lowestBit(mask) {
|
|
1244
|
+
return mask & -mask;
|
|
1245
|
+
}
|
|
1246
|
+
function describeReading(bit, spelled) {
|
|
1247
|
+
switch (bit) {
|
|
1248
|
+
case SPELLED:
|
|
1249
|
+
return `the ${spelled} it spells`;
|
|
1250
|
+
case ITSELF:
|
|
1251
|
+
return "itself";
|
|
1252
|
+
case SPELLED_LIST:
|
|
1253
|
+
return `the list of the ${spelled} it spells`;
|
|
1254
|
+
default:
|
|
1255
|
+
return "the list of itself";
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
/** Each key a model declares, read as its validator holds it, and every other key's reading. */
|
|
1259
|
+
function keyReadingsOf(program, model) {
|
|
1260
|
+
return withVisibility(renderedVisibilityOf(program, model, currentVisibility()), () => {
|
|
1261
|
+
const object = objectOfModel(program, model);
|
|
1262
|
+
const keys = new Map();
|
|
1263
|
+
const none = { readings: NO_READING, read: false, required: false };
|
|
1264
|
+
if (object.kind !== "object")
|
|
1265
|
+
return { model, keys, rest: none };
|
|
1266
|
+
for (const member of object.members) {
|
|
1267
|
+
if (member.property === undefined)
|
|
1268
|
+
continue;
|
|
1269
|
+
keys.set(member.key, {
|
|
1270
|
+
readings: formReadingsOfType(program, encodedParameterType(program, member.property)),
|
|
1271
|
+
read: true,
|
|
1272
|
+
required: member.property.optional !== true,
|
|
1273
|
+
property: member.property,
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
const rest = object.rest;
|
|
1277
|
+
return {
|
|
1278
|
+
model,
|
|
1279
|
+
keys,
|
|
1280
|
+
rest: rest === "sealed"
|
|
1281
|
+
? none
|
|
1282
|
+
: typeof rest === "string"
|
|
1283
|
+
? { readings: EVERY_READING, read: false, required: false }
|
|
1284
|
+
: rest.kind === "type"
|
|
1285
|
+
? { readings: formReadingsOfType(program, rest.type), read: true, required: false }
|
|
1286
|
+
: rest.kind === "never"
|
|
1287
|
+
? none
|
|
1288
|
+
: { readings: EVERY_READING, read: true, required: false },
|
|
1289
|
+
};
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* **The readings of a key's text a value of `type` accepts** - see {@link FormReadings}. A string
|
|
1294
|
+
* accepts any text as itself; a string literal its own text; a number or a boolean the value a text
|
|
1295
|
+
* spells; a list the one-element list of what its element accepts; a value of any type every
|
|
1296
|
+
* reading. A scalar that reads no number or boolean - a date, `bytes` - is text, as a string is.
|
|
1297
|
+
*/
|
|
1298
|
+
function formReadingsOfType(program, type) {
|
|
1299
|
+
let numeric = 0;
|
|
1300
|
+
let boolean = 0;
|
|
1301
|
+
let other = 0;
|
|
1302
|
+
const seen = new Set();
|
|
1303
|
+
// A literal's own text, in the class of text it is.
|
|
1304
|
+
const literalText = (value, itself) => {
|
|
1305
|
+
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value))
|
|
1306
|
+
numeric |= itself;
|
|
1307
|
+
else if (value === "true" || value === "false")
|
|
1308
|
+
boolean |= itself;
|
|
1309
|
+
else
|
|
1310
|
+
other |= itself;
|
|
1311
|
+
};
|
|
1312
|
+
const visit = (each, list) => {
|
|
1313
|
+
if (seen.has(each))
|
|
1314
|
+
return;
|
|
1315
|
+
seen.add(each);
|
|
1316
|
+
const [spelled, itself] = list ? [SPELLED_LIST, ITSELF_LIST] : [SPELLED, ITSELF];
|
|
1317
|
+
if (each.kind === "UnionVariant")
|
|
1318
|
+
return visit(each.type, list);
|
|
1319
|
+
if (each.kind === "Union") {
|
|
1320
|
+
for (const variant of each.variants.values())
|
|
1321
|
+
visit(variant.type, list);
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
const element = elementOf(each);
|
|
1325
|
+
if (element !== undefined) {
|
|
1326
|
+
// A list of lists is no text a key spells.
|
|
1327
|
+
if (!list)
|
|
1328
|
+
visit(element, true);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
if (isTypelessMember(program, each)) {
|
|
1332
|
+
numeric |= spelled | itself;
|
|
1333
|
+
boolean |= spelled | itself;
|
|
1334
|
+
other |= itself;
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
switch (each.kind) {
|
|
1338
|
+
case "String":
|
|
1339
|
+
return literalText(each.value, itself);
|
|
1340
|
+
case "Number":
|
|
1341
|
+
numeric |= spelled;
|
|
1342
|
+
return;
|
|
1343
|
+
case "Boolean":
|
|
1344
|
+
boolean |= spelled;
|
|
1345
|
+
return;
|
|
1346
|
+
case "Enum":
|
|
1347
|
+
for (const member of each.members.values())
|
|
1348
|
+
visit(member, list);
|
|
1349
|
+
return;
|
|
1350
|
+
case "EnumMember":
|
|
1351
|
+
if (typeof each.value === "number")
|
|
1352
|
+
numeric |= spelled;
|
|
1353
|
+
else
|
|
1354
|
+
literalText(each.value ?? each.name, itself);
|
|
1355
|
+
return;
|
|
1356
|
+
case "Scalar":
|
|
1357
|
+
if (readsFromText(program, each)) {
|
|
1358
|
+
if (isBooleanScalar(each))
|
|
1359
|
+
boolean |= spelled;
|
|
1360
|
+
else
|
|
1361
|
+
numeric |= spelled;
|
|
1362
|
+
}
|
|
1363
|
+
else {
|
|
1364
|
+
numeric |= itself;
|
|
1365
|
+
boolean |= itself;
|
|
1366
|
+
other |= itself;
|
|
1367
|
+
}
|
|
1368
|
+
return;
|
|
1369
|
+
default:
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
visit(type, false);
|
|
1374
|
+
return { numeric, boolean, other };
|
|
1375
|
+
}
|
|
1376
|
+
/** Whether a scalar is `boolean`, or declared on it. */
|
|
1377
|
+
function isBooleanScalar(scalar) {
|
|
1378
|
+
for (let each = scalar; each !== undefined; each = each.baseScalar) {
|
|
1379
|
+
if (each.name === "boolean")
|
|
1380
|
+
return true;
|
|
1381
|
+
}
|
|
1382
|
+
return false;
|
|
1383
|
+
}
|
|
1384
|
+
/** Whether readers read anything, or leave every value the text it is. */
|
|
1385
|
+
function readsText(readers) {
|
|
1386
|
+
return readers.values !== null || readers.fields !== null || readers.by !== undefined;
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* **Every object a URL-encoded form can stand for in a body of this type, as the readers of its
|
|
1390
|
+
* text**: a model's own, and each model a union reaches, a union nested in it included. A member that
|
|
1391
|
+
* is no object - `string` or `int32` beside a model, a list, `null` - is never what a form is, so it
|
|
1392
|
+
* adds no reading, and its schema refuses the object as the document's does. A form is never `null`.
|
|
1393
|
+
*/
|
|
1394
|
+
function formReadingsOf(program, registry, type) {
|
|
1395
|
+
if (type.kind === "Model") {
|
|
1396
|
+
return isArrayType(type)
|
|
1397
|
+
? []
|
|
1398
|
+
: [withParameterPosition(true, () => readersOf(program, registry, type, true))];
|
|
1399
|
+
}
|
|
1400
|
+
if (type.kind !== "Union")
|
|
1401
|
+
return [];
|
|
1402
|
+
const variants = [...type.variants.values()].map((variant) => variant.type);
|
|
1403
|
+
const [discriminated] = getDiscriminatedUnion(program, type);
|
|
1404
|
+
if (discriminated === undefined) {
|
|
1405
|
+
return variants.flatMap((variant) => formReadingsOf(program, registry, variant));
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* **A discriminated union is read as the variant its discriminator names**, as a discriminated
|
|
1409
|
+
* base is - see `readersOf` - so a key two variants declare differently is read as the one sent.
|
|
1410
|
+
* With no envelope the variant is the object. With one, the object is its envelope, `{ kind, value
|
|
1411
|
+
* }`, and `value` is read as the variant: `kind=i&value=5` is `{ kind: "i", value: 5 }` where the
|
|
1412
|
+
* variant is an `int32`, and `value=5` under `kind=l` the list `[5]`.
|
|
1413
|
+
*/
|
|
1414
|
+
const { envelope, discriminatorPropertyName, envelopePropertyName } = discriminated.options;
|
|
1415
|
+
const byVariant = [...discriminated.variants].map(([value, variant]) => {
|
|
1416
|
+
if (envelope === "none") {
|
|
1417
|
+
return [value, formReadingsOf(program, registry, variant)[0] ?? NO_READERS];
|
|
1418
|
+
}
|
|
1419
|
+
const reader = readerOf(program, registry, variant, undefined, true);
|
|
1420
|
+
return [
|
|
1421
|
+
value,
|
|
1422
|
+
reader === null ? NO_READERS : { values: null, fields: { [envelopePropertyName]: reader } },
|
|
1423
|
+
];
|
|
1424
|
+
});
|
|
1425
|
+
/**
|
|
1426
|
+
* A variant that is a discriminated base dispatches on a discriminator of its own, which one
|
|
1427
|
+
* dispatch does not reach: each variant is then a reading of its own, judged by the whole union.
|
|
1428
|
+
*/
|
|
1429
|
+
if (byVariant.some(([, readers]) => readers.by !== undefined)) {
|
|
1430
|
+
return variants.flatMap((variant) => formReadingsOf(program, registry, variant));
|
|
1431
|
+
}
|
|
1432
|
+
if (!byVariant.some(([, readers]) => readsText(readers)))
|
|
1433
|
+
return [NO_READERS];
|
|
1434
|
+
return [
|
|
1435
|
+
{
|
|
1436
|
+
values: null,
|
|
1437
|
+
fields: null,
|
|
1438
|
+
by: { property: discriminatorPropertyName, readers: Object.fromEntries(byVariant) },
|
|
1439
|
+
},
|
|
1440
|
+
];
|
|
1441
|
+
}
|
|
1442
|
+
function jsonOrTextDecoded(declared, asText) {
|
|
1443
|
+
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})`;
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* **A JSON list part, decoded from one text as the whole list or from several as its elements** -
|
|
1447
|
+
* see {@link isJsonListPart}. A lone text is the list here, as the component publishes it; the
|
|
1448
|
+
* request boxes one that is not into the one element it is, before this applies.
|
|
1449
|
+
*/
|
|
1450
|
+
function jsonListDecoded(declared) {
|
|
1451
|
+
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})`;
|
|
1452
|
+
}
|
|
836
1453
|
function jsonTextDecoded(declared) {
|
|
837
1454
|
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
1455
|
}
|
|
839
|
-
function isBinaryPart(type) {
|
|
1456
|
+
function isBinaryPart(program, type) {
|
|
840
1457
|
if (type.kind === "Scalar") {
|
|
841
1458
|
for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
|
|
842
1459
|
if (scalar.name === "bytes")
|
|
@@ -844,13 +1461,7 @@ function isBinaryPart(type) {
|
|
|
844
1461
|
}
|
|
845
1462
|
return false;
|
|
846
1463
|
}
|
|
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;
|
|
1464
|
+
return type.kind === "Model" && isOrExtendsHttpFile(program, type);
|
|
854
1465
|
}
|
|
855
1466
|
/**
|
|
856
1467
|
* Whether a body served under these media types is RAW BINARY rather than a JSON value.
|
|
@@ -884,6 +1495,17 @@ function isParseableMediaType(type) {
|
|
|
884
1495
|
essence === "application/x-www-form-urlencoded" ||
|
|
885
1496
|
essence.startsWith("multipart/"));
|
|
886
1497
|
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Whether a body is rendered with its metadata annotations ignored, as openapi3 renders it: an
|
|
1500
|
+
* explicit `@body` whose type carries them - see `withMetadataAnnotationsIgnored`.
|
|
1501
|
+
*/
|
|
1502
|
+
function ignoresMetadata(body) {
|
|
1503
|
+
return (body !== undefined &&
|
|
1504
|
+
"isExplicit" in body &&
|
|
1505
|
+
body.isExplicit === true &&
|
|
1506
|
+
"containsMetadataAnnotations" in body &&
|
|
1507
|
+
body.containsMetadataAnnotations === true);
|
|
1508
|
+
}
|
|
887
1509
|
/** The declared request body type, if the operation takes one. */
|
|
888
1510
|
function requestBodyOf(operation) {
|
|
889
1511
|
const body = requestBodyParameterOf(operation);
|
|
@@ -923,6 +1545,11 @@ const COLLECTION_DELIMITERS = {
|
|
|
923
1545
|
"ArrayEncoding.commaDelimited": ",",
|
|
924
1546
|
"ArrayEncoding.newlineDelimited": "\n",
|
|
925
1547
|
};
|
|
1548
|
+
/** A parameter's array encoding - the `style` openapi3 publishes for it - or `undefined`. */
|
|
1549
|
+
function arrayEncodingOf(program, property) {
|
|
1550
|
+
const encoding = getEncode(program, property)?.encoding;
|
|
1551
|
+
return encoding !== undefined && encoding in COLLECTION_DELIMITERS ? encoding : undefined;
|
|
1552
|
+
}
|
|
926
1553
|
/**
|
|
927
1554
|
* Whether the parameter crosses the wire as a list, which is what makes a delimiter meaningful.
|
|
928
1555
|
*
|
|
@@ -932,8 +1559,34 @@ const COLLECTION_DELIMITERS = {
|
|
|
932
1559
|
* schema, so every request was refused. Only a query parameter keeps its list under an array
|
|
933
1560
|
* encoding, and only there is a split undone.
|
|
934
1561
|
*/
|
|
935
|
-
function isCollectionParameter(
|
|
936
|
-
return isArrayType(
|
|
1562
|
+
function isCollectionParameter(wire) {
|
|
1563
|
+
return isArrayType(wire);
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* **The forms a parameter's text can take: one per shape its type's members have.** A union of a
|
|
1567
|
+
* scalar and a list - `int32 | string[]` - is `5` or `a,b` on the wire, and each is undone its own
|
|
1568
|
+
* way: the scalar members read as text, each list split, each record or model expanded. Undone only
|
|
1569
|
+
* as the scalar it might be, `a,b` was refused. A type of one shape is one form, itself.
|
|
1570
|
+
*/
|
|
1571
|
+
function wireFormsOf(type) {
|
|
1572
|
+
if (type.kind !== "Union")
|
|
1573
|
+
return [type];
|
|
1574
|
+
const members = [];
|
|
1575
|
+
const visit = (each) => {
|
|
1576
|
+
if (each.kind === "UnionVariant")
|
|
1577
|
+
return visit(each.type);
|
|
1578
|
+
if (each.kind === "Union")
|
|
1579
|
+
return [...each.variants.values()].forEach((v) => visit(v.type));
|
|
1580
|
+
if (!(each.kind === "Intrinsic" && each.name === "null"))
|
|
1581
|
+
members.push(each);
|
|
1582
|
+
};
|
|
1583
|
+
visit(type);
|
|
1584
|
+
const shaped = members.filter((member) => expansionShapeOf(member) !== "scalar");
|
|
1585
|
+
if (shaped.length === 0)
|
|
1586
|
+
return [type];
|
|
1587
|
+
const scalar = members.some((member) => expansionShapeOf(member) === "scalar");
|
|
1588
|
+
// The union itself stands for its scalar members: a scalar's form, read against the whole schema.
|
|
1589
|
+
return [...(scalar ? [type] : []), ...shaped];
|
|
937
1590
|
}
|
|
938
1591
|
/**
|
|
939
1592
|
* Whether the values arrive one per OCCURRENCE (RFC 6570 explode) rather than joined into one.
|
|
@@ -944,10 +1597,16 @@ function isCollectionParameter(program, property) {
|
|
|
944
1597
|
* refused a conformant request while accepting the same list sent twice, which no document
|
|
945
1598
|
* describes. Found by the first consumer's `?topics=ip` (#1).
|
|
946
1599
|
*/
|
|
947
|
-
function isExplodedCollection(program, parameter) {
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1600
|
+
function isExplodedCollection(program, parameter, wire) {
|
|
1601
|
+
/**
|
|
1602
|
+
* **A query only.** A header is `simple` style, which joins a list with commas exploded or not -
|
|
1603
|
+
* explode changes how a `simple` OBJECT is written, never a list - so an exploded header list
|
|
1604
|
+
* boxed whole was `["1,2"]`, refused where the document reads `[1, 2]`.
|
|
1605
|
+
*/
|
|
1606
|
+
return (isCollectionParameter(wire) &&
|
|
1607
|
+
parameter.type === "query" &&
|
|
1608
|
+
parameter.explode === true &&
|
|
1609
|
+
arrayEncodingOf(program, parameter.param) === undefined);
|
|
951
1610
|
}
|
|
952
1611
|
/**
|
|
953
1612
|
* The delimiter a parameter's values were joined with, or `undefined` when they were not joined.
|
|
@@ -956,17 +1615,24 @@ function isExplodedCollection(program, parameter) {
|
|
|
956
1615
|
* `form` style, which OpenAPI joins with a comma, and a header list is `simple` style, also comma.
|
|
957
1616
|
* Those are the two that looked correct because the document and the validator agreed.
|
|
958
1617
|
*/
|
|
959
|
-
function collectionDelimiterOf(program, parameter) {
|
|
960
|
-
if (!isCollectionParameter(
|
|
961
|
-
return undefined;
|
|
962
|
-
if (parameter.type !== "query" && parameter.type !== "header")
|
|
1618
|
+
function collectionDelimiterOf(program, parameter, wire) {
|
|
1619
|
+
if (!isCollectionParameter(wire))
|
|
963
1620
|
return undefined;
|
|
964
|
-
|
|
1621
|
+
// A cookie is `form` with `explode: false` in the document, which joins a list with commas.
|
|
1622
|
+
if (parameter.type !== "query" && parameter.type !== "header" && parameter.type !== "cookie") {
|
|
965
1623
|
return undefined;
|
|
966
|
-
const encoding = getEncode(program, parameter.param)?.encoding;
|
|
967
|
-
if (encoding !== undefined && encoding in COLLECTION_DELIMITERS) {
|
|
968
|
-
return COLLECTION_DELIMITERS[encoding];
|
|
969
1624
|
}
|
|
1625
|
+
/**
|
|
1626
|
+
* **An array encoding is a `style`, and it wins over `explode`.** openapi3 writes the encoding's
|
|
1627
|
+
* style - `pipeDelimited` - and writes `explode: true` only by leaving it out, which for every
|
|
1628
|
+
* style but `form` means NOT exploded. So the document joins the list with the encoding's
|
|
1629
|
+
* delimiter, `?xp=1|2`, explode or not; boxed as one occurrence, that was refused.
|
|
1630
|
+
*/
|
|
1631
|
+
const encoding = arrayEncodingOf(program, parameter.param);
|
|
1632
|
+
if (encoding !== undefined)
|
|
1633
|
+
return COLLECTION_DELIMITERS[encoding];
|
|
1634
|
+
if (parameter.type === "query" && parameter.explode === true)
|
|
1635
|
+
return undefined;
|
|
970
1636
|
return ",";
|
|
971
1637
|
}
|
|
972
1638
|
/**
|
|
@@ -997,26 +1663,25 @@ function collectionDelimiterOf(program, parameter) {
|
|
|
997
1663
|
* the document justifies. Same rule as the collection split above - decode the wire form, then apply
|
|
998
1664
|
* the document's schema to the result, never instead of it.
|
|
999
1665
|
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
* with
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
if (kind === "boolean")
|
|
1010
|
-
return `z.preprocess(${BOOLEAN}, ${declared})`;
|
|
1666
|
+
* **Read against the schema, not by a kind decided from the type** - see `WIRE_READ_SOURCE`. A
|
|
1667
|
+
* kind could not say `int32 | boolean`, a numeric enum, or `int32 | "all"`, where `"5"` is `5` and
|
|
1668
|
+
* `"all"` is text; each of them refused a value the document accepts. Only a type with a value text
|
|
1669
|
+
* cannot be read at all - every string - is left without a reader, and its validator is unchanged.
|
|
1670
|
+
*/
|
|
1671
|
+
function wireDecoded(program, registry, declared, type) {
|
|
1672
|
+
if (readsFromText(program, type)) {
|
|
1673
|
+
return `z.preprocess((raw) => wireRead(raw, ${declared}), ${declared})`;
|
|
1674
|
+
}
|
|
1011
1675
|
/**
|
|
1012
|
-
* A list whose ELEMENTS are
|
|
1013
|
-
* repeated `?id=1&id=2
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1676
|
+
* A list whose ELEMENTS are read from text - `?ids=1,2,3` after the collection split, or a
|
|
1677
|
+
* repeated `?id=1&id=2` - each against the element's own schema, as a lone scalar is. A non-array
|
|
1678
|
+
* value is left alone; the split that produces the array runs outside this and may not have
|
|
1679
|
+
* happened.
|
|
1016
1680
|
*/
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1681
|
+
const element = elementOf(type);
|
|
1682
|
+
if (element !== undefined && readsFromText(program, element)) {
|
|
1683
|
+
const schema = atItem(program, type, () => registry.expressionFor(element));
|
|
1684
|
+
return `z.preprocess((raw) => (Array.isArray(raw) ? raw.map((item) => wireRead(item, ${schema})) : raw), ${declared})`;
|
|
1020
1685
|
}
|
|
1021
1686
|
return declared;
|
|
1022
1687
|
}
|
|
@@ -1122,10 +1787,11 @@ literalQueryNames) {
|
|
|
1122
1787
|
let accept;
|
|
1123
1788
|
/** Form-exploded records and models, gathered back under their names around the query object. */
|
|
1124
1789
|
const gathered = [];
|
|
1125
|
-
const
|
|
1790
|
+
const parameters = publishedParametersOf(operation);
|
|
1791
|
+
const queryNames = parameters
|
|
1126
1792
|
.filter((parameter) => parameter.type === "query")
|
|
1127
1793
|
.map((parameter) => parameter.name);
|
|
1128
|
-
for (const parameter of
|
|
1794
|
+
for (const parameter of parameters) {
|
|
1129
1795
|
/**
|
|
1130
1796
|
* **`cookie` is here because `@typespec/http` puts it here**, and excluding it was silent.
|
|
1131
1797
|
* `HttpOperationParameter` discriminates on `"header" | "cookie" | "query" | "path"`, and this
|
|
@@ -1157,113 +1823,198 @@ literalQueryNames) {
|
|
|
1157
1823
|
* The TypeSpec property name is an authoring convenience that does not survive to the contract.
|
|
1158
1824
|
*/
|
|
1159
1825
|
/**
|
|
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.
|
|
1826
|
+
* **Two parameters of one location and one wire name are one value on the wire, and the
|
|
1827
|
+
* document publishes the first.** openapi3 writes one parameter per name and location, so
|
|
1828
|
+
* `@query("x") a: string` beside `@query("x") b: int32` is published as `x: string`, and `?x=abc`
|
|
1829
|
+
* is valid. Intersected, as both declarations, it was refused. `shared-input-key` reports it.
|
|
1166
1830
|
*/
|
|
1167
|
-
const
|
|
1168
|
-
if (
|
|
1831
|
+
const first = parameters.find((other) => sameWireName(other, parameter));
|
|
1832
|
+
if (first !== parameter)
|
|
1169
1833
|
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
|
-
*/
|
|
1834
|
+
// A header another one names in other case is refused, as `never` - see `headerCaseCollisionOf`.
|
|
1835
|
+
if (headerCaseCollisionOf(program, operation, parameter)) {
|
|
1836
|
+
const refused = parameter.param.optional ? "z.never().exactOptional()" : "z.never()";
|
|
1837
|
+
add(parameter.type, parameter.name, refused);
|
|
1838
|
+
add("header-without-accept", parameter.name, refused);
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1180
1841
|
/**
|
|
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.
|
|
1842
|
+
* A parameter published as a reference to another document states no schema - openapi3 writes
|
|
1843
|
+
* the reference and nothing else - and is refused, as a type published so is.
|
|
1185
1844
|
*/
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1188
|
-
|
|
1845
|
+
const external = externalReferenceOf(parameter.param);
|
|
1846
|
+
if (external !== undefined) {
|
|
1847
|
+
const refused = refuseExternalReference(program, parameter.param, external);
|
|
1848
|
+
const value = parameter.param.optional ? `${refused}.exactOptional()` : refused;
|
|
1849
|
+
add(parameter.type, parameter.name, value);
|
|
1850
|
+
if (parameter.type === "header")
|
|
1851
|
+
add("header-without-accept", parameter.name, value);
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
// At the visibility its shared component is published at - see `parameterVisibilityOf`.
|
|
1855
|
+
withVisibility(parameterVisibilityOf(parameter.param) ?? currentVisibility(), () => {
|
|
1856
|
+
const declared = registry.expressionForProperty(parameter.param);
|
|
1189
1857
|
/**
|
|
1190
|
-
*
|
|
1191
|
-
* the
|
|
1192
|
-
*
|
|
1193
|
-
* eighteen such parameters being decoded.
|
|
1858
|
+
* Each form the value can take, undone its own way and validated against the WHOLE schema - see
|
|
1859
|
+
* `wireFormsOf`. The first form the schema accepts is the value, as the first reading `wireRead`
|
|
1860
|
+
* finds is.
|
|
1194
1861
|
*/
|
|
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
|
-
|
|
1862
|
+
/**
|
|
1863
|
+
* `@encode` on the PROPERTY changes what crosses the wire, so the encoded type is the one to
|
|
1864
|
+
* read - see below.
|
|
1865
|
+
*/
|
|
1866
|
+
const wires = wireFormsOf(encodedParameterType(program, parameter.param));
|
|
1867
|
+
// The members a form-exploded query gathers back under its name - see `formOf`.
|
|
1868
|
+
const objectForms = parameter.type === "query" && parameter.explode === true
|
|
1869
|
+
? wires.filter((wire) => expansionShapeOf(wire) === "record")
|
|
1870
|
+
: [];
|
|
1871
|
+
/**
|
|
1872
|
+
* **A list or a scalar beside them is the parameter sent as itself**, `?x=1&x=2`, which the
|
|
1873
|
+
* objects' gatherer would take as a key of its own - see `WIRE_ITSELF_SOURCE`. Then, as for a
|
|
1874
|
+
* union of models, the query is gathered once and each member reads it its own way.
|
|
1875
|
+
*/
|
|
1876
|
+
const itself = objectForms.length > 0 && wires.length > objectForms.length;
|
|
1877
|
+
const gatheredOnce = objectForms.length > 1 || itself;
|
|
1878
|
+
const gatheredOthers = [
|
|
1879
|
+
...queryNames.filter((name) => name !== parameter.name),
|
|
1880
|
+
...literalQueryNames,
|
|
1881
|
+
];
|
|
1882
|
+
const formOf = (wire) => {
|
|
1883
|
+
const delimiter = collectionDelimiterOf(program, parameter, wire);
|
|
1884
|
+
/**
|
|
1885
|
+
* The wire form is decoded before it is validated, never instead of it. `z.preprocess` undoes
|
|
1886
|
+
* exactly the flattening the document's `style` describes and then applies the schema the
|
|
1887
|
+
* document publishes, so every constraint on the list - `minItems`, the element type - still
|
|
1888
|
+
* runs. Splitting in the handler instead would have left those unenforced.
|
|
1889
|
+
*/
|
|
1890
|
+
/**
|
|
1891
|
+
* Decoded BEFORE the split is wrapped around it, so the two nest in wire order: the outer
|
|
1892
|
+
* preprocess turns `"1,2,3"` into `["1","2","3"]`, the inner one turns those into numbers, and
|
|
1893
|
+
* the document's schema then validates the result. Wrapping the other way round would hand the
|
|
1894
|
+
* element decoder an unsplit string.
|
|
1895
|
+
*/
|
|
1896
|
+
const decoded = parameter.type === "header" && parameter.name.toLowerCase() === "content-type"
|
|
1897
|
+
? mediaTypeDecoded(declared)
|
|
1898
|
+
: wireDecoded(program, registry, declared, wire);
|
|
1225
1899
|
/**
|
|
1226
|
-
*
|
|
1227
|
-
*
|
|
1900
|
+
* The boxing half of the same principle: an exploded array's single occurrence arrives as a
|
|
1901
|
+
* bare string, so it is boxed into the one-element array the document describes before the
|
|
1902
|
+
* schema runs - every constraint on the list still applies to the result. Several
|
|
1903
|
+
* occurrences arrive already an array and pass through untouched, exactly as the delimiter
|
|
1904
|
+
* arm leaves a pre-split value alone.
|
|
1228
1905
|
*/
|
|
1229
|
-
const
|
|
1230
|
-
|
|
1906
|
+
const boxedOrSplit = isExplodedCollection(program, parameter, wire)
|
|
1907
|
+
? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
|
|
1908
|
+
: delimiter === undefined
|
|
1909
|
+
? decoded
|
|
1910
|
+
: `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${literal(delimiter)}) : raw), ${decoded})`;
|
|
1911
|
+
/**
|
|
1912
|
+
* **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
|
|
1913
|
+
* wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
|
|
1914
|
+
* run on that. See {@link uriExpansionCall}.
|
|
1915
|
+
*/
|
|
1916
|
+
const shape = expansionShapeOf(wire);
|
|
1917
|
+
// A key repeats only in a form-exploded query - see `readersOf`.
|
|
1918
|
+
const readers = readersOf(program, registry, wire, false);
|
|
1919
|
+
let expression = boxedOrSplit;
|
|
1920
|
+
if (parameter.type === "path") {
|
|
1921
|
+
const segment = decodedSegmentOf(program, parameter, segments, wire);
|
|
1922
|
+
if (segment !== undefined) {
|
|
1923
|
+
// A value of any type is the shape its expansion spells - see `URI_EXPANSION_SOURCE`.
|
|
1924
|
+
const any = shape === "scalar" && takesAnyValue(program, wire);
|
|
1925
|
+
expression = `z.preprocess(${uriExpansionCall({
|
|
1926
|
+
...segment,
|
|
1927
|
+
name: parameter.name,
|
|
1928
|
+
shape: any ? "any" : shape,
|
|
1929
|
+
readers: any ? { values: "z.unknown()", fields: null } : readers,
|
|
1930
|
+
})}, ${decoded})`;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
else if ((parameter.type === "header" || parameter.type === "cookie") &&
|
|
1934
|
+
shape === "record") {
|
|
1935
|
+
/**
|
|
1936
|
+
* **A header or a cookie carries a record or a model as text too**, and nothing read it: the
|
|
1937
|
+
* validator met `"a,1,b,2"` where the document's schema describes `{a: 1, b: 2}`, and refused
|
|
1938
|
+
* every value. A header is `simple` style - `a,1,b,2`, or `a=1,b=2` exploded - and a cookie is
|
|
1939
|
+
* `form` without explode, as openapi3 publishes it: the same expansion as a query's, undone
|
|
1940
|
+
* the same way.
|
|
1941
|
+
*/
|
|
1942
|
+
const explode = parameter.type === "header" && parameter.explode === true;
|
|
1943
|
+
expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode, shape, readers })}, ${decoded})`;
|
|
1944
|
+
}
|
|
1945
|
+
else if (parameter.type === "query" && shape === "record") {
|
|
1946
|
+
if (parameter.explode === true) {
|
|
1947
|
+
/**
|
|
1948
|
+
* `{?param*}` over an object expands to the object's own keys, so there is no `param` key for
|
|
1949
|
+
* this entry to read; the gatherer around the query object puts one back. A union of
|
|
1950
|
+
* models is gathered once, below, and each member reads the gathered values its own way.
|
|
1951
|
+
*/
|
|
1952
|
+
const repeated = readersOf(program, registry, wire, true);
|
|
1953
|
+
if (gatheredOnce) {
|
|
1954
|
+
expression = `z.preprocess(${wireReadObjectCall(repeated)}, ${expression})`;
|
|
1955
|
+
}
|
|
1956
|
+
else {
|
|
1957
|
+
const model = wire.kind === "Model" && wire.indexer === undefined ? wire : undefined;
|
|
1958
|
+
gathered.push(explodedQueryCall({
|
|
1959
|
+
name: parameter.name,
|
|
1960
|
+
others: gatheredOthers,
|
|
1961
|
+
keys: model === undefined ? undefined : explodedKeysOf(program, model),
|
|
1962
|
+
itself: false,
|
|
1963
|
+
readers: repeated,
|
|
1964
|
+
}));
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
else {
|
|
1968
|
+
expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode: false, shape, readers })}, ${decoded})`;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
else if (itself) {
|
|
1972
|
+
// What arrived under the name, where the objects' gatherer took the query.
|
|
1973
|
+
expression = `z.preprocess(${wireItselfCall()}, ${expression})`;
|
|
1974
|
+
}
|
|
1975
|
+
return expression;
|
|
1976
|
+
};
|
|
1977
|
+
/**
|
|
1978
|
+
* `@encode` on the PROPERTY changes what crosses the wire, so the encoded type is the one to
|
|
1979
|
+
* read. `@encode("unixTimestamp", int32) value: utcDateTime` is a number on the wire and a date
|
|
1980
|
+
* in the spec, and reading the declared type alone stopped eighteen such parameters being
|
|
1981
|
+
* decoded.
|
|
1982
|
+
*/
|
|
1983
|
+
const forms = wires.map(formOf);
|
|
1984
|
+
if (gatheredOnce) {
|
|
1985
|
+
// One gatherer for every member, over every key one of them declares - see `formOf`.
|
|
1986
|
+
const keyed = objectForms.map((wire) => wire.kind === "Model" && wire.indexer === undefined
|
|
1987
|
+
? explodedKeysOf(program, wire)
|
|
1988
|
+
: undefined);
|
|
1231
1989
|
gathered.push(explodedQueryCall({
|
|
1232
1990
|
name: parameter.name,
|
|
1233
|
-
others:
|
|
1234
|
-
keys:
|
|
1991
|
+
others: gatheredOthers,
|
|
1992
|
+
keys: keyed.includes(undefined)
|
|
1235
1993
|
? 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,
|
|
1994
|
+
: [...new Set(keyed.flatMap((keys) => keys ?? []))],
|
|
1995
|
+
itself,
|
|
1996
|
+
readers: NO_READERS,
|
|
1244
1997
|
}));
|
|
1245
1998
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1999
|
+
const expression = forms.length === 1 ? forms[0] : `z.union([${forms.join(", ")}])`;
|
|
2000
|
+
add(parameter.type, parameter.name, expression);
|
|
2001
|
+
/**
|
|
2002
|
+
* **On a route several operations share, `accept` SELECTS the operation - it is not a
|
|
2003
|
+
* constraint on one of them.** Each member declares its own literal, so validating against any
|
|
2004
|
+
* single member's would 400 every request asking for one of the others, and 400 is the wrong
|
|
2005
|
+
* answer twice over: the request is well-formed, and the contract's answer to "I cannot serve
|
|
2006
|
+
* what you asked for" is 406. `selectContentType` decides it instead, so the negotiated route
|
|
2007
|
+
* validates every OTHER header and leaves this one to the negotiation.
|
|
2008
|
+
*/
|
|
2009
|
+
if (parameter.type === "header" && parameter.name.toLowerCase() === "accept") {
|
|
2010
|
+
const declared = parameter.param.type;
|
|
2011
|
+
if (declared.kind === "String")
|
|
2012
|
+
accept = { name: parameter.name, value: declared.value };
|
|
1248
2013
|
}
|
|
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
|
-
}
|
|
2014
|
+
else if (parameter.type === "header") {
|
|
2015
|
+
add("header-without-accept", parameter.name, expression);
|
|
2016
|
+
}
|
|
2017
|
+
});
|
|
1267
2018
|
}
|
|
1268
2019
|
const fileContentType = fileContentTypeOf(operation);
|
|
1269
2020
|
if (fileContentType !== undefined) {
|
|
@@ -1300,32 +2051,159 @@ literalQueryNames) {
|
|
|
1300
2051
|
* string`, and validated by `z.string()`; shaped from the declared list it was expanded into
|
|
1301
2052
|
* `["a", "b"]` first, and every request was refused.
|
|
1302
2053
|
*/
|
|
1303
|
-
function decodedSegmentOf(program, parameter, segments) {
|
|
2054
|
+
function decodedSegmentOf(program, parameter, segments, wire) {
|
|
1304
2055
|
const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
|
|
1305
|
-
|
|
1306
|
-
|
|
2056
|
+
if (segment === undefined)
|
|
2057
|
+
return undefined;
|
|
2058
|
+
// Decoded where any form the value can take is - see `wireFormsOf`.
|
|
2059
|
+
const forms = wire === undefined ? wireFormsOf(encodedParameterType(program, parameter.param)) : [wire];
|
|
2060
|
+
return forms.some((form) => needsExpansionDecoding(segment, expansionShapeOf(form)))
|
|
1307
2061
|
? segment
|
|
1308
2062
|
: undefined;
|
|
1309
2063
|
}
|
|
2064
|
+
/**
|
|
2065
|
+
* **Whether a value of the type may be anything**: `unknown`, a scalar with no standard base, or a
|
|
2066
|
+
* union holding one - published as `{}` or with `{}` among its members - so its text may spell a list
|
|
2067
|
+
* or an object as well as a scalar.
|
|
2068
|
+
*/
|
|
2069
|
+
function takesAnyValue(program, type) {
|
|
2070
|
+
if (type.kind === "UnionVariant")
|
|
2071
|
+
return takesAnyValue(program, type.type);
|
|
2072
|
+
if (type.kind === "Union") {
|
|
2073
|
+
return [...type.variants.values()].some((variant) => takesAnyValue(program, variant.type));
|
|
2074
|
+
}
|
|
2075
|
+
return isTypelessMember(program, type);
|
|
2076
|
+
}
|
|
1310
2077
|
/** What a parameter's value decodes into once its expansion is undone. */
|
|
1311
2078
|
function expansionShapeOf(type) {
|
|
1312
2079
|
if (isArrayType(type))
|
|
1313
2080
|
return "list";
|
|
1314
2081
|
return type.kind === "Model" ? "record" : "scalar";
|
|
1315
2082
|
}
|
|
1316
|
-
/**
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
2083
|
+
/**
|
|
2084
|
+
* The schemas a record's or a model's values are read from text against - see `wireRead`: every
|
|
2085
|
+
* value of a record against its indexer's, each key of a model against its property's. A value
|
|
2086
|
+
* text cannot spell anything else, a string, needs none.
|
|
2087
|
+
*
|
|
2088
|
+
* `repeats` says whether a key may arrive more than once, a list as its repeated values: a
|
|
2089
|
+
* form-exploded query object, and a URL-encoded form. There a lone occurrence is boxed into the list
|
|
2090
|
+
* it stands for. Anywhere else a value is one piece of text, which spells no list: boxed in a header,
|
|
2091
|
+
* a cookie, a path or a non-exploded query, `l,1` was `{ l: [1] }` where the document reads
|
|
2092
|
+
* `{ l: "1" }` and refuses it, and `l,1,l,2` dropped the first value.
|
|
2093
|
+
*/
|
|
2094
|
+
function readersOf(program, registry, type, repeats) {
|
|
2095
|
+
if (type.kind !== "Model" || isArrayType(type))
|
|
2096
|
+
return NO_READERS;
|
|
2097
|
+
/**
|
|
2098
|
+
* **Every key no member names is read as the indexer that holds it, the topmost in the chain** -
|
|
2099
|
+
* see `bindingIndexerOf`. Read as the model's own, an inherited `...Record<int32>` read nothing and
|
|
2100
|
+
* a subtype's narrower indexer read what the base's, which the validator enforces, accepts.
|
|
2101
|
+
*/
|
|
2102
|
+
const binding = bindingIndexerOf(type);
|
|
2103
|
+
const values = binding === undefined || isNeverType(binding.value)
|
|
2104
|
+
? null
|
|
2105
|
+
: readerOf(program, registry, binding.value, undefined, repeats);
|
|
2106
|
+
/**
|
|
2107
|
+
* **Each key is read as the validator holds it**: the object the model is rendered as, each member
|
|
2108
|
+
* its most derived declaration at its position. Read first-wins from the base down, `Sub { x?:
|
|
2109
|
+
* string }` under `Base { x?: int32 | string }` read `"5"` as `5`, and `Sub` refused it.
|
|
2110
|
+
*
|
|
2111
|
+
* **Where an indexer reads every key, a declared key is read as its own property says** - text
|
|
2112
|
+
* included, so `s?: string` beside `...Record<int32 | string>` stays `"5"` rather than taking the
|
|
2113
|
+
* indexer's reading and becoming `5`.
|
|
2114
|
+
*/
|
|
2115
|
+
const fields = {};
|
|
2116
|
+
withVisibility(renderedVisibilityOf(program, type, currentVisibility()), () => {
|
|
2117
|
+
const object = objectOfModel(program, type);
|
|
2118
|
+
if (object.kind !== "object")
|
|
2119
|
+
return;
|
|
2120
|
+
for (const member of object.members) {
|
|
2121
|
+
if (member.property === undefined)
|
|
2122
|
+
continue;
|
|
2123
|
+
const reader = readerOf(program, registry, encodedParameterType(program, member.property), { property: member.property, position: member.position }, repeats, values !== null);
|
|
2124
|
+
if (reader !== null)
|
|
2125
|
+
fields[member.key] = reader;
|
|
2126
|
+
}
|
|
2127
|
+
});
|
|
2128
|
+
/**
|
|
2129
|
+
* **A discriminated base is read as the subtype its discriminator names**: a header `Snake`
|
|
2130
|
+
* carrying `kind,cobra,length,5` is a `Cobra`, whose `length` is a number, and `Pet` carrying
|
|
2131
|
+
* `kind,cat,x,5` a `Cat`, whose `x` is a string though `Dog`'s is a number. Read by one table for
|
|
2132
|
+
* every subtype, a key two of them declare was read as the first's.
|
|
2133
|
+
*/
|
|
2134
|
+
const discriminator = getDiscriminator(program, type);
|
|
2135
|
+
const dispatched = dispatchedSubtypesOf(program, type);
|
|
2136
|
+
return {
|
|
2137
|
+
values,
|
|
2138
|
+
fields: Object.keys(fields).length === 0 ? null : fields,
|
|
2139
|
+
...(discriminator === undefined || dispatched.length === 0
|
|
2140
|
+
? {}
|
|
2141
|
+
: {
|
|
2142
|
+
by: {
|
|
2143
|
+
property: discriminator.propertyName,
|
|
2144
|
+
readers: Object.fromEntries(dispatched.map(([value, subtype]) => [
|
|
2145
|
+
value,
|
|
2146
|
+
readersOf(program, registry, subtype, repeats),
|
|
2147
|
+
])),
|
|
2148
|
+
},
|
|
2149
|
+
}),
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* The reader for one value of a record or a model, or `null` for none: a list boxed and read element
|
|
2154
|
+
* by element where a key repeats, a value text can spell as something else read against its schema -
|
|
2155
|
+
* the member's, at its position, where there is one - and text, which needs no reading unless
|
|
2156
|
+
* `always`.
|
|
2157
|
+
*/
|
|
2158
|
+
function readerOf(program, registry, type, member, repeats, always = false) {
|
|
2159
|
+
const whole = () => member === undefined
|
|
2160
|
+
? withParameterPosition(true, () => registry.expressionFor(type))
|
|
2161
|
+
: registry.expressionForProperty(member.property, member.position);
|
|
2162
|
+
// Text is never `null`, so a `| null` beside a list leaves the list to read.
|
|
2163
|
+
const inner = nullableInnerOf(type) ?? type;
|
|
2164
|
+
const element = elementOf(inner);
|
|
2165
|
+
if (element !== undefined) {
|
|
2166
|
+
// One piece of text spells no list, so it is left to the schema to refuse.
|
|
2167
|
+
if (!repeats)
|
|
2168
|
+
return always ? whole() : null;
|
|
2169
|
+
return eachReader(atItem(program, inner, () => withParameterPosition(true, () => registry.expressionFor(element))));
|
|
1327
2170
|
}
|
|
1328
|
-
|
|
2171
|
+
/**
|
|
2172
|
+
* **A union holding a list is one value or several**: `e?: int32 | int32[]` arrives as `?e=1` or
|
|
2173
|
+
* `?e=1&e=2`. Several are read as the list's elements; one is read against the whole type, which
|
|
2174
|
+
* takes it as the one value. Read as text alone, the repeated key reached the schema unread and was
|
|
2175
|
+
* refused where the document reads `[1, 2]`. Where a key does not repeat, it is only ever one.
|
|
2176
|
+
*/
|
|
2177
|
+
const forms = inner.kind === "Union" ? wireFormsOf(inner) : [];
|
|
2178
|
+
const lists = forms.filter((each) => elementOf(each) !== undefined);
|
|
2179
|
+
if (lists.length > 0) {
|
|
2180
|
+
if (!repeats)
|
|
2181
|
+
return whole();
|
|
2182
|
+
/**
|
|
2183
|
+
* **Every list member's elements, and the whole value to choose by**: `int32[] | string[]`
|
|
2184
|
+
* reads `?ll=1&ll=x` as the strings, the reading the whole accepts, where its first member's
|
|
2185
|
+
* elements made it `[1, "x"]`, and refused. One value is the one value only where a member is
|
|
2186
|
+
* not a list; `?ll=x` is the list `["x"]`.
|
|
2187
|
+
*/
|
|
2188
|
+
const each = lists.map((list) => atItem(program, list, () => withParameterPosition(true, () => registry.expressionFor(elementOf(list)))));
|
|
2189
|
+
const one = forms.some((each) => elementOf(each) === undefined) ? whole() : undefined;
|
|
2190
|
+
return eachReader(each, one, whole());
|
|
2191
|
+
}
|
|
2192
|
+
if (!always && !readsFromText(program, type))
|
|
2193
|
+
return null;
|
|
2194
|
+
return whole();
|
|
2195
|
+
}
|
|
2196
|
+
/**
|
|
2197
|
+
* **The keys a form-exploded model is gathered by: the members its validator declares**, at the
|
|
2198
|
+
* visibility it is rendered at. Every property was a key, so `?secret=x` on a GET gathered a
|
|
2199
|
+
* create-only property the document does not publish there, and a sealed model refused the request
|
|
2200
|
+
* the document reads as `{}`.
|
|
2201
|
+
*/
|
|
2202
|
+
function explodedKeysOf(program, model) {
|
|
2203
|
+
return withVisibility(renderedVisibilityOf(program, model, currentVisibility()), () => {
|
|
2204
|
+
const object = objectOfModel(program, model);
|
|
2205
|
+
return object.kind === "object" ? object.members.map((member) => member.key) : [];
|
|
2206
|
+
});
|
|
1329
2207
|
}
|
|
1330
2208
|
/**
|
|
1331
2209
|
* **The media type an `Http.File` body declares, as the input it is.** A file's `contentType` is the
|
|
@@ -1340,7 +2218,7 @@ function fileContentTypeOf(operation) {
|
|
|
1340
2218
|
const body = requestBodyParameterOf(operation);
|
|
1341
2219
|
if (body?.bodyKind !== "file")
|
|
1342
2220
|
return undefined;
|
|
1343
|
-
const declared = operation.
|
|
2221
|
+
const declared = publishedParametersOf(operation).some((parameter) => parameter.type === "header" && parameter.name.toLowerCase() === "content-type");
|
|
1344
2222
|
return declared ? undefined : body.contentTypeProperty;
|
|
1345
2223
|
}
|
|
1346
2224
|
/**
|
|
@@ -1354,9 +2232,14 @@ function fileContentTypeOf(operation) {
|
|
|
1354
2232
|
* handler receives.
|
|
1355
2233
|
*/
|
|
1356
2234
|
/**
|
|
1357
|
-
* The keys a merged body would contribute to the handler's input: each
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
2235
|
+
* The keys a merged body would contribute to the handler's input: each key of the object the body's
|
|
2236
|
+
* validator declares, under its wire name, or each named part of a multipart body.
|
|
2237
|
+
*
|
|
2238
|
+
* **Read from that object, at the visibility and in the context it is rendered in**, so the two
|
|
2239
|
+
* cannot disagree. A read-only property is one openapi3 shares into a request that has nothing else
|
|
2240
|
+
* request-specific, as `readOnly` and still required under OAS 3.1's reading: read at the request
|
|
2241
|
+
* visibility it dropped out, so `@path accountId` beside a body requiring `accountId` was merged with
|
|
2242
|
+
* it and one of the two lost.
|
|
1360
2243
|
*/
|
|
1361
2244
|
function mergedBodyKeysOf(program, operation) {
|
|
1362
2245
|
const body = requestBodyParameterOf(operation);
|
|
@@ -1366,9 +2249,55 @@ function mergedBodyKeysOf(program, operation) {
|
|
|
1366
2249
|
const type = requestBodyOf(operation);
|
|
1367
2250
|
if (type?.kind !== "Model")
|
|
1368
2251
|
return [];
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
2252
|
+
const requested = requestVisibilityOf(program, operation);
|
|
2253
|
+
return withVisibility(rootVisibilityOf(program, type, requested), () => withMetadataAnnotationsIgnored(ignoresMetadata(body), () => {
|
|
2254
|
+
const object = objectOfModel(program, type);
|
|
2255
|
+
return object.kind === "object" ? object.members.map((member) => member.key) : [];
|
|
2256
|
+
}));
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* **Whether two parameters name one value on the wire**: one location and one name - a header's name
|
|
2260
|
+
* read without its case, as HTTP reads it (RFC 9110 section 5.1).
|
|
2261
|
+
*/
|
|
2262
|
+
function sameWireName(one, other) {
|
|
2263
|
+
return (one.type === other.type &&
|
|
2264
|
+
(one.type === "header"
|
|
2265
|
+
? one.name.toLowerCase() === other.name.toLowerCase()
|
|
2266
|
+
: one.name === other.name));
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* **Two headers whose names differ only in case are one header**, and openapi3 publishes them as two
|
|
2270
|
+
* parameters of the one value - `x-foo: int32` and `X-Foo: string` - which every value has to
|
|
2271
|
+
* satisfy at once and a server hands over once. `header-case-collision`, an error, reported once per
|
|
2272
|
+
* pair, and both validate nothing; validated as the last, `X-Foo: abc` was served where the
|
|
2273
|
+
* document refuses it.
|
|
2274
|
+
*/
|
|
2275
|
+
const headerCaseCollisionsReported = new WeakMap();
|
|
2276
|
+
function headerCaseCollisionOf(program, operation, parameter) {
|
|
2277
|
+
if (parameter.type !== "header")
|
|
2278
|
+
return false;
|
|
2279
|
+
const other = publishedParametersOf(operation).find((each) => each.type === "header" &&
|
|
2280
|
+
each.name !== parameter.name &&
|
|
2281
|
+
each.name.toLowerCase() === parameter.name.toLowerCase());
|
|
2282
|
+
if (other === undefined)
|
|
2283
|
+
return false;
|
|
2284
|
+
const names = [parameter.name, other.name].toSorted();
|
|
2285
|
+
const reported = headerCaseCollisionsReported.get(program) ?? new Set();
|
|
2286
|
+
headerCaseCollisionsReported.set(program, reported);
|
|
2287
|
+
const key = `${operation.operation.name}\u0000${names.join("\u0000")}`;
|
|
2288
|
+
if (!reported.has(key)) {
|
|
2289
|
+
reported.add(key);
|
|
2290
|
+
reportDiagnostic(program, {
|
|
2291
|
+
code: "header-case-collision",
|
|
2292
|
+
target: parameter.param,
|
|
2293
|
+
format: {
|
|
2294
|
+
operation: operation.operation.name,
|
|
2295
|
+
first: names[0] ?? "",
|
|
2296
|
+
second: names[1] ?? "",
|
|
2297
|
+
},
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
return true;
|
|
1372
2301
|
}
|
|
1373
2302
|
const INPUT_LOCATION_NOUNS = {
|
|
1374
2303
|
path: "path parameter",
|
|
@@ -1395,7 +2324,7 @@ function sharedInputKeysOf(program, operation) {
|
|
|
1395
2324
|
if (resolved !== undefined)
|
|
1396
2325
|
return resolved;
|
|
1397
2326
|
const inputs = [];
|
|
1398
|
-
for (const parameter of operation
|
|
2327
|
+
for (const parameter of publishedParametersOf(operation)) {
|
|
1399
2328
|
if (parameter.type === "path" ||
|
|
1400
2329
|
parameter.type === "query" ||
|
|
1401
2330
|
parameter.type === "header" ||
|
|
@@ -1440,7 +2369,13 @@ function sharedInputKeysOf(program, operation) {
|
|
|
1440
2369
|
if (reported.has(said))
|
|
1441
2370
|
return;
|
|
1442
2371
|
reported.add(said);
|
|
1443
|
-
reportDiagnostic(program, {
|
|
2372
|
+
reportDiagnostic(program, {
|
|
2373
|
+
code: "shared-input-key",
|
|
2374
|
+
// One location, one value on the wire: the document publishes the first declaration.
|
|
2375
|
+
messageId: first.location === input.location ? "repeated" : "default",
|
|
2376
|
+
target: input.target,
|
|
2377
|
+
format,
|
|
2378
|
+
});
|
|
1444
2379
|
});
|
|
1445
2380
|
sharedInputKeys.set(operation, shared);
|
|
1446
2381
|
return shared;
|
|
@@ -1448,6 +2383,7 @@ function sharedInputKeysOf(program, operation) {
|
|
|
1448
2383
|
const sharedInputKeys = new WeakMap();
|
|
1449
2384
|
const sharedInputKeysReported = new WeakMap();
|
|
1450
2385
|
function requestBodyPlacementOf(program, operation) {
|
|
2386
|
+
reportNeverBody(program, operation, operation.parameters.body, "request");
|
|
1451
2387
|
const requestType = requestBodyOf(operation);
|
|
1452
2388
|
const bodyParameter = requestBodyParameterOf(operation);
|
|
1453
2389
|
const contentTypes = [...(bodyParameter?.contentTypes ?? [])];
|
|
@@ -1457,7 +2393,7 @@ function requestBodyPlacementOf(program, operation) {
|
|
|
1457
2393
|
* A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
|
|
1458
2394
|
* verify a different string and every delivery would fail authentication.
|
|
1459
2395
|
*/
|
|
1460
|
-
const bytes = requestType?.kind === "Scalar" && requestType
|
|
2396
|
+
const bytes = requestType?.kind === "Scalar" && program.checker.isStdType(requestType, "bytes");
|
|
1461
2397
|
/**
|
|
1462
2398
|
* **A body declared only under media types nothing can parse into it is handed over raw.** A
|
|
1463
2399
|
* model as `application/xml` has no serialisation this emitter can derive, and the server has no
|
|
@@ -1542,10 +2478,11 @@ function requestBodyPlacementOf(program, operation) {
|
|
|
1542
2478
|
* other.** `PUT /renames/{name}` with a body `{ name }` handed the handler one `name`, the body's,
|
|
1543
2479
|
* and the path's was lost with nothing reported - in `parameters/spread`'s composite request, and
|
|
1544
2480
|
* 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
|
-
*
|
|
2481
|
+
* separately; named, the handler is given both. The body's keys are the keys its validator
|
|
2482
|
+
* declares - see `mergedBodyKeysOf` - so a read-only `id` shared into the request, still required
|
|
2483
|
+
* under OAS 3.1's reading, collides with `@path id` as any other key does.
|
|
1547
2484
|
*/
|
|
1548
|
-
const parameterNames = new Set(operation.
|
|
2485
|
+
const parameterNames = new Set(publishedParametersOf(operation).map((parameter) => parameter.name));
|
|
1549
2486
|
const collides = rawBodyProperty === undefined &&
|
|
1550
2487
|
mergedBodyKeysOf(program, operation).some((key) => parameterNames.has(key));
|
|
1551
2488
|
const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
|
|
@@ -1577,7 +2514,11 @@ function requestBodyPlacementOf(program, operation) {
|
|
|
1577
2514
|
};
|
|
1578
2515
|
}
|
|
1579
2516
|
export function collectRoutes(program, registry, service) {
|
|
2517
|
+
return withServiceView(program, service.namespace, () => collectServiceRoutes(program, registry, service));
|
|
2518
|
+
}
|
|
2519
|
+
function collectServiceRoutes(program, registry, service) {
|
|
1580
2520
|
const routes = [];
|
|
2521
|
+
reportUnpublishable(program, service.namespace);
|
|
1581
2522
|
{
|
|
1582
2523
|
for (const operation of service.operations) {
|
|
1583
2524
|
/**
|
|
@@ -1600,14 +2541,16 @@ export function collectRoutes(program, registry, service) {
|
|
|
1600
2541
|
*/
|
|
1601
2542
|
const streamed = streamedTypeOf(program, operation.operation.returnType);
|
|
1602
2543
|
if (streamed !== undefined) {
|
|
1603
|
-
withVisibility(
|
|
2544
|
+
withVisibility(Visibility.Read, () => registry.expressionFor(streamed));
|
|
1604
2545
|
}
|
|
1605
2546
|
// `@requestVisibility` when the operation states one, otherwise the verb's default - the
|
|
1606
2547
|
// same resolution `@typespec/openapi3` performs before emitting a request schema.
|
|
1607
|
-
const requestVisibility =
|
|
2548
|
+
const requestVisibility = requestVisibilityOf(program, operation);
|
|
1608
2549
|
const { rawBodyProperty, rawBodyBinary, rawBodyUnvalidated, bodyProperty, optionalBody } = requestBodyPlacementOf(program, operation);
|
|
1609
2550
|
const authentication = authenticationFor(program, operation);
|
|
1610
|
-
const template = routeTemplateOf(operation.uriTemplate,
|
|
2551
|
+
const template = routeTemplateOf(operation.uriTemplate,
|
|
2552
|
+
// Every path parameter, a `never` included: the template still holds its expression.
|
|
2553
|
+
operation.parameters.parameters
|
|
1611
2554
|
.filter((parameter) => parameter.type === "path")
|
|
1612
2555
|
.map((parameter) => ({
|
|
1613
2556
|
name: parameter.name,
|
|
@@ -1641,7 +2584,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1641
2584
|
.map((parameter) => parameter.name),
|
|
1642
2585
|
pathSegments: template.segments,
|
|
1643
2586
|
sharedInputKeys: sharedInputKeysOf(program, operation),
|
|
1644
|
-
decodedPathParameters: operation
|
|
2587
|
+
decodedPathParameters: publishedParametersOf(operation)
|
|
1645
2588
|
.filter((parameter) => decodedSegmentOf(program, parameter, template.segments) !== undefined)
|
|
1646
2589
|
.map((parameter) => parameter.name),
|
|
1647
2590
|
literalQuery: template.literalQuery,
|
|
@@ -1669,17 +2612,29 @@ export function collectRoutes(program, registry, service) {
|
|
|
1669
2612
|
* one and not the other, and `@visibility` can add or remove properties outright. Walking
|
|
1670
2613
|
* both at one visibility gives whichever was seen first to both.
|
|
1671
2614
|
*/
|
|
1672
|
-
|
|
2615
|
+
...withVisibility(requestVisibility, () => withRequestReading(program, requestVisibility, () => {
|
|
1673
2616
|
// Multipart first: its body has no single type to walk, and skipping it left every
|
|
1674
2617
|
// such route mounted with nothing checking the request at all.
|
|
1675
2618
|
const parameterBody = requestBodyParameterOf(operation);
|
|
1676
2619
|
if (parameterBody?.bodyKind === "multipart") {
|
|
1677
|
-
return
|
|
2620
|
+
return {
|
|
2621
|
+
requestSchema: multipartRequestOf(program, registry, parameterBody, multipartSchemaOf(program, parameterBody, registry)),
|
|
2622
|
+
textParts: textPartsOf(program, parameterBody),
|
|
2623
|
+
};
|
|
1678
2624
|
}
|
|
1679
|
-
|
|
1680
|
-
|
|
2625
|
+
if (requestType === undefined)
|
|
2626
|
+
return { requestSchema: undefined };
|
|
2627
|
+
reportAmbiguousReadOnly(program, requestType, requestVisibility);
|
|
2628
|
+
const declared = withMetadataAnnotationsIgnored(ignoresMetadata(parameterBody), () => registry.expressionFor(requestType, true));
|
|
2629
|
+
const form = formBodyOf(program, registry, operation, parameterBody, requestType, declared);
|
|
2630
|
+
return form === undefined
|
|
2631
|
+
? { requestSchema: declared }
|
|
2632
|
+
: form.only
|
|
2633
|
+
? { requestSchema: form.schema }
|
|
2634
|
+
: { requestSchema: declared, formRequestSchema: form.schema };
|
|
2635
|
+
})),
|
|
1681
2636
|
...(() => {
|
|
1682
|
-
const split = withVisibility(
|
|
2637
|
+
const split = withVisibility(requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
|
|
1683
2638
|
return {
|
|
1684
2639
|
cookieSchema: split.cookie,
|
|
1685
2640
|
pathSchema: split.path,
|
|
@@ -1696,7 +2651,7 @@ export function collectRoutes(program, registry, service) {
|
|
|
1696
2651
|
* visibility declared six components under the wrong suffix - measured:
|
|
1697
2652
|
* type/model/visibility gained six divergences.
|
|
1698
2653
|
*/
|
|
1699
|
-
responses: withVisibility(
|
|
2654
|
+
responses: withVisibility(Visibility.Read, () => responsesOf(program, operation, registry)),
|
|
1700
2655
|
rawBodyProperty,
|
|
1701
2656
|
rawBodyBinary,
|
|
1702
2657
|
rawBodyUnvalidated,
|
|
@@ -1911,7 +2866,8 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
|
|
|
1911
2866
|
if (d.structural === undefined) {
|
|
1912
2867
|
return `\nexport const ${d.identifier} = ${compiled(d.source, compileSchemas)};\nexport type ${d.typeName} = z.infer<typeof ${d.identifier}>;\n`;
|
|
1913
2868
|
}
|
|
1914
|
-
|
|
2869
|
+
// One object literal is an interface; a union of them - an envelope's wrappers - is not.
|
|
2870
|
+
const declared = isSingleObjectLiteral(d.structural)
|
|
1915
2871
|
? `export interface ${d.typeName} ${d.structural}`
|
|
1916
2872
|
: `export type ${d.typeName} = ${d.structural};`;
|
|
1917
2873
|
/**
|
|
@@ -2033,11 +2989,27 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
|
|
|
2033
2989
|
* reason `z` is imported only when used: an unused declaration fails the lint a generated file has
|
|
2034
2990
|
* to pass. Declared rather than imported, so this file still depends on no runtime.
|
|
2035
2991
|
*/
|
|
2036
|
-
const referenced = identifiersIn(routeDeclarations.join(""));
|
|
2992
|
+
const referenced = identifiersIn([...declarations, ...routeDeclarations].join(""));
|
|
2993
|
+
// Both decoders read their values through `wireRead`, so it is declared beside either.
|
|
2994
|
+
const expands = referenced.has("uriExpansion") ||
|
|
2995
|
+
referenced.has("explodedQuery") ||
|
|
2996
|
+
referenced.has("wireReadObject");
|
|
2997
|
+
// And `wireReadAs` reads through `wireRead`, which a multipart text part calls on its own.
|
|
2998
|
+
const readsAs = expands || referenced.has("wireReadAs");
|
|
2999
|
+
if (readsAs || referenced.has("wireRead"))
|
|
3000
|
+
parts.push(WIRE_READ_SOURCE);
|
|
3001
|
+
if (readsAs)
|
|
3002
|
+
parts.push(WIRE_READ_AS_SOURCE);
|
|
3003
|
+
if (expands)
|
|
3004
|
+
parts.push(WIRE_READERS_SOURCE);
|
|
2037
3005
|
if (referenced.has("uriExpansion"))
|
|
2038
3006
|
parts.push(URI_EXPANSION_SOURCE);
|
|
2039
3007
|
if (referenced.has("explodedQuery"))
|
|
2040
3008
|
parts.push(EXPLODED_QUERY_SOURCE);
|
|
3009
|
+
if (referenced.has("wireReadObject"))
|
|
3010
|
+
parts.push(WIRE_READ_OBJECT_SOURCE);
|
|
3011
|
+
if (referenced.has("wireItself"))
|
|
3012
|
+
parts.push(WIRE_ITSELF_SOURCE);
|
|
2041
3013
|
if (routeDeclarations.length > 0) {
|
|
2042
3014
|
parts.push(`
|
|
2043
3015
|
/** One declared response: its status key, its body's schema, its media types and its headers. */
|
|
@@ -2063,14 +3035,55 @@ type ResponseArm = {
|
|
|
2063
3035
|
* keeps it, so the generated file does not fill up with aliases that alias nothing.
|
|
2064
3036
|
*/
|
|
2065
3037
|
const isIdentifier = (expression) => /^[A-Za-z_$][\w$]*$/.test(expression);
|
|
2066
|
-
|
|
3038
|
+
/**
|
|
3039
|
+
* The identifier a route's validator is exported under: the operation's id and the part it checks.
|
|
3040
|
+
*
|
|
3041
|
+
* **Never one of the helpers the module declares beside it.** An operation named `exploded` has a
|
|
3042
|
+
* query validator called `explodedQuery`, which is also the name of the function that gathers an
|
|
3043
|
+
* exploded query object, and the module did not parse. Such a name takes a trailing `_`, as a model
|
|
3044
|
+
* named after an emitted helper does.
|
|
3045
|
+
*/
|
|
3046
|
+
const schemaConst = (operationId, suffix, taken) => {
|
|
3047
|
+
/**
|
|
3048
|
+
* **And always an identifier, and never one already taken.** `@operationId("get-widget")` is a
|
|
3049
|
+
* valid id and not a valid name: `export const get-widgetQuery` did not parse. Each character no
|
|
3050
|
+
* identifier may hold becomes `_`, and a name another operation already took - `get_widget`
|
|
3051
|
+
* beside `get-widget` - takes one more.
|
|
3052
|
+
*/
|
|
3053
|
+
let name = `${operationId.replace(/[^\w$]/g, "_").replace(/^(\d)/, "_$1")}${suffix}`;
|
|
3054
|
+
while (EMITTED_FUNCTIONS.has(name) || taken.has(name))
|
|
3055
|
+
name = `${name}_`;
|
|
3056
|
+
taken.add(name);
|
|
3057
|
+
return name;
|
|
3058
|
+
};
|
|
3059
|
+
/**
|
|
3060
|
+
* Text written inside a block comment, which `*\/` would end: `@operationId("list*\/items")` is a
|
|
3061
|
+
* valid id, and the comment naming it closed early and left the rest of the line as code.
|
|
3062
|
+
*/
|
|
3063
|
+
function commentSafe(text) {
|
|
3064
|
+
// And printable ASCII, as every generated file is: a letter outside it is written as its escape.
|
|
3065
|
+
return text
|
|
3066
|
+
.replaceAll("*/", "*\\/")
|
|
3067
|
+
.replace(/[^\x20-\x7e]/gu, (each) => `\\u{${(each.codePointAt(0) ?? 0).toString(16)}}`);
|
|
3068
|
+
}
|
|
3069
|
+
/** The functions a schemas module may declare for itself - see `helperSources`. */
|
|
3070
|
+
const EMITTED_FUNCTIONS = new Set([
|
|
3071
|
+
"uriExpansion",
|
|
3072
|
+
"explodedQuery",
|
|
3073
|
+
"wireRead",
|
|
3074
|
+
"wireReadAs",
|
|
3075
|
+
"wireReaderOf",
|
|
3076
|
+
"wireReadObject",
|
|
3077
|
+
"wireItself",
|
|
3078
|
+
"wireSentAsItself",
|
|
3079
|
+
]);
|
|
2067
3080
|
/** `[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) {
|
|
3081
|
+
function namedSchema(operationId, suffix, expression, compileSchemas, taken) {
|
|
2069
3082
|
if (expression === undefined)
|
|
2070
3083
|
return { name: undefined, declaration: undefined };
|
|
2071
3084
|
if (isIdentifier(expression))
|
|
2072
3085
|
return { name: expression, declaration: undefined };
|
|
2073
|
-
const name = schemaConst(operationId, suffix);
|
|
3086
|
+
const name = schemaConst(operationId, suffix, taken);
|
|
2074
3087
|
/**
|
|
2075
3088
|
* **Exported, because a schema nothing outside this file can name is a schema nothing can
|
|
2076
3089
|
* check.** These are the validators the server actually enforces on every request - the path,
|
|
@@ -2127,10 +3140,11 @@ function sharedSlots(routes) {
|
|
|
2127
3140
|
function nameRouteSchemas(routes, compileSchemas) {
|
|
2128
3141
|
const shared = sharedSlots(routes);
|
|
2129
3142
|
const declarations = [];
|
|
3143
|
+
const taken = new Set();
|
|
2130
3144
|
const names = new Map();
|
|
2131
3145
|
for (const route of routes) {
|
|
2132
3146
|
const declare = (suffix, expression) => {
|
|
2133
|
-
const resolved = namedSchema(route.operationId, suffix, expression, compileSchemas);
|
|
3147
|
+
const resolved = namedSchema(route.operationId, suffix, expression, compileSchemas, taken);
|
|
2134
3148
|
if (resolved.declaration !== undefined)
|
|
2135
3149
|
declarations.push(resolved.declaration);
|
|
2136
3150
|
return resolved.name;
|
|
@@ -2151,6 +3165,9 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
2151
3165
|
const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
|
|
2152
3166
|
// A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
|
|
2153
3167
|
const body = route.rawBodyProperty === undefined ? declare("Body", route.requestSchema) : undefined;
|
|
3168
|
+
const formBody = route.rawBodyProperty === undefined
|
|
3169
|
+
? declare("FormBody", route.formRequestSchema)
|
|
3170
|
+
: undefined;
|
|
2154
3171
|
/**
|
|
2155
3172
|
* One identifier per distinct body, suffixed by the status that first carries it. Two statuses
|
|
2156
3173
|
* sharing one inline body share its declaration rather than emitting it twice.
|
|
@@ -2180,7 +3197,7 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
2180
3197
|
}
|
|
2181
3198
|
return { status: response.status, schema: declaredBodies.get(response.schema), headers };
|
|
2182
3199
|
});
|
|
2183
|
-
const responses = schemaConst(route.operationId, "Responses");
|
|
3200
|
+
const responses = schemaConst(route.operationId, "Responses", taken);
|
|
2184
3201
|
declarations.push(`export const ${responses} = ${responseArmsOf(route, arms)} satisfies readonly ResponseArm[];`);
|
|
2185
3202
|
names.set(route.operationId, {
|
|
2186
3203
|
operationId: route.operationId,
|
|
@@ -2191,6 +3208,7 @@ function nameRouteSchemas(routes, compileSchemas) {
|
|
|
2191
3208
|
selectedAccept: negotiating ? route.accept : undefined,
|
|
2192
3209
|
cookie,
|
|
2193
3210
|
body,
|
|
3211
|
+
...(formBody === undefined ? {} : { formBody }),
|
|
2194
3212
|
arms,
|
|
2195
3213
|
responses,
|
|
2196
3214
|
});
|
|
@@ -2262,11 +3280,10 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2262
3280
|
* caller satisfying the published type sent read-only fields and a strict validator answered
|
|
2263
3281
|
* 400.
|
|
2264
3282
|
*
|
|
2265
|
-
*
|
|
2266
|
-
*
|
|
2267
|
-
* for the wrong program.
|
|
3283
|
+
* The predicates `propertyToTs` consults read the visibility `withVisibility` sets, and outside
|
|
3284
|
+
* one they read `Read`.
|
|
2268
3285
|
*/
|
|
2269
|
-
withVisibility(program,
|
|
3286
|
+
withVisibility(requestVisibilityOf(program, operation), () => withRequestReading(program, requestVisibilityOf(program, operation), () => {
|
|
2270
3287
|
const body = requestBodyOf(operation);
|
|
2271
3288
|
const placement = requestBodyPlacementOf(program, operation);
|
|
2272
3289
|
/** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
|
|
@@ -2278,7 +3295,7 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2278
3295
|
* beside the payload, they are arguments to the verification. Leaving them out of the wire
|
|
2279
3296
|
* type would describe an input the application cannot act on.
|
|
2280
3297
|
*/
|
|
2281
|
-
const extraProperties = operation
|
|
3298
|
+
const extraProperties = publishedParametersOf(operation)
|
|
2282
3299
|
.filter((parameter) => parameter.type === "path" ||
|
|
2283
3300
|
parameter.type === "query" ||
|
|
2284
3301
|
parameter.type === "header" ||
|
|
@@ -2290,9 +3307,16 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2290
3307
|
* `"x-thing"` here too: a consumer checking its handlers against these types otherwise
|
|
2291
3308
|
* fails to typecheck against a server that is correct.
|
|
2292
3309
|
*/
|
|
3310
|
+
// One value per name and location, as the document publishes the first declaration.
|
|
3311
|
+
.filter((parameter, index, all) => all.findIndex((other) => sameWireName(other, parameter)) === index)
|
|
2293
3312
|
.map((parameter) => ({
|
|
2294
3313
|
key: parameter.name,
|
|
2295
|
-
|
|
3314
|
+
// At the visibility its shared component is published at - see `parameterVisibilityOf`.
|
|
3315
|
+
// Refused as `never`, as its validator is - a header case collision, or a `@useRef`.
|
|
3316
|
+
line: headerCaseCollisionOf(program, operation, parameter) ||
|
|
3317
|
+
externalReferenceOf(parameter.param) !== undefined
|
|
3318
|
+
? `${objectKey(parameter.name)}${parameter.param.optional ? "?" : ""}: never;`
|
|
3319
|
+
: withVisibility(parameterVisibilityOf(parameter.param) ?? currentVisibility(), () => registry.expressionForProperty(parameter.param, parameter.name)),
|
|
2296
3320
|
}));
|
|
2297
3321
|
const fileContentType = fileContentTypeOf(operation);
|
|
2298
3322
|
if (fileContentType !== undefined) {
|
|
@@ -2326,15 +3350,16 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2326
3350
|
* it, though the input is the text or the bytes that arrived. 0.27.0 declared it, and a
|
|
2327
3351
|
* consumer importing it lost the import when the body became raw.
|
|
2328
3352
|
*/
|
|
2329
|
-
if (body !== undefined && declaresComponent(body))
|
|
2330
|
-
registry.expressionFor(body);
|
|
3353
|
+
if (body !== undefined && declaresComponent(body)) {
|
|
3354
|
+
withMetadataAnnotationsIgnored(ignoresMetadata(parameterBody), () => registry.expressionFor(body));
|
|
3355
|
+
}
|
|
2331
3356
|
}
|
|
2332
3357
|
else {
|
|
2333
3358
|
const expression = parameterBody?.bodyKind === "multipart"
|
|
2334
|
-
? multipartTsOf(
|
|
3359
|
+
? multipartTsOf(program, parameterBody, registry)
|
|
2335
3360
|
: body === undefined
|
|
2336
3361
|
? undefined
|
|
2337
|
-
: registry.expressionFor(body);
|
|
3362
|
+
: withMetadataAnnotationsIgnored(ignoresMetadata(parameterBody), () => registry.expressionFor(body, true));
|
|
2338
3363
|
if (parameterBody?.bodyKind !== "multipart")
|
|
2339
3364
|
bodyRef = expression;
|
|
2340
3365
|
if (expression !== undefined) {
|
|
@@ -2351,10 +3376,12 @@ function collectRequestTypes(program, registry, service) {
|
|
|
2351
3376
|
entries.push({
|
|
2352
3377
|
operationId: operationIdOf(program, operation.operation),
|
|
2353
3378
|
bodyRef,
|
|
2354
|
-
input: parts.length === 0
|
|
3379
|
+
input: parts.length === 0
|
|
3380
|
+
? "globalThis.Record<string, never>"
|
|
3381
|
+
: `Simplify<${parts.join(" & ")}>`,
|
|
2355
3382
|
streamsBody,
|
|
2356
3383
|
});
|
|
2357
|
-
});
|
|
3384
|
+
}));
|
|
2358
3385
|
}
|
|
2359
3386
|
return entries;
|
|
2360
3387
|
}
|
|
@@ -2368,7 +3395,7 @@ function collectResponseTypes(program, registry, service) {
|
|
|
2368
3395
|
* components under the wrong suffix. Saying it here rather than relying on the default is what
|
|
2369
3396
|
* makes that impossible from either direction.
|
|
2370
3397
|
*/
|
|
2371
|
-
withVisibility(
|
|
3398
|
+
withVisibility(Visibility.Read, () => {
|
|
2372
3399
|
for (const operation of service.operations) {
|
|
2373
3400
|
/**
|
|
2374
3401
|
* **The same responses, and the same members, the server emitters render.** This walk used
|
|
@@ -2394,7 +3421,8 @@ function collectResponseTypes(program, registry, service) {
|
|
|
2394
3421
|
if (member.unvalidated &&
|
|
2395
3422
|
member.response.body !== undefined &&
|
|
2396
3423
|
declaresComponent(member.response.body)) {
|
|
2397
|
-
|
|
3424
|
+
const declared = member.response.body;
|
|
3425
|
+
withMetadataAnnotationsIgnored(member.response.ignoresMetadata, () => registry.expressionFor(declared));
|
|
2398
3426
|
}
|
|
2399
3427
|
switch (member.serving) {
|
|
2400
3428
|
case "none":
|
|
@@ -2408,9 +3436,10 @@ function collectResponseTypes(program, registry, service) {
|
|
|
2408
3436
|
case "unvalidated":
|
|
2409
3437
|
return "string";
|
|
2410
3438
|
default: {
|
|
2411
|
-
|
|
3439
|
+
const declared = member.response.body;
|
|
3440
|
+
if (declared === undefined)
|
|
2412
3441
|
return undefined;
|
|
2413
|
-
const ref =
|
|
3442
|
+
const ref = withMetadataAnnotationsIgnored(member.response.ignoresMetadata, () => registry.expressionFor(declared, true));
|
|
2414
3443
|
bodyRefs.push(ref);
|
|
2415
3444
|
return `Produced<${ref}>`;
|
|
2416
3445
|
}
|
|
@@ -2434,8 +3463,8 @@ function collectResponseTypes(program, registry, service) {
|
|
|
2434
3463
|
const every = headers.every(({ property }) => property.optional);
|
|
2435
3464
|
const rendered = headers
|
|
2436
3465
|
.map(({ name, property }) => property.optional
|
|
2437
|
-
? `readonly ${objectKey(name)}?: ${
|
|
2438
|
-
: `readonly ${objectKey(name)}: ${
|
|
3466
|
+
? `readonly ${objectKey(name)}?: ${headerTypeOf(program, property)} | undefined`
|
|
3467
|
+
: `readonly ${objectKey(name)}: ${headerTypeOf(program, property)}`)
|
|
2439
3468
|
.join("; ");
|
|
2440
3469
|
fields.push(`readonly headers${every ? "?" : ""}: { ${rendered} }`);
|
|
2441
3470
|
}
|
|
@@ -2493,9 +3522,9 @@ const BYTE_STREAM = `
|
|
|
2493
3522
|
*/
|
|
2494
3523
|
export interface ByteStream {
|
|
2495
3524
|
getReader(): {
|
|
2496
|
-
read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>;
|
|
3525
|
+
read(): globalThis.Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>;
|
|
2497
3526
|
releaseLock(): void;
|
|
2498
|
-
cancel?(reason?: unknown): Promise<void>;
|
|
3527
|
+
cancel?(reason?: unknown): globalThis.Promise<void>;
|
|
2499
3528
|
};
|
|
2500
3529
|
}
|
|
2501
3530
|
`;
|
|
@@ -2532,7 +3561,7 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
2532
3561
|
: `\nexport type ${declaration.name} = ${declaration.source};\n`;
|
|
2533
3562
|
});
|
|
2534
3563
|
const responseLookup = responses
|
|
2535
|
-
.map((entry) => `\t/** Every success body \`${entry.operationId}\` declares. */\n\t${entry.operationId}: ${entry.output};`)
|
|
3564
|
+
.map((entry) => `\t/** Every success body \`${commentSafe(entry.operationId)}\` declares. */\n\t${objectKey(entry.operationId)}: ${entry.output};`)
|
|
2536
3565
|
.join("\n");
|
|
2537
3566
|
/**
|
|
2538
3567
|
* **One member per declared response and media type, each pairing a status with its own body.**
|
|
@@ -2540,7 +3569,7 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
2540
3569
|
* over a service binding returns `WireResults[op]`, and a handler can hand it straight on.
|
|
2541
3570
|
*/
|
|
2542
3571
|
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("")};`)
|
|
3572
|
+
.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
3573
|
.join("\n");
|
|
2545
3574
|
/**
|
|
2546
3575
|
* **Keyed by operation id, and the only name an operation's input has.**
|
|
@@ -2553,7 +3582,7 @@ function renderRequestTypes(entries, responses, registry) {
|
|
|
2553
3582
|
* `WireInputs["createUser"]` is the same type, and nothing a spec declares can collide with it.
|
|
2554
3583
|
*/
|
|
2555
3584
|
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")};`)
|
|
3585
|
+
.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
3586
|
.join("\n");
|
|
2558
3587
|
/**
|
|
2559
3588
|
* **Emitted only where an operation has something to flatten.**
|
|
@@ -2598,8 +3627,9 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
|
2598
3627
|
* one service: 2 contract methods returning \`readonly T[]\` and 35 readonly array properties, none
|
|
2599
3628
|
* of them expressible against the published shape.
|
|
2600
3629
|
*/
|
|
2601
|
-
export type Produced<T> = T extends readonly
|
|
2602
|
-
?
|
|
3630
|
+
export type Produced<T> = T extends readonly unknown[]
|
|
3631
|
+
? // Mapped over the list itself, so a tuple keeps its positions: \`[A?, ...unknown[]]\`.
|
|
3632
|
+
{ readonly [K in keyof T]: Produced<T[K]> }
|
|
2603
3633
|
: T extends object
|
|
2604
3634
|
? { readonly [K in keyof T]: Produced<T[K]> }
|
|
2605
3635
|
: T;
|
|
@@ -2700,7 +3730,7 @@ function renderWireAssertions(schemas, types, entries, responses, contractsPacka
|
|
|
2700
3730
|
}
|
|
2701
3731
|
if (paired.length === 0)
|
|
2702
3732
|
return undefined;
|
|
2703
|
-
const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.input<typeof ${pair.identifier}>>, Contracts.${pair.name}
|
|
3733
|
+
const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<Declared<z.input<typeof ${pair.identifier}>>, Flat<Contracts.${pair.name}>>>;`);
|
|
2704
3734
|
return `${generatedBanner()}
|
|
2705
3735
|
import type { z } from "zod";
|
|
2706
3736
|
import type * as Contracts from "${contractsPackage}";
|
|
@@ -2762,10 +3792,9 @@ type DeclaredKeyOf<T> = keyof {
|
|
|
2762
3792
|
|
|
2763
3793
|
type Declared<T> = T extends (...args: never[]) => unknown
|
|
2764
3794
|
? T
|
|
2765
|
-
: T extends readonly
|
|
2766
|
-
?
|
|
2767
|
-
|
|
2768
|
-
: readonly Declared<Element>[]
|
|
3795
|
+
: T extends readonly unknown[]
|
|
3796
|
+
? // Mapped over the list itself: a tuple keeps its positions, and \`readonly\` is kept as it is.
|
|
3797
|
+
{ [K in keyof T]: Declared<T[K]> }
|
|
2769
3798
|
: T extends object
|
|
2770
3799
|
? [DeclaredKeyOf<T>] extends [never]
|
|
2771
3800
|
? [T[keyof T]] extends [never]
|
|
@@ -2792,6 +3821,24 @@ type Declared<T> = T extends (...args: never[]) => unknown
|
|
|
2792
3821
|
}
|
|
2793
3822
|
: T;
|
|
2794
3823
|
|
|
3824
|
+
/**
|
|
3825
|
+
* **The contract type with its intersections written out**, as \`Declared\` writes out the validator's:
|
|
3826
|
+
* \`(A) & (Inner)\` - a redeclaration merged with a \`@oneOf\` union it cannot be merged into key by key
|
|
3827
|
+
* - and the one object type a mapping makes of it are one shape, which the identity check below tells
|
|
3828
|
+
* apart by spelling. Mapped and nothing dropped, so an index signature the contract states is still
|
|
3829
|
+
* compared.
|
|
3830
|
+
*/
|
|
3831
|
+
type Flat<T> = T extends (...args: never[]) => unknown
|
|
3832
|
+
? T
|
|
3833
|
+
: T extends object
|
|
3834
|
+
? [keyof T] extends [never]
|
|
3835
|
+
? // **Every object with no key is the one \`{}\`**, as \`Declared\` writes an empty object: an
|
|
3836
|
+
// empty interface beside an empty literal, \`M1 | {}\`, is otherwise two members where
|
|
3837
|
+
// the validator's side is one.
|
|
3838
|
+
{}
|
|
3839
|
+
: { [K in keyof T]: Flat<T[K]> }
|
|
3840
|
+
: T;
|
|
3841
|
+
|
|
2795
3842
|
/**
|
|
2796
3843
|
* **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
|
|
2797
3844
|
* type when the two disagree, and a type alias to an odd type compiles fine - 66 assertions that
|
|
@@ -2963,124 +4010,190 @@ export async function emitHttpZod(context) {
|
|
|
2963
4010
|
for (const snapshot of snapshots) {
|
|
2964
4011
|
beginOperationIds();
|
|
2965
4012
|
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
4013
|
/**
|
|
2973
|
-
* **
|
|
2974
|
-
*
|
|
2975
|
-
*
|
|
2976
|
-
*
|
|
2977
|
-
*
|
|
2978
|
-
*
|
|
2979
|
-
*
|
|
2980
|
-
* package specifier has to be set per service.
|
|
4014
|
+
* **Rendered whole before anything is written.** This service's view, its operation ids and
|
|
4015
|
+
* its banner are module state, and an `await` while they are in force lets another compile in
|
|
4016
|
+
* the same process run in between: the contract types were walked after `schemas.gen.ts` was
|
|
4017
|
+
* written, and with three fixtures compiled at once in one suite, one walked them under
|
|
4018
|
+
* another program's view - its components unsuffixed, `DerivedK` declared twice, its wire
|
|
4019
|
+
* contract unable to pair a validator with its type. Written after the scope closes, nothing
|
|
4020
|
+
* awaits inside it.
|
|
2981
4021
|
*/
|
|
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());
|
|
4022
|
+
const writes = [];
|
|
4023
|
+
// Named and written as openapi3 names and writes this service's components - see `published.ts`.
|
|
4024
|
+
const restoreView = beginServiceView(context.program, service.namespace);
|
|
4025
|
+
try {
|
|
3001
4026
|
/**
|
|
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.
|
|
4027
|
+
* **A union that reaches itself through unions alone is refused before anything is walked,
|
|
4028
|
+
* and the service emits nothing.** Every other refusal is reported and walked past, so one
|
|
4029
|
+
* compile names them all; this one cannot be walked past, because every reading of it - the
|
|
4030
|
+
* schema, the type, a parameter's forms - recurses without end. The rest of the service is
|
|
4031
|
+
* read once the cycle is broken. See `reachesItselfThroughUnions`.
|
|
3009
4032
|
*/
|
|
3010
|
-
const
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
4033
|
+
const selfReaching = [...publishedTypes()].filter((type) => type.kind === "Union" &&
|
|
4034
|
+
type.name !== undefined &&
|
|
4035
|
+
reachesItselfThroughUnions(context.program, type));
|
|
4036
|
+
for (const union of selfReaching)
|
|
4037
|
+
refuseSelfReachingUnion(context.program, union);
|
|
4038
|
+
if (selfReaching.length > 0)
|
|
4039
|
+
continue;
|
|
4040
|
+
const serviceName = service.namespace.name;
|
|
4041
|
+
const perService = options.services?.[serviceName];
|
|
4042
|
+
const outputDir = perService?.["output-dir"] ??
|
|
4043
|
+
(distinctServices.size > 1
|
|
4044
|
+
? resolvePath(context.emitterOutputDir, serviceName)
|
|
4045
|
+
: context.emitterOutputDir);
|
|
3020
4046
|
/**
|
|
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.
|
|
4047
|
+
* **Split where two services would write one file, and nowhere else.** A shared contracts
|
|
4048
|
+
* directory was written once per service to one path, so the second service's
|
|
4049
|
+
* `requests.gen.ts` replaced the first's and a whole surface lost its contracts with no
|
|
4050
|
+
* diagnostic. Counted over the services that WRITE there: one service falling back to the
|
|
4051
|
+
* shared directory, beside others that name their own, collides with nothing, and splitting
|
|
4052
|
+
* it moved contracts a project already imported. A relative `contracts-package` is resolved
|
|
4053
|
+
* from each service's own `wire-contract.gen.ts`, so it names that service's contracts; a
|
|
4054
|
+
* package specifier has to be set per service.
|
|
3031
4055
|
*/
|
|
3032
|
-
const
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
4056
|
+
const sharedContractsDir = options["contracts-output-dir"];
|
|
4057
|
+
const resolved = {
|
|
4058
|
+
contractsOutputDir: perService?.["contracts-output-dir"] ??
|
|
4059
|
+
(sharedContractsDir !== undefined && sharingContracts.length > 1
|
|
4060
|
+
? resolvePath(sharedContractsDir, serviceName)
|
|
4061
|
+
: sharedContractsDir),
|
|
4062
|
+
contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
|
|
4063
|
+
sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
|
|
4064
|
+
compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
|
|
4065
|
+
keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
|
|
4066
|
+
regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
|
|
3038
4067
|
};
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
4068
|
+
beginBanner(resolved.regenerateHint);
|
|
4069
|
+
const registry = new SchemaRegistry(context.program);
|
|
4070
|
+
const { result: { routes }, imports: externals, vocabularies, } = collectExternalImports(() => withContractsPackage(resolved.contractsPackage, () => withSealedObjects(resolved.sealObjectSchemas, () => {
|
|
4071
|
+
// Declarations first: a vocabulary is a fact about the contract, so it is collected from
|
|
4072
|
+
// what the service DECLARES rather than from what the Zod walk happens to touch. An enum
|
|
4073
|
+
// reachable only through an error body was silently absent otherwise.
|
|
4074
|
+
noteDeclaredVocabularies(service.namespace.enums.values());
|
|
4075
|
+
/**
|
|
4076
|
+
* A closed key SET is a vocabulary too, and it cannot be an enum.
|
|
4077
|
+
*
|
|
4078
|
+
* TypeSpec has no key-constrained `Record`, so a model naming its permitted properties is
|
|
4079
|
+
* how the document states one - and a generated type cannot be iterated, which leaves a
|
|
4080
|
+
* consumer needing the set at run time with a hand-maintained copy. Naming the model in
|
|
4081
|
+
* `key-vocabularies` writes the keys beside the types. A name matching nothing is
|
|
4082
|
+
* reported rather than quietly emitting no tuple.
|
|
4083
|
+
*/
|
|
4084
|
+
const emittedKeyVocabularies = noteKeyVocabularies(context.program, service.namespace.models.values(), resolved.keyVocabularies);
|
|
4085
|
+
for (const name of resolved.keyVocabularies) {
|
|
4086
|
+
if (emittedKeyVocabularies.includes(name))
|
|
4087
|
+
continue;
|
|
4088
|
+
reportDiagnostic(context.program, {
|
|
4089
|
+
code: "unknown-key-vocabulary",
|
|
4090
|
+
target: service.namespace,
|
|
4091
|
+
format: { name, service: service.namespace.name },
|
|
4092
|
+
});
|
|
4093
|
+
}
|
|
4094
|
+
/**
|
|
4095
|
+
* The error body is registered INSIDE this walk, so its validator lands in
|
|
4096
|
+
* `schemas.gen.ts` alongside every other declaration. Nothing else reaches it: the Zod
|
|
4097
|
+
* walk follows success bodies and parameters, so without this a runtime would have no
|
|
4098
|
+
* generated schema to check the documents it sends against.
|
|
4099
|
+
*
|
|
4100
|
+
* **Its expression is registered and then deliberately discarded.** It used to be
|
|
4101
|
+
* returned and used as a fallback for any response arm whose own body could not be
|
|
4102
|
+
* resolved, which meant every catch-all answered with whichever shape `errorBodyOf` had
|
|
4103
|
+
* settled on rather than the one its own response declares. Each arm resolves its own
|
|
4104
|
+
* body now; registering this one only ensures it is declared.
|
|
4105
|
+
*/
|
|
4106
|
+
const errorBody = errorBodyOf(context.program, service);
|
|
4107
|
+
if (errorBody !== undefined) {
|
|
4108
|
+
withVisibility(Visibility.Read, () => registry.expressionFor(errorBody));
|
|
4109
|
+
}
|
|
4110
|
+
const routes = withStreamModule(streamModule, () => collectRoutes(context.program, registry, service));
|
|
4111
|
+
// And every type no operation reaches, as openapi3 publishes it - see `unreachedTypes`.
|
|
4112
|
+
withVisibility(Visibility.All, () => {
|
|
4113
|
+
for (const type of unreachedTypes())
|
|
4114
|
+
registry.expressionFor(type);
|
|
4115
|
+
});
|
|
4116
|
+
// And every component openapi3 writes that no route names - see `publishedComponents`.
|
|
4117
|
+
for (const { type, at, ignore, binary } of publishedComponents(context.program)) {
|
|
4118
|
+
withVisibility(at, () => withMetadataAnnotationsIgnored(ignore, () => withMultipartBinary(binary, () => registry.declare(type))));
|
|
4119
|
+
}
|
|
4120
|
+
return { routes };
|
|
4121
|
+
})));
|
|
3053
4122
|
/**
|
|
3054
|
-
* **
|
|
3055
|
-
*
|
|
3056
|
-
*
|
|
4123
|
+
* **Named AFTER the walk and BEFORE the file is rendered.** Naming mints identifiers that
|
|
4124
|
+
* `schemas.gen.ts` must declare, so it has to happen while the registry is complete and before
|
|
4125
|
+
* anything is written - and it has to happen HERE rather than in a server emitter, or the
|
|
4126
|
+
* validators it produces never reach this package's own output.
|
|
3057
4127
|
*/
|
|
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),
|
|
4128
|
+
const { names, declarations } = nameRouteSchemas(routes, resolved.compileSchemas);
|
|
4129
|
+
writes.push({
|
|
4130
|
+
path: resolvePath(outputDir, "schemas.gen.ts"),
|
|
4131
|
+
content: renderSchemas(registry, externals, declarations, resolved.compileSchemas),
|
|
3069
4132
|
});
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
4133
|
+
if (resolved.contractsOutputDir !== undefined) {
|
|
4134
|
+
const types = new TypeRegistry(context.program, registry.held);
|
|
4135
|
+
/**
|
|
4136
|
+
* **Inside the same stream scope as the route walk**, because both read the same response
|
|
4137
|
+
* records: outside it `streamedTypeOf` cannot see `@typespec/streams`, so an SSE body was
|
|
4138
|
+
* stated as the string the document describes rather than the stream a server sends.
|
|
4139
|
+
*/
|
|
4140
|
+
/**
|
|
4141
|
+
* **And inside the same sealing**, because whether a model is published at all can depend
|
|
4142
|
+
* on it - a template instance extending a base openapi3 seals is refused on both sides,
|
|
4143
|
+
* `sealed-base-extended` - and the two walks have to agree about that.
|
|
4144
|
+
*/
|
|
4145
|
+
const { requests, assertions } = withSealedObjects(resolved.sealObjectSchemas, () => {
|
|
4146
|
+
const { entries, responses } = withStreamModule(streamModule, () => ({
|
|
4147
|
+
entries: collectRequestTypes(context.program, types, service),
|
|
4148
|
+
responses: collectResponseTypes(context.program, types, service),
|
|
4149
|
+
}));
|
|
4150
|
+
withVisibility(Visibility.All, () => {
|
|
4151
|
+
for (const type of unreachedTypes())
|
|
4152
|
+
types.expressionFor(type);
|
|
4153
|
+
});
|
|
4154
|
+
for (const { type, at, ignore, binary } of publishedComponents(context.program)) {
|
|
4155
|
+
withVisibility(at, () => withMetadataAnnotationsIgnored(ignore, () => withMultipartBinary(binary, () => types.declare(type))));
|
|
4156
|
+
}
|
|
4157
|
+
types.refuse(registry.refusedNames());
|
|
4158
|
+
return {
|
|
4159
|
+
requests: renderRequestTypes(entries, responses, types),
|
|
4160
|
+
assertions: renderWireAssertions(registry, types, entries, responses, resolved.contractsPackage),
|
|
4161
|
+
};
|
|
3075
4162
|
});
|
|
4163
|
+
writes.push({
|
|
4164
|
+
path: resolvePath(resolved.contractsOutputDir, "requests.gen.ts"),
|
|
4165
|
+
content: requests,
|
|
4166
|
+
});
|
|
4167
|
+
writes.push({
|
|
4168
|
+
path: resolvePath(resolved.contractsOutputDir, "vocabularies.gen.ts"),
|
|
4169
|
+
content: renderVocabularies(vocabularies),
|
|
4170
|
+
});
|
|
4171
|
+
if (assertions !== undefined) {
|
|
4172
|
+
writes.push({
|
|
4173
|
+
path: resolvePath(outputDir, "wire-contract.gen.ts"),
|
|
4174
|
+
content: assertions,
|
|
4175
|
+
});
|
|
4176
|
+
}
|
|
3076
4177
|
}
|
|
4178
|
+
// Still inside this service's id scope, so each operation resolves to the id its route carries.
|
|
4179
|
+
const operations = new Map(service.operations.map((operation) => [
|
|
4180
|
+
operationIdOf(context.program, operation.operation),
|
|
4181
|
+
operation,
|
|
4182
|
+
]));
|
|
4183
|
+
emitted.push({
|
|
4184
|
+
service,
|
|
4185
|
+
routes,
|
|
4186
|
+
schemaNames: names,
|
|
4187
|
+
operations,
|
|
4188
|
+
outputDir,
|
|
4189
|
+
options: resolved,
|
|
4190
|
+
});
|
|
4191
|
+
}
|
|
4192
|
+
finally {
|
|
4193
|
+
restoreView();
|
|
3077
4194
|
}
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
operationIdOf(context.program, operation.operation),
|
|
3081
|
-
operation,
|
|
3082
|
-
]));
|
|
3083
|
-
emitted.push({ service, routes, schemaNames: names, operations, outputDir, options: resolved });
|
|
4195
|
+
for (const write of writes)
|
|
4196
|
+
await emitFile(context.program, write);
|
|
3084
4197
|
}
|
|
3085
4198
|
return emitted;
|
|
3086
4199
|
}
|