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