typespec-http-zod 0.31.0 → 0.33.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 CHANGED
@@ -50,6 +50,9 @@ And the OpenAPI that [`@typespec/openapi3`](https://typespec.io) generates from
50
50
 
51
51
  - **Documentation and validation cannot drift** - both come from one definition, so there is no
52
52
  second source to keep in step
53
+ - **Agrees with the OpenAPI version you publish** - 3.0, `@typespec/openapi3`'s default, and 3.1 read
54
+ some schemas differently; the validators follow the `openapi-versions` you give openapi3, and refuse
55
+ at compile what a 3.0 document cannot say. See [OpenAPI versions](docs/guides.md#openapi-versions)
53
56
  - **One runtime dependency** - `zod`, and nothing else
54
57
  - **No framework** - use the validators in a server, a client, or a test
55
58
  - **Types with zero imports** - a contracts module anything can depend on
package/dist/src/api.d.ts CHANGED
@@ -575,6 +575,43 @@ export interface EmittedService {
575
575
  readonly routes: readonly EmittedRoute[];
576
576
  /** The identifiers every per-operation validator was declared under, keyed by operation id. */
577
577
  readonly schemaNames: ReadonlyMap<string, RouteSchemaNames>;
578
+ /**
579
+ * **The identifier each declared type's validator is exported under in `schemas.gen.ts`**, keyed
580
+ * by the type: a model, union or enum the document publishes as a component of its own. A scalar
581
+ * is validated inline wherever it is used and has no entry.
582
+ *
583
+ * **Keyed by the types this service was emitted from**, which are the ones `operations` holds: a
584
+ * walk from an operation's response bodies, through each base's `derivedModels`, reaches the keys
585
+ * themselves. For a versioned service those are the types projected to its latest version, not
586
+ * the types as declared, which hold every version at once.
587
+ *
588
+ * **A body is keyed by the model it is, its effective payload type.** Where visibility reshapes a
589
+ * model, `@typespec/http` hands its body over as a copy - `Widget` returned beside a create-only
590
+ * property is `{ ...Widget }` - which is not a key and has no `derivedModels`. openapi3 resolves it
591
+ * to `Widget` with `getEffectivePayloadType`, and so does a lookup:
592
+ * `createMetadataInfo(program, { canonicalVisibility: Visibility.Read })
593
+ * .getEffectivePayloadType(body.type, Visibility.Read)`.
594
+ *
595
+ * **The declaration under the type's own component name**, which is the one a response reads
596
+ * wherever a response reads the type: `Widget` -> `widgetSchema`, beside a create view
597
+ * `widgetCreateSchema` that is not in this map. `@friendlyName` names it and a reserved word takes
598
+ * the module's `_`, as the module declares it: `@friendlyName("Canine") model Hound` ->
599
+ * `canineSchema`, `` model `default` `` -> `default_Schema`. A multipart body's model is here,
600
+ * validated as the form the document publishes. A type the document publishes only under views of
601
+ * it, sent in create and update bodies and never returned, has none.
602
+ *
603
+ * **Every subtype of a discriminated base is here**, nested bases included and whether or not an
604
+ * operation names it, so a wrapping emitter can tell which subtype a value is by the subtype's
605
+ * own validator: the base's parsed output does not say which arm of its dispatch matched.
606
+ *
607
+ * **A declaration that validates nothing has no entry** - `duplicate-declaration`,
608
+ * `split-visibility` and every other refusal of a whole type - so a lookup answers `undefined`
609
+ * rather than a validator that refuses every value.
610
+ *
611
+ * Published so that an emitter building on this one imports a declared type's validator by name
612
+ * rather than agreeing with this package about how the name is spelled.
613
+ */
614
+ readonly typeSchemaNames: ReadonlyMap<Type, string>;
578
615
  /**
579
616
  * **The operation each route was collected from, keyed by operation id**, so a wrapping emitter
580
617
  * reading its doc comment, tags or diagnostic target reads the right one. Verb and path do not
package/dist/src/api.js CHANGED
@@ -1,8 +1,9 @@
1
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";
2
+ import { getAuthenticationForOperation, getVisibilitySuffix, isOrExtendsHttpFile, Visibility, } from "@typespec/http";
3
3
  import { getOperationId, resolveOperationId } from "@typespec/openapi";
4
4
  import { firstReportOf } from "./constraints.js";
5
5
  import { reportDiagnostic } from "./lib.js";
6
+ import { openApiVersionsOf, publishesOpenApi30AndLater } from "./openapi-versions.js";
6
7
  import { literal } from "./literal.js";
7
8
  import { beginServiceView, parameterVisibilityOf, publishedComponents, publishedTypes, requestVisibilityOf, unreachedTypes, withServiceView, } from "./published.js";
8
9
  import { isSingleObjectLiteral, SchemaRegistry, TypeRegistry } from "./registry.js";
@@ -13,7 +14,7 @@ import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
13
14
  import { EXPLODED_QUERY_SOURCE, explodedQueryCall, eachReader, NO_READERS, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, WIRE_READ_OBJECT_SOURCE, WIRE_READ_AS_SOURCE, WIRE_READ_SOURCE, WIRE_ITSELF_SOURCE, WIRE_READERS_SOURCE, wireItselfCall, wireReadObjectCall, } from "./uri-template.js";
14
15
  export { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
15
16
  export { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
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";
17
+ import { collectExternalImports, encodedTypeOf, externalReferenceOf, holdsRawBytes, isExclusive, isTypelessMember, nullableInnerOf, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, objectOfModel, reachesItselfThroughUnions, refuseExternalReference, refuseSelfReachingUnion, atItem, bindingIndexerOf, currentVisibility, dispatchedSubtypesOf, readsFromText, renderedVisibilityOf, rootVisibilityOf, withContractsPackage, withSealedObjects, withMetadataAnnotationsIgnored, requiredReadOnlyIn, reportUnpublishable, withRequestReading, withMultipartBinary, withParameterPosition, UNREPRESENTABLE, withVisibility, } from "./zod.js";
17
18
  /**
18
19
  * `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
19
20
  * service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
@@ -626,6 +627,33 @@ function bodyTypeOf(type) {
626
627
  * `undeclared-discriminator` refuses a discriminator the document cannot state. `void` says "no
627
628
  * body", and openapi3 publishes it as none.
628
629
  */
630
+ /**
631
+ * **A request holding a required read-only property, where openapi3 publishes 3.0 and a later
632
+ * version** - see `ambiguous-read-only`. The 3.0 document lets the request leave it out and the later
633
+ * one does not, so no request validator agrees with both.
634
+ */
635
+ function reportAmbiguousReadOnly(program, type, requested) {
636
+ if (!publishesOpenApi30AndLater(program))
637
+ return;
638
+ const property = requiredReadOnlyIn(program, type, rootVisibilityOf(program, type, requested), new Set());
639
+ if (property === undefined || !firstReportOf(program, "ambiguous-read-only", type))
640
+ return;
641
+ reportDiagnostic(program, {
642
+ code: "ambiguous-read-only",
643
+ target: type,
644
+ format: {
645
+ type: getTypeName(type),
646
+ property: property.model === undefined
647
+ ? property.name
648
+ : `${getTypeName(property.model)}.${property.name}`,
649
+ later: openApiVersionsOf(program)
650
+ .filter((version) => !version.startsWith("3.0."))
651
+ .map((version) => `OpenAPI ${version}`)
652
+ .join(" and "),
653
+ view: `${getVisibilitySuffix(requested, Visibility.Read)}<${getTypeName(property.model ?? type)}>`,
654
+ },
655
+ });
656
+ }
629
657
  function reportNeverBody(program, operation, body, position) {
630
658
  if (body?.bodyKind !== "single" || !isNeverType(body.type))
631
659
  return;
@@ -2490,6 +2518,7 @@ export function collectRoutes(program, registry, service) {
2490
2518
  }
2491
2519
  function collectServiceRoutes(program, registry, service) {
2492
2520
  const routes = [];
2521
+ reportUnpublishable(program, service.namespace);
2493
2522
  {
2494
2523
  for (const operation of service.operations) {
2495
2524
  /**
@@ -2583,7 +2612,7 @@ function collectServiceRoutes(program, registry, service) {
2583
2612
  * one and not the other, and `@visibility` can add or remove properties outright. Walking
2584
2613
  * both at one visibility gives whichever was seen first to both.
2585
2614
  */
2586
- ...withVisibility(requestVisibility, () => {
2615
+ ...withVisibility(requestVisibility, () => withRequestReading(program, requestVisibility, () => {
2587
2616
  // Multipart first: its body has no single type to walk, and skipping it left every
2588
2617
  // such route mounted with nothing checking the request at all.
2589
2618
  const parameterBody = requestBodyParameterOf(operation);
@@ -2595,6 +2624,7 @@ function collectServiceRoutes(program, registry, service) {
2595
2624
  }
2596
2625
  if (requestType === undefined)
2597
2626
  return { requestSchema: undefined };
2627
+ reportAmbiguousReadOnly(program, requestType, requestVisibility);
2598
2628
  const declared = withMetadataAnnotationsIgnored(ignoresMetadata(parameterBody), () => registry.expressionFor(requestType, true));
2599
2629
  const form = formBodyOf(program, registry, operation, parameterBody, requestType, declared);
2600
2630
  return form === undefined
@@ -2602,7 +2632,7 @@ function collectServiceRoutes(program, registry, service) {
2602
2632
  : form.only
2603
2633
  ? { requestSchema: form.schema }
2604
2634
  : { requestSchema: declared, formRequestSchema: form.schema };
2605
- }),
2635
+ })),
2606
2636
  ...(() => {
2607
2637
  const split = withVisibility(requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
2608
2638
  return {
@@ -3253,7 +3283,7 @@ function collectRequestTypes(program, registry, service) {
3253
3283
  * The predicates `propertyToTs` consults read the visibility `withVisibility` sets, and outside
3254
3284
  * one they read `Read`.
3255
3285
  */
3256
- withVisibility(requestVisibilityOf(program, operation), () => {
3286
+ withVisibility(requestVisibilityOf(program, operation), () => withRequestReading(program, requestVisibilityOf(program, operation), () => {
3257
3287
  const body = requestBodyOf(operation);
3258
3288
  const placement = requestBodyPlacementOf(program, operation);
3259
3289
  /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
@@ -3351,7 +3381,7 @@ function collectRequestTypes(program, registry, service) {
3351
3381
  : `Simplify<${parts.join(" & ")}>`,
3352
3382
  streamsBody,
3353
3383
  });
3354
- });
3384
+ }));
3355
3385
  }
3356
3386
  return entries;
3357
3387
  }
@@ -4154,6 +4184,7 @@ export async function emitHttpZod(context) {
4154
4184
  service,
4155
4185
  routes,
4156
4186
  schemaNames: names,
4187
+ typeSchemaNames: registry.typeIdentifiers(),
4157
4188
  operations,
4158
4189
  outputDir,
4159
4190
  options: resolved,
package/dist/src/lib.d.ts CHANGED
@@ -194,6 +194,15 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
194
194
  "never-body": {
195
195
  readonly default: import("@typespec/compiler").CallableMessage<["operationId", "position"]>;
196
196
  };
197
+ "unpublishable-null": {
198
+ readonly default: import("@typespec/compiler").CallableMessage<["type", "why"]>;
199
+ };
200
+ "unpublishable-record": {
201
+ readonly default: import("@typespec/compiler").CallableMessage<["type", "base", "base"]>;
202
+ };
203
+ "ambiguous-read-only": {
204
+ readonly default: import("@typespec/compiler").CallableMessage<["type", "property", "later", "view"]>;
205
+ };
197
206
  "unsatisfiable-encoding": {
198
207
  readonly default: import("@typespec/compiler").CallableMessage<["name", "declared", "encoded", "why"]>;
199
208
  };
@@ -225,7 +234,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
225
234
  readonly context: import("@typespec/compiler").CallableMessage<["name"]>;
226
235
  };
227
236
  }, EmitterOptions, never>;
228
- export declare const reportDiagnostic: <C extends "ambiguous-form-reading" | "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "header-case-collision" | "inline-cycle" | "invalid-default" | "invalid-subtype-discriminator" | "never-body" | "non-unicode-pattern" | "optional-prototype-name" | "sealed-base-extended" | "shared-input-key" | "split-visibility" | "truncated-doc-comment" | "undeclared-discriminator" | "unenforceable-encoded-constraint" | "unenforceable-property-name" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unrepresentable-override" | "unsatisfiable-encoding" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
237
+ export declare const reportDiagnostic: <C extends "ambiguous-form-reading" | "ambiguous-read-only" | "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "header-case-collision" | "inline-cycle" | "invalid-default" | "invalid-subtype-discriminator" | "never-body" | "non-unicode-pattern" | "optional-prototype-name" | "sealed-base-extended" | "shared-input-key" | "split-visibility" | "truncated-doc-comment" | "undeclared-discriminator" | "unenforceable-encoded-constraint" | "unenforceable-property-name" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unpublishable-null" | "unpublishable-record" | "unrepresentable-override" | "unsatisfiable-encoding" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
229
238
  "unsupported-type": {
230
239
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
231
240
  };
@@ -291,6 +300,15 @@ export declare const reportDiagnostic: <C extends "ambiguous-form-reading" | "de
291
300
  "never-body": {
292
301
  readonly default: import("@typespec/compiler").CallableMessage<["operationId", "position"]>;
293
302
  };
303
+ "unpublishable-null": {
304
+ readonly default: import("@typespec/compiler").CallableMessage<["type", "why"]>;
305
+ };
306
+ "unpublishable-record": {
307
+ readonly default: import("@typespec/compiler").CallableMessage<["type", "base", "base"]>;
308
+ };
309
+ "ambiguous-read-only": {
310
+ readonly default: import("@typespec/compiler").CallableMessage<["type", "property", "later", "view"]>;
311
+ };
294
312
  "unsatisfiable-encoding": {
295
313
  readonly default: import("@typespec/compiler").CallableMessage<["name", "declared", "encoded", "why"]>;
296
314
  };
@@ -387,6 +405,15 @@ export declare const reportDiagnostic: <C extends "ambiguous-form-reading" | "de
387
405
  "never-body": {
388
406
  readonly default: import("@typespec/compiler").CallableMessage<["operationId", "position"]>;
389
407
  };
408
+ "unpublishable-null": {
409
+ readonly default: import("@typespec/compiler").CallableMessage<["type", "why"]>;
410
+ };
411
+ "unpublishable-record": {
412
+ readonly default: import("@typespec/compiler").CallableMessage<["type", "base", "base"]>;
413
+ };
414
+ "ambiguous-read-only": {
415
+ readonly default: import("@typespec/compiler").CallableMessage<["type", "property", "later", "view"]>;
416
+ };
390
417
  "unsatisfiable-encoding": {
391
418
  readonly default: import("@typespec/compiler").CallableMessage<["name", "declared", "encoded", "why"]>;
392
419
  };
package/dist/src/lib.js CHANGED
@@ -475,6 +475,46 @@ const diagnostics = {
475
475
  default: paramMessage `'${"operationId"}' declares a ${"position"} body of type 'never', which has no values, and '@typespec/openapi3' publishes it as any JSON value. Replace it with 'void' to declare no body.`,
476
476
  },
477
477
  },
478
+ /**
479
+ * **A `null` the OpenAPI 3.0 document cannot publish.** OAS 3.0.4: `nullable` "only takes effect if
480
+ * `type` is explicitly defined within the same Schema Object", and "Other Schema Object constraints
481
+ * retain their defined behavior, and therefore may disallow the use of `null`". `@typespec/openapi3`
482
+ * publishes `Inner | null` as `{ type: object, allOf: [Inner], nullable: true }`, `Color | null` as
483
+ * `{ allOf: [Color], nullable: true }` and `"a" | "b" | null` as `{ type: string, enum: [a, b],
484
+ * nullable: true }`, and each refuses `null` by that text; a bare `null` is `{ nullable: true }`,
485
+ * any value. Refused where openapi3 publishes 3.0, since a validator agreeing with the spec's
486
+ * `| null` disagrees with the document; at 3.1 each is `anyOf` with `{ type: "null" }`.
487
+ */
488
+ "unpublishable-null": {
489
+ severity: "error",
490
+ messages: {
491
+ default: paramMessage `'${"type"}' admits null, and the OpenAPI 3.0 document '@typespec/openapi3' publishes does not: ${"why"}. Set 'openapi-versions: ["3.1.0"]' under '@typespec/openapi3' in tspconfig.yaml's options, whether or not it is emitted, to publish OpenAPI 3.1, which says null.`,
492
+ },
493
+ },
494
+ /**
495
+ * **A sealed model whose base is a record, where openapi3 publishes 3.0.** openapi3's 3.0 sealing
496
+ * is `additionalProperties: { not: {} }` beside the model's own `properties`, which cannot see the
497
+ * record its base declares in `allOf`, so the document refuses every key the record admits; 3.1
498
+ * seals with `unevaluatedProperties`, which can.
499
+ */
500
+ "unpublishable-record": {
501
+ severity: "error",
502
+ messages: {
503
+ default: paramMessage `'${"type"}' extends the record '${"base"}' and is sealed, and the OpenAPI 3.0 document '@typespec/openapi3' publishes seals it with 'additionalProperties: { not: {} }', which does not see the record in its 'allOf': every key the record admits is refused. Set 'openapi-versions: ["3.1.0"]' under '@typespec/openapi3', which seals with 'unevaluatedProperties', or spread the record onto the model, '...${"base"}', which is published open.`,
504
+ },
505
+ },
506
+ /**
507
+ * **A required read-only property in a request, where openapi3 publishes 3.0 and a later version.**
508
+ * OAS 3.0.4 says such a `required` "will take effect on the response only"; 3.1 made `readOnly`
509
+ * an annotation, so `required` holds in a request too. One request validator cannot agree with both
510
+ * documents.
511
+ */
512
+ "ambiguous-read-only": {
513
+ severity: "error",
514
+ messages: {
515
+ default: paramMessage `'${"type"}' is sent in a request and holds the required read-only property '${"property"}', and '@typespec/openapi3' publishes OpenAPI 3.0, where a request may leave it out, and ${"later"}, where it must send it: a request validator agrees with only one. Set one version in 'openapi-versions', or declare the request's own view of it, '${"view"}', which openapi3 publishes without it.`,
516
+ },
517
+ },
478
518
  /**
479
519
  * **An `@encode` whose published schema no value satisfies, or which states no type.**
480
520
  *
@@ -0,0 +1,20 @@
1
+ import type { Program } from "@typespec/compiler";
2
+ /**
3
+ * **The OpenAPI versions `@typespec/openapi3` publishes from this compile**: its `openapi-versions`
4
+ * option, read from the compile's options as `typespec-openapi-document` reads it, and openapi3's own
5
+ * default, `3.0.0`, where nothing sets it - whether or not openapi3 is emitted, since a project
6
+ * serving no document is still described by the one openapi3 would write.
7
+ *
8
+ * **The versions are two dialects, and a validator agrees with the one published.** 0.31.0 read every
9
+ * schema as OpenAPI 3.1 reads it while openapi3 published 3.0 by default, and the two answer one
10
+ * schema differently in two places: a required read-only property is required "on the response only"
11
+ * at 3.0 and in both directions at 3.1, and 3.0's `nullable` takes effect only beside `type` - see
12
+ * {@link publishesOpenApi30Only} and `unpublishable-null`.
13
+ */
14
+ export declare function openApiVersionsOf(program: Program): readonly string[];
15
+ /** Whether every document openapi3 publishes here is OpenAPI 3.0, which reads a request as 3.0 does. */
16
+ export declare function publishesOpenApi30Only(program: Program): boolean;
17
+ /** Whether openapi3 publishes an OpenAPI 3.0 document here, beside any other. */
18
+ export declare function publishesOpenApi30(program: Program): boolean;
19
+ /** Whether openapi3 publishes OpenAPI 3.0 and a later version here, two documents a request reads apart. */
20
+ export declare function publishesOpenApi30AndLater(program: Program): boolean;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * **The OpenAPI versions `@typespec/openapi3` publishes from this compile**: its `openapi-versions`
3
+ * option, read from the compile's options as `typespec-openapi-document` reads it, and openapi3's own
4
+ * default, `3.0.0`, where nothing sets it - whether or not openapi3 is emitted, since a project
5
+ * serving no document is still described by the one openapi3 would write.
6
+ *
7
+ * **The versions are two dialects, and a validator agrees with the one published.** 0.31.0 read every
8
+ * schema as OpenAPI 3.1 reads it while openapi3 published 3.0 by default, and the two answer one
9
+ * schema differently in two places: a required read-only property is required "on the response only"
10
+ * at 3.0 and in both directions at 3.1, and 3.0's `nullable` takes effect only beside `type` - see
11
+ * {@link publishesOpenApi30Only} and `unpublishable-null`.
12
+ */
13
+ export function openApiVersionsOf(program) {
14
+ const options = program.compilerOptions.options?.["@typespec/openapi3"];
15
+ const versions = options?.["openapi-versions"];
16
+ return versions === undefined || versions.length === 0 ? ["3.0.0"] : versions;
17
+ }
18
+ const is30 = (version) => version.startsWith("3.0.");
19
+ /** Whether every document openapi3 publishes here is OpenAPI 3.0, which reads a request as 3.0 does. */
20
+ export function publishesOpenApi30Only(program) {
21
+ return openApiVersionsOf(program).every(is30);
22
+ }
23
+ /** Whether openapi3 publishes an OpenAPI 3.0 document here, beside any other. */
24
+ export function publishesOpenApi30(program) {
25
+ return openApiVersionsOf(program).some(is30);
26
+ }
27
+ /** Whether openapi3 publishes OpenAPI 3.0 and a later version here, two documents a request reads apart. */
28
+ export function publishesOpenApi30AndLater(program) {
29
+ const versions = openApiVersionsOf(program);
30
+ return versions.some(is30) && versions.some((version) => !is30(version));
31
+ }
@@ -62,6 +62,8 @@ interface Declaration {
62
62
  /** The registry key it was declared under, and its metadata context, to render it again. */
63
63
  readonly key?: string;
64
64
  readonly ignore?: boolean;
65
+ /** The 3.0 request it is read for, to render it again - see `withRequestReading`. */
66
+ readonly reading?: Visibility;
65
67
  /** A shape no type stands for, and the type it is published for - see `declareNamed`. */
66
68
  readonly synthetic?: Synthetic;
67
69
  /** The keys a merge in it asked to be refused, to render it again when one is - see `refusedAt`. */
@@ -72,7 +74,7 @@ interface Synthetic {
72
74
  readonly kind: "multipart" | "envelope";
73
75
  readonly site: Type;
74
76
  }
75
- export declare function nameAt(bare: string, at: Visibility, type: Type): string;
77
+ export declare function nameAt(bare: string, at: Visibility, type: Type, reading?: Visibility): string;
76
78
  export declare class SchemaRegistry {
77
79
  #private;
78
80
  constructor(program: Program);
@@ -117,6 +119,16 @@ export declare class SchemaRegistry {
117
119
  declareNamed(name: string, build: () => string, site: Type): string;
118
120
  /** Every declaration, dependencies first. */
119
121
  declarations(): readonly Declaration[];
122
+ /**
123
+ * **The identifier each type is declared under by its own component name** - see
124
+ * `EmittedService.typeSchemaNames`. That is the declaration a response reads, wherever one does:
125
+ * `Read` is never suffixed, so a view of the type (`WidgetCreate`) and a 3.0 request's reading of
126
+ * it are other declarations, and neither is the one a wrapping emitter resolves a value by. A
127
+ * multipart body is its model's, which the document publishes under the model's name as the form it
128
+ * is; an envelope is named for its union and variant, and is no type's own. A declaration that
129
+ * validates nothing is left out rather than published as a validator.
130
+ */
131
+ typeIdentifiers(): ReadonlyMap<Type, string>;
120
132
  /** The names of the declarations that validate nothing, refused here or in the walk beneath. */
121
133
  refusedNames(): ReadonlySet<string>;
122
134
  }
@@ -4,7 +4,7 @@ import { firstReportOf } from "./constraints.js";
4
4
  import { reportDiagnostic } from "./lib.js";
5
5
  import { isSplitByVisibility, isSuffixedByUsage, viewRemedyOf, viewTemplateOf, } from "./published.js";
6
6
  import { propertyToTs, propertyTypeToTs, typeToTs, typeToTsBody, withTsRefResolver, withTsSyntheticDeclarations, } from "./types.js";
7
- import { captureBackEdges, currentVisibility, described, externalReferenceOf, isArrayLiteral, isOptionalAt, isSpeakableInstance, refuseExternalReference, metadataAnnotationsIgnored, metadataInfoOf, multipartBinaryContext, noteBackEdge, OWN_POSITION, propertyToZod, renderedVisibilityOf, rootVisibilityOf, UNREPRESENTABLE, typeToZodBody, visibilityName, withDeclarationSite, withDeferredAnnotator, withMetadataAnnotationsIgnored, withMultipartBinary, withRefResolver, withHeldDeclarations, withSyntheticDeclarations, withParameterPosition, withVisibility, } from "./zod.js";
7
+ import { captureBackEdges, currentVisibility, described, externalReferenceOf, isArrayLiteral, isOptionalAt, isSpeakableInstance, refuseExternalReference, metadataAnnotationsIgnored, metadataInfoOf, multipartBinaryContext, noteBackEdge, OWN_POSITION, propertyToZod, renderedVisibilityOf, requestReadingAt, requestReadsApart, rootVisibilityOf, UNREPRESENTABLE, typeToZodBody, visibilityName, withDeclarationSite, withDeferredAnnotator, withMetadataAnnotationsIgnored, withMultipartBinary, withRefResolver, withRequestReadingAs, withHeldDeclarations, withSyntheticDeclarations, withParameterPosition, withVisibility, } from "./zod.js";
8
8
  /**
9
9
  * The words TypeScript refuses as the name of a declaration the emitted files write - a type alias,
10
10
  * an interface, and the references to each - in a module, which is strict mode.
@@ -429,12 +429,33 @@ function visibilityAt(program, type, requested, depth) {
429
429
  ? rootVisibilityOf(program, type, requested)
430
430
  : renderedVisibilityOf(program, type, requested);
431
431
  }
432
- export function nameAt(bare, at, type) {
432
+ export function nameAt(bare, at, type, reading) {
433
+ /**
434
+ * **A 3.0 request's reading of a shared schema is named for the request**, `WidgetCreate` beside
435
+ * the `Widget` a response reads: the document publishes one component and OAS 3.0 reads it two
436
+ * ways - see `withRequestReading` - so the module declares both, the request's under the name
437
+ * openapi3 gives a view of the type at that visibility.
438
+ */
439
+ if (reading !== undefined) {
440
+ return safeDeclarationName(`${bare}${getVisibilitySuffix(reading, Visibility.Read)}`);
441
+ }
433
442
  // Suffixed only where openapi3 suffixes it: used at more than one visibility - see `isSuffixedByUsage`.
434
443
  const suffixed = at !== Visibility.Read && isSuffixedByUsage(type);
435
444
  return safeDeclarationName(`${bare}${suffixed ? getVisibilitySuffix(at, Visibility.Read) : ""}`);
436
445
  }
437
- function keyFor(type, visibility) {
446
+ /**
447
+ * **The 3.0 request a shared schema is read apart for here**, or `undefined`: only a type rendered at
448
+ * `Read` is shared with a response, and only one reaching a required read-only property reads apart -
449
+ * see `requestReadsApart`. A view openapi3 publishes for a request's own visibility drops read-only
450
+ * properties already, and the shared types it holds are read apart as they are reached.
451
+ */
452
+ function readingOf(program, type, at) {
453
+ const reading = requestReadingAt();
454
+ return reading !== undefined && at === Visibility.Read && requestReadsApart(program, type, at)
455
+ ? reading
456
+ : undefined;
457
+ }
458
+ function keyFor(type, visibility, reading) {
438
459
  /**
439
460
  * **Keyed on the named model, never a copy of it**: `@typespec/http` synthesises a fresh anonymous
440
461
  * model per position, and two operations taking one body each hand over a copy. Both resolve to
@@ -445,7 +466,9 @@ function keyFor(type, visibility) {
445
466
  const body = metadataAnnotationsIgnored() ? "\u0000body" : "";
446
467
  // And inside a multipart part's union holding raw `bytes` - see `withMultipartBinary`.
447
468
  const binary = multipartBinaryContext() ? "\u0000multipart" : "";
448
- return `${String(visibility)}\u0000${String(type.name ?? "")}\u0000${identityOf(type)}${body}${binary}`;
469
+ // And a 3.0 request's reading of a shared schema - see `readingOf`.
470
+ const request = reading === undefined ? "" : `\u0000request${String(reading)}`;
471
+ return `${String(visibility)}\u0000${String(type.name ?? "")}\u0000${identityOf(type)}${body}${binary}${request}`;
449
472
  }
450
473
  /** A stable per-type identity, since two models can share a name across namespaces. */
451
474
  const identities = new WeakMap();
@@ -472,6 +495,8 @@ export class SchemaRegistry {
472
495
  #withheld = new Set();
473
496
  /** Every type name a refused identifier stood for: `Foo` and `foo` are both `fooSchema`. */
474
497
  #refusedTypeNames = new Set();
498
+ /** Each type declared by another type's declaration, the two rendered alike under one name. */
499
+ #alike = [];
475
500
  /**
476
501
  * Inlined types currently being walked, by identity, against the number of declarations that were
477
502
  * in flight when each was entered.
@@ -541,7 +566,8 @@ export class SchemaRegistry {
541
566
  * request and a response. Collapsing onto `Read` first is what makes the key and the name agree,
542
567
  * and it is the same collapse openapi3 performs before naming a component.
543
568
  */
544
- const key = keyFor(type, at);
569
+ const reading = readingOf(this.#program, type, at);
570
+ const key = keyFor(type, at, reading);
545
571
  const existing = this.#declarations.get(key);
546
572
  if (existing !== undefined)
547
573
  return existing.identifier;
@@ -566,7 +592,7 @@ export class SchemaRegistry {
566
592
  this.#cyclic.add(inFlight);
567
593
  return pending;
568
594
  }
569
- const name = nameAt(bare, at, type);
595
+ const name = nameAt(bare, at, type, reading);
570
596
  const identifier = `${lowerFirst(name)}Schema`;
571
597
  const { source, deferred, consulted } = this.#render(type, at, name, key, identifier);
572
598
  const declaration = {
@@ -577,6 +603,7 @@ export class SchemaRegistry {
577
603
  at,
578
604
  key,
579
605
  ignore: metadataAnnotationsIgnored(),
606
+ ...(reading === undefined ? {} : { reading }),
580
607
  ...(this.#cyclic.has(key) ? { structural: this.#structuralBodyOf(type, at) } : {}),
581
608
  ...(deferred ? { annotated: true } : {}),
582
609
  ...(consulted.size === 0 ? {} : { consulted }),
@@ -607,6 +634,9 @@ export class SchemaRegistry {
607
634
  */
608
635
  if (twin !== undefined && twin.source === declaration.source) {
609
636
  this.#declarations.set(key, twin);
637
+ // Another type of the name, rendered alike, is validated by it too - see `typeIdentifiers`.
638
+ if (twin.type !== type)
639
+ this.#alike.push({ type, identifier });
610
640
  return twin.identifier;
611
641
  }
612
642
  if (twin !== undefined) {
@@ -713,14 +743,14 @@ export class SchemaRegistry {
713
743
  refused: (expression) => this.#refused.has(expression),
714
744
  annotated: (expression) => this.#order.some((declaration) => declaration.identifier === expression && declaration.annotated === true),
715
745
  refusedAt: (type, at) => {
716
- const key = keyFor(type, at);
746
+ const key = keyFor(type, at, readingOf(this.#program, type, at));
717
747
  this.#consulted.at(-1)?.add(key);
718
748
  const declared = this.#declarations.get(key);
719
749
  return (declared !== undefined &&
720
750
  (this.#refused.has(declared.identifier) || this.#withheld.has(declared.identifier)));
721
751
  },
722
752
  holdsRefusalAt: (type, at) => {
723
- const key = keyFor(type, at);
753
+ const key = keyFor(type, at, readingOf(this.#program, type, at));
724
754
  this.#consulted.at(-1)?.add(key);
725
755
  const declared = this.#declarations.get(key);
726
756
  return declared !== undefined && this.#reachesRefusal(declared.identifier, new Set());
@@ -771,7 +801,7 @@ export class SchemaRegistry {
771
801
  .map(([each]) => each);
772
802
  for (const each of keys)
773
803
  this.#declarations.delete(each);
774
- const { source, deferred, consulted } = withMetadataAnnotationsIgnored(held.ignore ?? false, () => this.#render(type, at, held.typeName, key, held.identifier));
804
+ const { source, deferred, consulted } = withRequestReadingAs(held.reading, () => withMetadataAnnotationsIgnored(held.ignore ?? false, () => this.#render(type, at, held.typeName, key, held.identifier)));
775
805
  const fresh = {
776
806
  ...held,
777
807
  source,
@@ -953,7 +983,8 @@ export class SchemaRegistry {
953
983
  const bare = declaredNameOf(this.#program, candidate);
954
984
  if (bare === undefined)
955
985
  return undefined;
956
- return nameAt(bare, renderedVisibilityOf(this.#program, candidate, currentVisibility()), candidate);
986
+ const at = renderedVisibilityOf(this.#program, candidate, currentVisibility());
987
+ return nameAt(bare, at, candidate, readingOf(this.#program, candidate, at));
957
988
  }
958
989
  /**
959
990
  * Render `type` without consulting its own declaration, but letting nested types use theirs.
@@ -1031,6 +1062,50 @@ export class SchemaRegistry {
1031
1062
  declarations() {
1032
1063
  return this.#order;
1033
1064
  }
1065
+ /**
1066
+ * **The identifier each type is declared under by its own component name** - see
1067
+ * `EmittedService.typeSchemaNames`. That is the declaration a response reads, wherever one does:
1068
+ * `Read` is never suffixed, so a view of the type (`WidgetCreate`) and a 3.0 request's reading of
1069
+ * it are other declarations, and neither is the one a wrapping emitter resolves a value by. A
1070
+ * multipart body is its model's, which the document publishes under the model's name as the form it
1071
+ * is; an envelope is named for its union and variant, and is no type's own. A declaration that
1072
+ * validates nothing is left out rather than published as a validator.
1073
+ */
1074
+ typeIdentifiers() {
1075
+ const found = new Map();
1076
+ const consider = (type, declaration) => {
1077
+ if (this.#validatesNothing(declaration))
1078
+ return;
1079
+ // A view, and a 3.0 request's reading, are named with a suffix - see `nameAt`.
1080
+ const bare = declaredNameOf(this.#program, type);
1081
+ if (bare === undefined || declaration.typeName !== safeDeclarationName(bare))
1082
+ return;
1083
+ found.set(type, declaration.identifier);
1084
+ };
1085
+ for (const declaration of this.#order) {
1086
+ const { type, synthetic } = declaration;
1087
+ if (type !== undefined)
1088
+ consider(type, declaration);
1089
+ else if (synthetic?.kind === "multipart")
1090
+ consider(synthetic.site, declaration);
1091
+ }
1092
+ // By identifier, because the declaration a type shares can be rendered again since.
1093
+ for (const { type, identifier } of this.#alike) {
1094
+ const declaration = this.#order.find((each) => each.identifier === identifier);
1095
+ if (declaration !== undefined)
1096
+ consider(type, declaration);
1097
+ }
1098
+ return found;
1099
+ }
1100
+ /**
1101
+ * **Rendered as `z.never()`, and described where it has a doc comment**, so its source is not always
1102
+ * `UNREPRESENTABLE` itself: a model split by visibility keeps its description. A declaration refused
1103
+ * as two shapes under one name is replaced by a bare `UNREPRESENTABLE`, so it is one of these too.
1104
+ */
1105
+ #validatesNothing(declaration) {
1106
+ return (declaration.source === UNREPRESENTABLE ||
1107
+ declaration.source.startsWith(`${UNREPRESENTABLE}.describe(`));
1108
+ }
1034
1109
  /** The names of the declarations that validate nothing, refused here or in the walk beneath. */
1035
1110
  refusedNames() {
1036
1111
  return new Set([
@@ -1154,8 +1229,9 @@ export class TypeRegistry {
1154
1229
  this.#inlineInProgress.delete(type);
1155
1230
  }
1156
1231
  }
1157
- const key = keyFor(type, at);
1158
- const name = nameAt(bare, at, type);
1232
+ const reading = readingOf(this.#program, type, at);
1233
+ const key = keyFor(type, at, reading);
1234
+ const name = nameAt(bare, at, type, reading);
1159
1235
  const existing = this.#declarations.get(key);
1160
1236
  if (existing !== undefined)
1161
1237
  return existing.name;
package/dist/src/zod.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Enum, type Model, type ModelProperty, type Program, type Scalar, type Type, type Union } from "@typespec/compiler";
1
+ import { type Enum, type Model, type ModelProperty, type Namespace, type Program, type Scalar, type Type, type Union } from "@typespec/compiler";
2
2
  import { type MetadataInfo, Visibility } from "@typespec/http";
3
3
  /**
4
4
  * What a walk returns after reporting a refusal.
@@ -27,6 +27,21 @@ export declare function metadataInfoOf(program: Program): MetadataInfo;
27
27
  export declare function withVisibility<T>(next: Visibility, run: () => T): T;
28
28
  /** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */
29
29
  export declare function isOptionalAt(program: Program, property: ModelProperty): boolean;
30
+ export declare function withRequestReading<T>(program: Program, requested: Visibility, run: () => T): T;
31
+ /** A declaration's recorded reading, restored to render it again - see `Declaration.reading`. */
32
+ export declare function withRequestReadingAs<T>(reading: Visibility | undefined, run: () => T): T;
33
+ /** The request visibility a 3.0 request reading is under, or `undefined` outside one. */
34
+ export declare function requestReadingAt(): Visibility | undefined;
35
+ /**
36
+ * **Whether a type, rendered at `at`, is read apart in a 3.0 request**: it reaches a required
37
+ * read-only property openapi3 publishes in it - through its own and inherited properties, a list's
38
+ * elements, a record's values, a union's members and a discriminated base's subtypes, each at the
39
+ * visibility openapi3 renders it at. Such a type's request reading lets that property be left out
40
+ * and its response reading does not, so the two are two declarations; a type reaching none is one.
41
+ */
42
+ export declare function requestReadsApart(program: Program, type: Type, at: Visibility): boolean;
43
+ /** The first required read-only property `type` reaches at `at` - see {@link requestReadsApart}. */
44
+ export declare function requiredReadOnlyIn(program: Program, type: Type, at: Visibility, seen: Set<Type>): ModelProperty | undefined;
30
45
  /** Whether a property belongs in the BODY at the current position, rather than being metadata. */
31
46
  export declare function isPayloadProperty(program: Program, property: ModelProperty): boolean;
32
47
  export declare function withMetadataAnnotationsIgnored<T>(ignored: boolean, run: () => T): T;
@@ -285,6 +300,31 @@ export declare function optionalDiscriminator(program: Program, union: Union, va
285
300
  * at more than one. None for a union with no name, whose envelopes are written out where it is.
286
301
  */
287
302
  export declare function envelopeNameOf(program: Program, union: Union, variantName: string, variant: Type): string | undefined;
303
+ /**
304
+ * **Why the OpenAPI 3.0 document refuses a `null` the spec admits**, or `undefined` where it does not -
305
+ * asked only where openapi3 publishes 3.0. Read off openapi3's 3.0 `unionSchema` and OAS 3.0.4's
306
+ * `nullable`, which "only takes effect if `type` is explicitly defined within the same Schema Object",
307
+ * while "Other Schema Object constraints retain their defined behavior":
308
+ *
309
+ * - Each JSON kind of literal is one `{ type, enum }` member, and the `enum` refuses `null`.
310
+ * - **One member**, or any member a `$ref`, is written with `nullable` beside it: a `$ref` is wrapped
311
+ * as `{ type?, allOf: [$ref], nullable }` and the component refuses `null` unless it has no type; an
312
+ * inline schema admits it where it carries a `type` - a standard scalar, a list, a record, a model
313
+ * expression - or none at all.
314
+ * - **Several inline members** are `{ anyOf: [...], nullable: true }`, with no `type`, so `nullable`
315
+ * does nothing and only a member admitting `null` on its own does.
316
+ * - A bare `null` is `{ nullable: true }` - any value - and a union of nothing but `null` is refused by
317
+ * openapi3 itself, as `union-null`.
318
+ */
319
+ export declare function unpublishableNullWhy(program: Program, type: Type): string | undefined;
320
+ /**
321
+ * **Everything a service declares that its 3.0 document cannot publish, reported once each**,
322
+ * whichever walk renders it or none: a `null` in a union, named or written where it is used, a bare
323
+ * `null`, and a sealed model's record - see `unpublishable-null` and `unpublishable-record`.
324
+ * The walks refuse each as they reach it; this is what makes sure every one is named, the way a
325
+ * property's `T | null` is peeled off before any union is rendered.
326
+ */
327
+ export declare function reportUnpublishable(program: Program, service: Namespace): void;
288
328
  /**
289
329
  * Every property the model declares, **including the ones it inherits**.
290
330
  *
@@ -697,6 +737,15 @@ export interface HeldDeclarations {
697
737
  readonly holdsRefusalAt: (type: Type, at: Visibility) => boolean;
698
738
  }
699
739
  export declare function withHeldDeclarations<T>(given: HeldDeclarations, run: () => T): T;
740
+ /**
741
+ * **The record a sealed model's 3.0 document cannot see**, or `undefined`. openapi3's 3.0 sealing is
742
+ * `additionalProperties: { not: {} }` beside the model's own `properties` - it re-declares a base's
743
+ * properties there, so they survive - and a base that is a record declares none: `model M extends
744
+ * Record<string> { name: string }` sealed refuses every key the record admits, where 3.1's
745
+ * `unevaluatedProperties` sees the record through `allOf`. Mirrors {@link isSealed}: sealed here,
746
+ * with an ancestor whose indexer admits a value.
747
+ */
748
+ export declare function unpublishableRecordOf(program: Program, model: Model): Model | undefined;
700
749
  /**
701
750
  * A model as the object it publishes at the current position: the merge of one - see
702
751
  * {@link mergeShapes}. **HTTP metadata is not payload**, and neither is a property invisible here or
package/dist/src/zod.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getDiscriminatedUnion, getDiscriminatedUnionFromInheritance, getDiscriminator, getDoc, getEncode, getTypeName, ignoreDiagnostics, resolveEncodedName, serializeValueAsJson, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
1
+ import { getDiscriminatedUnion, getDiscriminatedUnionFromInheritance, getDiscriminator, getDoc, getEncode, getTypeName, ignoreDiagnostics, isGlobalNamespace, navigateTypesInNamespace, resolveEncodedName, serializeValueAsJson, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
2
2
  import { createMetadataInfo, getCookieParamOptions, getHeaderFieldName, getVisibilitySuffix, getPathParamName, getQueryParamName, isCookieParam, isHeader, isPathParam, isQueryParam, Visibility, } from "@typespec/http";
3
3
  import { $ } from "@typespec/compiler/typekit";
4
4
  import { z } from "zod";
@@ -7,6 +7,7 @@ import { applyConstraints, firstReportOf, brokenConstraintOf, brokenItemsOf, has
7
7
  import { literal } from "./literal.js";
8
8
  import { isPublishedSplit, isSharedRouteOptional, isSuffixedByUsage, keptVisibilityOf, readVisibilityOf, } from "./published.js";
9
9
  import { reportDiagnostic } from "./lib.js";
10
+ import { publishesOpenApi30, publishesOpenApi30Only } from "./openapi-versions.js";
10
11
  /**
11
12
  * What a walk returns after reporting a refusal.
12
13
  *
@@ -109,7 +110,7 @@ function isSealed(model) {
109
110
  /**
110
111
  * Whether `@typespec/openapi3` is sealing object schemas for this compile.
111
112
  *
112
- * **This emitter cannot see the other emitter's options, so the consumer states it twice.**
113
+ * **The consumer states it twice, here and in openapi3's own options.**
113
114
  * Awkward, and the alternative is worse: assume sealing is on and a project running openapi3 with
114
115
  * its default gets validators that reject properties its published document permits. Whichever way
115
116
  * the two are configured, the conformance differential compares openness in both directions and
@@ -187,7 +188,109 @@ export function withVisibility(next, run) {
187
188
  }
188
189
  /** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */
189
190
  export function isOptionalAt(program, property) {
190
- return (metadataInfoOf(program).isOptional(property, visibility) || isSharedRouteOptional(property));
191
+ return (metadataInfoOf(program).isOptional(property, visibility) ||
192
+ isSharedRouteOptional(property) ||
193
+ (requestReading !== undefined && isReadonlyProperty(program, property)));
194
+ }
195
+ /**
196
+ * **A request read as an OpenAPI 3.0 document reads it: the request's visibility, while the walk is
197
+ * inside one, where openapi3 publishes only 3.0.**
198
+ *
199
+ * openapi3 shares a read-only property into the schema a request is published with - a create body
200
+ * with nothing create-specific IS the canonical schema, `id` required and `readOnly` - and OAS 3.0.4
201
+ * says such a `required` "will take effect on the response only". So at 3.0 a request may leave it
202
+ * out, and the validator lets it, as 0.30.0 did; 0.31.0 read every request as 3.1 does, where
203
+ * `readOnly` is an annotation and `required` holds both ways, and refused every create body leaving
204
+ * out a server-assigned id while openapi3 published 3.0 by default.
205
+ *
206
+ * A declaration a request reads apart from its response is declared again for the request - keyed and
207
+ * named for it, `WidgetCreate` beside `Widget` - see {@link requestReadsApart}.
208
+ */
209
+ let requestReading;
210
+ export function withRequestReading(program, requested, run) {
211
+ const previous = requestReading;
212
+ requestReading = publishesOpenApi30Only(program) ? requested : undefined;
213
+ try {
214
+ return run();
215
+ }
216
+ finally {
217
+ requestReading = previous;
218
+ }
219
+ }
220
+ /** A declaration's recorded reading, restored to render it again - see `Declaration.reading`. */
221
+ export function withRequestReadingAs(reading, run) {
222
+ const previous = requestReading;
223
+ requestReading = reading;
224
+ try {
225
+ return run();
226
+ }
227
+ finally {
228
+ requestReading = previous;
229
+ }
230
+ }
231
+ /** The request visibility a 3.0 request reading is under, or `undefined` outside one. */
232
+ export function requestReadingAt() {
233
+ return requestReading;
234
+ }
235
+ /**
236
+ * **Whether a type, rendered at `at`, is read apart in a 3.0 request**: it reaches a required
237
+ * read-only property openapi3 publishes in it - through its own and inherited properties, a list's
238
+ * elements, a record's values, a union's members and a discriminated base's subtypes, each at the
239
+ * visibility openapi3 renders it at. Such a type's request reading lets that property be left out
240
+ * and its response reading does not, so the two are two declarations; a type reaching none is one.
241
+ */
242
+ export function requestReadsApart(program, type, at) {
243
+ return requiredReadOnlyIn(program, type, at, new Set()) !== undefined;
244
+ }
245
+ /** The first required read-only property `type` reaches at `at` - see {@link requestReadsApart}. */
246
+ export function requiredReadOnlyIn(program, type, at, seen) {
247
+ if (seen.has(type))
248
+ return undefined;
249
+ seen.add(type);
250
+ const inner = (next) => requiredReadOnlyIn(program, next, renderedVisibilityOf(program, next, at), seen);
251
+ switch (type.kind) {
252
+ case "Model": {
253
+ if (type.indexer !== undefined) {
254
+ const found = inner(type.indexer.value);
255
+ if (found !== undefined)
256
+ return found;
257
+ }
258
+ const info = metadataInfoOf(program);
259
+ for (const { property } of flattenedPropertiesOf(program, type)) {
260
+ if (!info.isPayloadProperty(property, at, metadataIgnored))
261
+ continue;
262
+ if (isReadonlyProperty(program, property) && !property.optional)
263
+ return property;
264
+ const found = inner(property.type);
265
+ if (found !== undefined)
266
+ return found;
267
+ }
268
+ for (const derived of type.derivedModels) {
269
+ const found = requiredReadOnlyIn(program, derived, at, seen);
270
+ if (found !== undefined)
271
+ return found;
272
+ }
273
+ return undefined;
274
+ }
275
+ case "Union":
276
+ for (const variant of type.variants.values()) {
277
+ const found = inner(variant.type);
278
+ if (found !== undefined)
279
+ return found;
280
+ }
281
+ return undefined;
282
+ case "UnionVariant":
283
+ return inner(type.type);
284
+ case "Tuple":
285
+ for (const value of type.values) {
286
+ const found = inner(value);
287
+ if (found !== undefined)
288
+ return found;
289
+ }
290
+ return undefined;
291
+ default:
292
+ return undefined;
293
+ }
191
294
  }
192
295
  /** Whether a property belongs in the BODY at the current position, rather than being metadata. */
193
296
  export function isPayloadProperty(program, property) {
@@ -1017,7 +1120,150 @@ export function envelopeNameOf(program, union, variantName, variant) {
1017
1120
  const suffixed = envelopeAt !== Visibility.Read && isSuffixedByUsage(union);
1018
1121
  return `${union.name}${capitalise(variantName)}${suffixed ? getVisibilitySuffix(envelopeAt, Visibility.Read) : ""}`;
1019
1122
  }
1123
+ /**
1124
+ * **Why the OpenAPI 3.0 document refuses a `null` the spec admits**, or `undefined` where it does not -
1125
+ * asked only where openapi3 publishes 3.0. Read off openapi3's 3.0 `unionSchema` and OAS 3.0.4's
1126
+ * `nullable`, which "only takes effect if `type` is explicitly defined within the same Schema Object",
1127
+ * while "Other Schema Object constraints retain their defined behavior":
1128
+ *
1129
+ * - Each JSON kind of literal is one `{ type, enum }` member, and the `enum` refuses `null`.
1130
+ * - **One member**, or any member a `$ref`, is written with `nullable` beside it: a `$ref` is wrapped
1131
+ * as `{ type?, allOf: [$ref], nullable }` and the component refuses `null` unless it has no type; an
1132
+ * inline schema admits it where it carries a `type` - a standard scalar, a list, a record, a model
1133
+ * expression - or none at all.
1134
+ * - **Several inline members** are `{ anyOf: [...], nullable: true }`, with no `type`, so `nullable`
1135
+ * does nothing and only a member admitting `null` on its own does.
1136
+ * - A bare `null` is `{ nullable: true }` - any value - and a union of nothing but `null` is refused by
1137
+ * openapi3 itself, as `union-null`.
1138
+ */
1139
+ export function unpublishableNullWhy(program, type) {
1140
+ if (!publishesOpenApi30(program))
1141
+ return undefined;
1142
+ if (type.kind === "Intrinsic" && type.name === "null") {
1143
+ return "a bare 'null' is published as '{ nullable: true }', which is any value";
1144
+ }
1145
+ if (type.kind !== "Union" || getDiscriminatedUnion(program, type)[0] !== undefined)
1146
+ return undefined;
1147
+ const variants = [...type.variants.values()].map((variant) => variant.type);
1148
+ if (!variants.some(isNull))
1149
+ return undefined;
1150
+ const real = variants.filter((variant) => !isNull(variant));
1151
+ if (real.length === 0) {
1152
+ return "a union of nothing but 'null' has no 3.0 schema, and openapi3 refuses it as 'union-null'";
1153
+ }
1154
+ if (admitsNullAt30(program, type, new Set()))
1155
+ return undefined;
1156
+ const referenced = real.some((member) => publishedAsReference(program, member));
1157
+ return referenced
1158
+ ? "'nullable' is published beside a reference, which still refuses null - OAS 3.0.4 lets 'nullable' take effect only beside 'type', and keeps every other constraint"
1159
+ : "'nullable' is published beside an 'enum' or an 'anyOf' that refuses null - OAS 3.0.4 lets 'nullable' take effect only beside 'type', and keeps every other constraint";
1160
+ }
1161
+ /** Whether openapi3's 3.0 schema for `union` admits `null` - see {@link unpublishableNullWhy}. */
1162
+ function admitsNullAt30(program, union, seen) {
1163
+ if (seen.has(union))
1164
+ return false;
1165
+ seen.add(union);
1166
+ const variants = [...union.variants.values()].map((variant) => variant.type);
1167
+ const nullable = variants.some(isNull);
1168
+ const real = variants.filter((variant) => !isNull(variant));
1169
+ const literal = (member) => member.kind === "EnumMember" ||
1170
+ (member.kind !== "UnionVariant" && literalValueOf(member) !== undefined);
1171
+ const members = real.filter((member) => !literal(member));
1172
+ const groups = new Set(real.filter(literal).map((member) => member.kind)).size;
1173
+ const wrapped = nullable &&
1174
+ (members.length + groups === 1 ||
1175
+ members.some((member) => publishedAsReference(program, member)));
1176
+ return members.some((member) => memberAdmitsNullAt30(program, member, wrapped, seen));
1177
+ }
1178
+ /** Whether one member of a 3.0 union admits `null`, with `nullable` beside it or not. */
1179
+ function memberAdmitsNullAt30(program, member, wrapped, seen) {
1180
+ if (member.kind === "UnionVariant")
1181
+ return memberAdmitsNullAt30(program, member.type, wrapped, seen);
1182
+ if (isTypelessMember(program, member))
1183
+ return true;
1184
+ if (publishedAsReference(program, member)) {
1185
+ // The component answers: a named union by its own members, anything else typed and refusing.
1186
+ return member.kind === "Union" && admitsNullAt30(program, member, seen);
1187
+ }
1188
+ if (member.kind === "Union")
1189
+ return admitsNullAt30(program, member, seen);
1190
+ // Inline and typed - a standard scalar, a list, a record, a model expression: `nullable` beside it.
1191
+ return wrapped;
1192
+ }
1193
+ /**
1194
+ * **Whether openapi3 publishes `type` as a `$ref` to a component**: a named model, enum or named union
1195
+ * it can name, and a scalar outside the standard library. Everything else it writes out where it is
1196
+ * used. Mirrors `declaredNameOf`, plus scalars, which this emitter validates inline and openapi3
1197
+ * declares.
1198
+ */
1199
+ function publishedAsReference(program, type) {
1200
+ switch (type.kind) {
1201
+ case "UnionVariant":
1202
+ return publishedAsReference(program, type.type);
1203
+ case "Enum":
1204
+ return true;
1205
+ case "Union":
1206
+ return type.name !== undefined && isSpeakableInstance(type) && !shouldInline(program, type);
1207
+ case "Scalar":
1208
+ return !program.checker.isStdType(type);
1209
+ case "Model":
1210
+ return (type.name !== "" &&
1211
+ !isArrayLiteral(program, type) &&
1212
+ !(type.name === "Record" && program.checker.isStdType(type)) &&
1213
+ isSpeakableInstance(type) &&
1214
+ !shouldInline(program, type));
1215
+ default:
1216
+ return false;
1217
+ }
1218
+ }
1219
+ /**
1220
+ * **Everything a service declares that its 3.0 document cannot publish, reported once each**,
1221
+ * whichever walk renders it or none: a `null` in a union, named or written where it is used, a bare
1222
+ * `null`, and a sealed model's record - see `unpublishable-null` and `unpublishable-record`.
1223
+ * The walks refuse each as they reach it; this is what makes sure every one is named, the way a
1224
+ * property's `T | null` is peeled off before any union is rendered.
1225
+ */
1226
+ export function reportUnpublishable(program, service) {
1227
+ if (!publishesOpenApi30(program))
1228
+ return;
1229
+ const check = (type) => {
1230
+ const why = unpublishableNullWhy(program, type);
1231
+ if (why !== undefined)
1232
+ refuseUnpublishableNull(program, type, why);
1233
+ };
1234
+ navigateTypesInNamespace(service, {
1235
+ union: check,
1236
+ // A bare `null` is one type everywhere, so it is reported where it is used.
1237
+ modelProperty: (property) => {
1238
+ if (property.type.kind !== "Intrinsic")
1239
+ return;
1240
+ const why = unpublishableNullWhy(program, property.type);
1241
+ if (why !== undefined)
1242
+ refuseUnpublishableNull(program, property.type, why, property);
1243
+ },
1244
+ model: (model) => {
1245
+ const record = unpublishableRecordOf(program, model);
1246
+ if (record !== undefined)
1247
+ refuseUnpublishableRecord(program, model, record);
1248
+ },
1249
+ }, { skipSubNamespaces: isGlobalNamespace(program, service) });
1250
+ }
1251
+ /** Refused where the 3.0 document cannot publish its `null` - see `unpublishable-null`. */
1252
+ function refuseUnpublishableNull(program, type, why, at = refusalTarget(type)) {
1253
+ const target = at;
1254
+ if (firstReportOf(program, "unpublishable-null", target)) {
1255
+ reportDiagnostic(program, {
1256
+ code: "unpublishable-null",
1257
+ target,
1258
+ format: { type: getTypeName(type), why },
1259
+ });
1260
+ }
1261
+ return UNREPRESENTABLE;
1262
+ }
1020
1263
  function unionToZod(program, union) {
1264
+ const unpublishable = unpublishableNullWhy(program, union);
1265
+ if (unpublishable !== undefined)
1266
+ return refuseUnpublishableNull(program, union, unpublishable);
1021
1267
  /**
1022
1268
  * A `@discriminated` union becomes `z.discriminatedUnion`, not `z.union`.
1023
1269
  *
@@ -2990,7 +3236,40 @@ export function withHeldDeclarations(given, run) {
2990
3236
  held = previous;
2991
3237
  }
2992
3238
  }
3239
+ /**
3240
+ * **The record a sealed model's 3.0 document cannot see**, or `undefined`. openapi3's 3.0 sealing is
3241
+ * `additionalProperties: { not: {} }` beside the model's own `properties` - it re-declares a base's
3242
+ * properties there, so they survive - and a base that is a record declares none: `model M extends
3243
+ * Record<string> { name: string }` sealed refuses every key the record admits, where 3.1's
3244
+ * `unevaluatedProperties` sees the record through `allOf`. Mirrors {@link isSealed}: sealed here,
3245
+ * with an ancestor whose indexer admits a value.
3246
+ */
3247
+ export function unpublishableRecordOf(program, model) {
3248
+ if (!publishesOpenApi30(program) || model.indexer !== undefined || !isSealed(model)) {
3249
+ return undefined;
3250
+ }
3251
+ for (let base = model.baseModel; base !== undefined; base = base.baseModel) {
3252
+ if (base.indexer !== undefined && !isNeverType(base.indexer.value))
3253
+ return base;
3254
+ }
3255
+ return undefined;
3256
+ }
3257
+ /** Refused where the 3.0 document seals away a base's record - see `unpublishable-record`. */
3258
+ function refuseUnpublishableRecord(program, model, base) {
3259
+ const target = refusalTarget(model);
3260
+ if (firstReportOf(program, "unpublishable-record", target)) {
3261
+ reportDiagnostic(program, {
3262
+ code: "unpublishable-record",
3263
+ target,
3264
+ format: { type: getTypeName(model), base: getTypeName(base) },
3265
+ });
3266
+ }
3267
+ return UNREPRESENTABLE;
3268
+ }
2993
3269
  function modelToZod(program, model) {
3270
+ const record = unpublishableRecordOf(program, model);
3271
+ if (record !== undefined)
3272
+ return refuseUnpublishableRecord(program, model, record);
2994
3273
  /**
2995
3274
  * **A `@discriminator` base is its own object, deserialized as a subtype where the value is one.**
2996
3275
  * See {@link DiscriminatedBase}.
@@ -4284,6 +4563,14 @@ function propertyValueToZod(program, property, position = OWN_POSITION) {
4284
4563
  * `.optional()` stays outermost: a constraint after it would apply to the optionality rather than
4285
4564
  * to the value.
4286
4565
  */
4566
+ // A `null` the 3.0 document cannot publish refuses the value - see `unpublishableNullWhy`.
4567
+ const unpublishable = unpublishableNullWhy(program, property.type);
4568
+ if (unpublishable !== undefined) {
4569
+ return {
4570
+ base: refuseUnpublishableNull(program, property.type, unpublishable),
4571
+ description: getDoc(program, property),
4572
+ };
4573
+ }
4287
4574
  /**
4288
4575
  * `@encode` is read as `encodedValueOf` says the document publishes it, nullability included:
4289
4576
  * openapi3 3.1 publishes `anyOf: [<the encoded value>, { type: "null" }]` where the encoding is
@@ -5144,15 +5431,18 @@ export function typeToZodBody(program, type) {
5144
5431
  */
5145
5432
  case "UnionVariant":
5146
5433
  return typeToZod(program, type.type);
5147
- case "Intrinsic":
5148
- if (type.name === "null")
5149
- return "z.null()";
5434
+ case "Intrinsic": {
5435
+ if (type.name === "null") {
5436
+ const why = unpublishableNullWhy(program, type);
5437
+ return why === undefined ? "z.null()" : refuseUnpublishableNull(program, type, why);
5438
+ }
5150
5439
  if (type.name === "unknown")
5151
5440
  return "z.unknown()";
5152
5441
  if (type.name === "void" || type.name === "never") {
5153
5442
  return refuse(program, type, `\`${type.name}\` has no runtime representation`, "Give the position a type a value can inhabit. A `never` property or parameter is dropped to match the document, and `Record<never>` seals the model, so none of those reaches here.");
5154
5443
  }
5155
5444
  return refuse(program, type, `intrinsic "${type.name}"`, "This emitter has no rule for that intrinsic. Replace it with a declared scalar or model.");
5445
+ }
5156
5446
  /**
5157
5447
  * **openapi3 publishes a tuple as `prefixItems`, with no `items` or `minItems` beside it**:
5158
5448
  * any length, each position checked where it is present, anything past the last. That is a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "description": "TypeSpec emitter and library: turn an HTTP service definition into Zod validators and framework-free contract types that agree, keyword for keyword, with the OpenAPI document @typespec/openapi3 publishes from the same source.",
5
5
  "keywords": [
6
6
  "codegen",