typespec-http-zod 0.24.0 → 0.25.1
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 +59 -7
- package/dist/src/api.d.ts +32 -12
- package/dist/src/api.js +233 -74
- package/dist/src/lib.d.ts +10 -1
- package/dist/src/lib.js +44 -0
- package/dist/src/registry.d.ts +41 -0
- package/dist/src/registry.js +80 -19
- package/dist/src/types.js +29 -8
- package/dist/src/zod.d.ts +27 -0
- package/dist/src/zod.js +45 -2
- package/package.json +18 -18
package/README.md
CHANGED
|
@@ -1,14 +1,60 @@
|
|
|
1
1
|
# typespec-http-zod
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Zod validators generated from the same API definition as your OpenAPI document.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
[`@typespec/openapi3`](https://typespec.io) publishes from the same source. Use them with Express,
|
|
7
|
-
Fastify, Elysia, a Workers fetch handler, a typed client, or a test suite. No server framework is
|
|
8
|
-
required.
|
|
5
|
+
## What is it?
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
An emitter for [TypeSpec](https://typespec.io), Microsoft's language for describing APIs. Describe
|
|
8
|
+
your API once, and this package generates the Zod schemas that validate every request and response,
|
|
9
|
+
plus the TypeScript types to go with them.
|
|
10
|
+
|
|
11
|
+
TypeSpec compiles to OpenAPI as well, so the same definition also gives you documentation that cannot
|
|
12
|
+
drift from the validation.
|
|
13
|
+
|
|
14
|
+
One model:
|
|
15
|
+
|
|
16
|
+
```tsp
|
|
17
|
+
model Widget {
|
|
18
|
+
id: string;
|
|
19
|
+
|
|
20
|
+
@minLength(1)
|
|
21
|
+
name: string;
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The validators and types this package generates:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
export const widgetSchema = z.object({
|
|
29
|
+
id: z.string(),
|
|
30
|
+
name: z.string().min(1),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type Widget = z.infer<typeof widgetSchema>;
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
And the OpenAPI that [`@typespec/openapi3`](https://typespec.io) generates from that same model:
|
|
37
|
+
|
|
38
|
+
```jsonc
|
|
39
|
+
"Widget": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"required": ["id", "name"],
|
|
42
|
+
"properties": {
|
|
43
|
+
"id": { "type": "string" },
|
|
44
|
+
"name": { "type": "string", "minLength": 1 }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- **Documentation and validation cannot drift** - both come from one definition, so there is no
|
|
52
|
+
second source to keep in step
|
|
53
|
+
- **One runtime dependency** - `zod`, and nothing else
|
|
54
|
+
- **No framework** - use the validators in a server, a client, or a test
|
|
55
|
+
- **Types with zero imports** - a contracts module anything can depend on
|
|
56
|
+
- **Built on a public API** - [`typespec-hono`](https://github.com/bison-digital/typespec-hono)
|
|
57
|
+
generates a whole Hono server from it
|
|
12
58
|
|
|
13
59
|
## Install
|
|
14
60
|
|
|
@@ -138,6 +184,12 @@ then a range such as `4XX`, then `default`.
|
|
|
138
184
|
| `vocabularies.gen.ts` | each enum's members as a runtime tuple | `contracts-output-dir` is set |
|
|
139
185
|
| `wire-contract.gen.ts` | assertions pairing the emitted Zod against those types | that and `contracts-package` |
|
|
140
186
|
|
|
187
|
+
`wire-contract.gen.ts` is not imported by any other file. The file contains type-level assertions
|
|
188
|
+
pairing each generated validator with its contract type, so a mismatch between the two is a compile
|
|
189
|
+
error. TypeScript reaches the file through `tsconfig`'s `include`, which has two consequences worth
|
|
190
|
+
knowing before removing it: dependency scanners report the file as unused, and deleting it does not
|
|
191
|
+
break the build until the validator and the contract type diverge.
|
|
192
|
+
|
|
141
193
|
Identifiers are named from the operation id the document publishes. An operation inside a namespace
|
|
142
194
|
carries the whole id, so `op readWidget` in `namespace Widgets` becomes `Widgets_readWidgetPath`.
|
|
143
195
|
|
package/dist/src/api.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export { EmitterOptionsSchema, type EmitterOptions } from "./lib.js";
|
|
|
42
42
|
* emitted module stopped being loadable. This is not a style choice a second emitter may make
|
|
43
43
|
* independently.
|
|
44
44
|
*/
|
|
45
|
-
export { objectKey } from "./zod.js";
|
|
45
|
+
export { jsDocComment, objectKey } from "./zod.js";
|
|
46
46
|
export declare function beginBanner(hint: string | undefined): void;
|
|
47
47
|
/**
|
|
48
48
|
* The status a row registers: the first non-error status the operation declares.
|
|
@@ -69,6 +69,22 @@ export declare function successStatusesOf(operation: HttpOperation): number[];
|
|
|
69
69
|
* listed above", and inventing one would be answering a question the contract left open.
|
|
70
70
|
*/
|
|
71
71
|
export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
|
|
72
|
+
/**
|
|
73
|
+
* Whether the operation declares `@useAuth(NoAuth)` - no caller is established for it.
|
|
74
|
+
*
|
|
75
|
+
* **`@useAuth(NoAuth)` reaches OpenAPI as `security: []`, which is why this is derivable at all.**
|
|
76
|
+
* Whether an operation needs a caller is a contract fact the document publishes. What KIND of caller
|
|
77
|
+
* - account-scoped, resource-scoped - is not: no OpenAPI keyword expresses it, and a server emitter
|
|
78
|
+
* that guesses is enforcing something no document states.
|
|
79
|
+
*/
|
|
80
|
+
/**
|
|
81
|
+
* One requirement the document publishes: scheme name to the scopes it demands.
|
|
82
|
+
*
|
|
83
|
+
* Satisfying **any one** requirement authorises a caller, and every scheme WITHIN a requirement must
|
|
84
|
+
* be satisfied together -- which is what an array of OpenAPI `security` objects means, and why this
|
|
85
|
+
* is published as a list of lists rather than a flat set of scopes.
|
|
86
|
+
*/
|
|
87
|
+
export type SecurityRequirement = Readonly<Record<string, readonly string[]>>;
|
|
72
88
|
/**
|
|
73
89
|
* The body every `@error` response in the service declares, when they all declare the same one.
|
|
74
90
|
*
|
|
@@ -298,6 +314,21 @@ export interface EmittedRoute {
|
|
|
298
314
|
* `requireScopes(...)` repeated per route that nothing checks against it.
|
|
299
315
|
*/
|
|
300
316
|
readonly scopes: readonly string[];
|
|
317
|
+
/**
|
|
318
|
+
* What the DOCUMENT says a caller must satisfy, in the shape the document says it.
|
|
319
|
+
*
|
|
320
|
+
* **`scopes` and `noAuth` are lossy projections of this, and publishing only those was a defect
|
|
321
|
+
* both server emitters had to work around.** A flat scope union cannot say WHICH scheme demanded
|
|
322
|
+
* what, so `@useAuth(BearerAuth)` -- which reaches OpenAPI as `security: [{ "BearerAuth": [] }]`
|
|
323
|
+
* -- arrived here as an empty scope list indistinguishable from no authentication at all. An
|
|
324
|
+
* emitter wanting the gate the document publishes had to call `getAuthenticationForOperation`
|
|
325
|
+
* again and rebuild this itself, and two of them did.
|
|
326
|
+
*
|
|
327
|
+
* Satisfying **any one** requirement authorises; every scheme within a requirement must be
|
|
328
|
+
* satisfied together. Empty where the operation declares `@useAuth(NoAuth)` or nothing, which is
|
|
329
|
+
* a fact rather than an absence.
|
|
330
|
+
*/
|
|
331
|
+
readonly security: readonly SecurityRequirement[];
|
|
301
332
|
/** Which of two success statuses to answer, and the literal property that decides it. */
|
|
302
333
|
readonly statusBy: {
|
|
303
334
|
property: string;
|
|
@@ -405,15 +436,4 @@ export interface EmitHttpZodOptions {
|
|
|
405
436
|
/** The specifier generated files import their runtime contract from when the consumer sets none. */
|
|
406
437
|
readonly defaultRuntimeModule?: string;
|
|
407
438
|
}
|
|
408
|
-
/**
|
|
409
|
-
* Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
|
|
410
|
-
*
|
|
411
|
-
* **The whole orchestration lives here, once.** The nesting below is load-bearing - a walk that
|
|
412
|
-
* runs outside `collectExternalImports` throws, vocabularies must be collected from what the service
|
|
413
|
-
* DECLARES rather than from what the Zod walk happens to touch, and the error body has to be
|
|
414
|
-
* registered inside the walk or its validator never reaches `schemas.gen.ts`. An emitter built on this
|
|
415
|
-
* library calls this rather than restating it: two copies of a sequence that must agree are two
|
|
416
|
-
* copies that will not, and the symptom would be a subtly different set of declarations in a file
|
|
417
|
-
* whose name is the contract.
|
|
418
|
-
*/
|
|
419
439
|
export declare function emitHttpZod(context: EmitContext, wrapper?: EmitHttpZodOptions): Promise<readonly EmittedService[]>;
|
package/dist/src/api.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, NoTarget, resolvePath, } from "@typespec/compiler";
|
|
1
|
+
import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolvePath, } from "@typespec/compiler";
|
|
2
2
|
import { getAuthenticationForOperation, isStatusCode, resolveRequestVisibility, Visibility, } from "@typespec/http";
|
|
3
3
|
import { getOperationId, resolveOperationId } from "@typespec/openapi";
|
|
4
4
|
import { reportDiagnostic } from "./lib.js";
|
|
@@ -48,7 +48,7 @@ export { EmitterOptionsSchema } from "./lib.js";
|
|
|
48
48
|
* emitted module stopped being loadable. This is not a style choice a second emitter may make
|
|
49
49
|
* independently.
|
|
50
50
|
*/
|
|
51
|
-
export { objectKey } from "./zod.js";
|
|
51
|
+
export { jsDocComment, objectKey } from "./zod.js";
|
|
52
52
|
/** Named once. The emitted banner and the internal-invariant messages both quote it. */
|
|
53
53
|
const PACKAGE_NAME = "typespec-http-zod";
|
|
54
54
|
/**
|
|
@@ -392,15 +392,48 @@ function bodySchemaOf(response, registry) {
|
|
|
392
392
|
return undefined;
|
|
393
393
|
}
|
|
394
394
|
/**
|
|
395
|
-
*
|
|
395
|
+
* Everything the document says about authenticating an operation, read ONCE.
|
|
396
396
|
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
397
|
+
* **`scopes` and `noAuth` were two lossy projections of this, each with its own read of the same
|
|
398
|
+
* fact.** A flat scope union cannot say which scheme demanded what, so an emitter that wanted the
|
|
399
|
+
* gate the document actually publishes had to call `getAuthenticationForOperation` a third time and
|
|
400
|
+
* rebuild the requirements itself -- which is what both server emitters were doing, in two
|
|
401
|
+
* hand-written copies of one rule.
|
|
402
|
+
*
|
|
403
|
+
* `scopes` is unchanged in meaning and is now derived from `security` rather than separately read:
|
|
404
|
+
* the union of every scope any requirement demands, which is what it always was.
|
|
401
405
|
*/
|
|
402
|
-
function
|
|
403
|
-
|
|
406
|
+
function authenticationFor(program, operation) {
|
|
407
|
+
const options = getAuthenticationForOperation(program, operation.operation)?.options ?? [];
|
|
408
|
+
const security = [];
|
|
409
|
+
let noAuth = false;
|
|
410
|
+
for (const option of options) {
|
|
411
|
+
const requirement = {};
|
|
412
|
+
let anonymous = false;
|
|
413
|
+
for (const scheme of option.schemes) {
|
|
414
|
+
/**
|
|
415
|
+
* **`NoAuth` inside an option means that option needs nothing**, which is how a spec says
|
|
416
|
+
* authentication is optional here. It is not a scheme to demand, and publishing it as one
|
|
417
|
+
* would make an emitter refuse the anonymous callers the document permits.
|
|
418
|
+
*/
|
|
419
|
+
if (scheme.type === "noAuth") {
|
|
420
|
+
anonymous = true;
|
|
421
|
+
noAuth = true;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
/** Scopes belong to the flows of an OAuth2 scheme; every other kind has none. */
|
|
425
|
+
requirement[scheme.id] =
|
|
426
|
+
scheme.type === "oauth2"
|
|
427
|
+
? [...new Set(scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value)))]
|
|
428
|
+
: [];
|
|
429
|
+
}
|
|
430
|
+
if (anonymous && Object.keys(requirement).length === 0)
|
|
431
|
+
continue;
|
|
432
|
+
if (Object.keys(requirement).length > 0)
|
|
433
|
+
security.push(requirement);
|
|
434
|
+
}
|
|
435
|
+
const scopes = [...new Set(security.flatMap((requirement) => Object.values(requirement).flat()))];
|
|
436
|
+
return { security, scopes, noAuth };
|
|
404
437
|
}
|
|
405
438
|
/**
|
|
406
439
|
* The body every `@error` response in the service declares, when they all declare the same one.
|
|
@@ -1100,9 +1133,36 @@ export function collectRoutes(program, registry, service) {
|
|
|
1100
1133
|
* express "absent". See {@link EmittedRoute.optionalBody}.
|
|
1101
1134
|
*/
|
|
1102
1135
|
const optionalBody = bodyParameter?.property?.optional === true;
|
|
1103
|
-
|
|
1136
|
+
/**
|
|
1137
|
+
* **An optional MULTIPART body reaches the same rule by a second route, because
|
|
1138
|
+
* `requestType` cannot see it.** `requestBodyOf` resolves a type only for
|
|
1139
|
+
* `bodyKind === "single"`, so `requestType` is `undefined` for every multipart operation
|
|
1140
|
+
* and the `optionalBody` disjunct above was unreachable however the document declared it.
|
|
1141
|
+
*
|
|
1142
|
+
* The emitted server for such an operation did not compile. Measured, and the mechanism is
|
|
1143
|
+
* worth stating because it is not a union: spreading a value typed `Parts | undefined`
|
|
1144
|
+
* makes every part OPTIONAL, so `{ file?: ...; pages?: ... }` was passed where
|
|
1145
|
+
* `{ file: ...; pages: ... }` was required. `TS2345`, inside `app.gen.ts` itself.
|
|
1146
|
+
*
|
|
1147
|
+
* That is the failure {@link EmittedRoute.optionalBody} predicts in prose - a merge has no
|
|
1148
|
+
* way to say "these properties are here only sometimes" without making every one of them
|
|
1149
|
+
* optional, which is a weaker and different claim about the body that IS sent. So this is
|
|
1150
|
+
* the stated rule reaching the one body kind it had never been applied to, not a new rule.
|
|
1151
|
+
*
|
|
1152
|
+
* **The REQUIRED arm is deliberately excluded.** The indexed reason does not apply to a
|
|
1153
|
+
* parts model, so a required multipart body merges as safely as a required single one, and
|
|
1154
|
+
* `typespec-hono/test/filepart/` compiles a handler that reads `input.file` directly.
|
|
1155
|
+
* Widening this to "multipart is always named" would rewrite that directory for no defect.
|
|
1156
|
+
*
|
|
1157
|
+
* This is the third hole the `"single"` gate has produced, after `multipartSchemaOf` and
|
|
1158
|
+
* `multipartTsOf`, both recorded above.
|
|
1159
|
+
*/
|
|
1160
|
+
const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
|
|
1161
|
+
const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
|
|
1162
|
+
optionalMultipartBody
|
|
1104
1163
|
? (bodyParameter?.property?.name ?? "body")
|
|
1105
1164
|
: undefined;
|
|
1165
|
+
const authentication = authenticationFor(program, operation);
|
|
1106
1166
|
routes.push({
|
|
1107
1167
|
operationId: operationIdOf(program, operation.operation),
|
|
1108
1168
|
verb: operation.verb.toUpperCase(),
|
|
@@ -1188,12 +1248,9 @@ export function collectRoutes(program, registry, service) {
|
|
|
1188
1248
|
errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
|
|
1189
1249
|
responseHeaders: responseHeadersOf(program, operation),
|
|
1190
1250
|
responseMediaTypes: responseMediaTypesOf(operation),
|
|
1191
|
-
scopes:
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
: []))),
|
|
1195
|
-
],
|
|
1196
|
-
noAuth: noAuthFor(program, operation),
|
|
1251
|
+
scopes: authentication.scopes,
|
|
1252
|
+
security: authentication.security,
|
|
1253
|
+
noAuth: authentication.noAuth,
|
|
1197
1254
|
statusBy: discriminator,
|
|
1198
1255
|
statusSelector,
|
|
1199
1256
|
/**
|
|
@@ -1630,61 +1687,83 @@ function collectRequestTypes(program, registry, service) {
|
|
|
1630
1687
|
for (const operation of service.operations) {
|
|
1631
1688
|
if (successStatusOf(operation) === undefined)
|
|
1632
1689
|
continue;
|
|
1633
|
-
const body = requestBodyOf(operation);
|
|
1634
|
-
/** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
|
|
1635
|
-
const multipartBody = operation.parameters.body;
|
|
1636
|
-
const multipart = multipartBody?.bodyKind === "multipart" ? { parts: multipartBody.parts } : undefined;
|
|
1637
1690
|
/**
|
|
1638
|
-
*
|
|
1691
|
+
* **Walked at the operation's REQUEST visibility, which is the whole point of this file.**
|
|
1639
1692
|
*
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
1642
|
-
*
|
|
1693
|
+
* `resolveRequestVisibility` is `@typespec/http`'s own answer - POST is Create, PATCH is
|
|
1694
|
+
* Update, PUT is CreateOrUpdate - and it is what the Zod walk already uses at the route
|
|
1695
|
+
* level. Without it this walk ran at the default `Read`, so a POST body's contract type
|
|
1696
|
+
* carried the canonical property set while the validator beside it accepted the create
|
|
1697
|
+
* projection. A caller satisfying the published type sent read-only fields and a strict
|
|
1698
|
+
* validator answered 400.
|
|
1699
|
+
*
|
|
1700
|
+
* It is also what keeps `metadataInfo` scoped: the predicates `propertyToTs` consults live
|
|
1701
|
+
* behind `withVisibility`, and calling them outside one leaves a module-level singleton
|
|
1702
|
+
* built for the wrong program.
|
|
1643
1703
|
*/
|
|
1644
|
-
|
|
1645
|
-
|
|
1704
|
+
withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => {
|
|
1705
|
+
const body = requestBodyOf(operation);
|
|
1706
|
+
/** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
|
|
1707
|
+
const multipartBody = operation.parameters.body;
|
|
1708
|
+
const multipart = multipartBody?.bodyKind === "multipart" ? { parts: multipartBody.parts } : undefined;
|
|
1646
1709
|
/**
|
|
1647
|
-
*
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
1650
|
-
*
|
|
1710
|
+
* Headers are part of the input too.
|
|
1711
|
+
*
|
|
1712
|
+
* The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
|
|
1713
|
+
* beside the payload, they are arguments to the verification. Leaving them out of the wire
|
|
1714
|
+
* type would describe an input the application cannot act on.
|
|
1651
1715
|
*/
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1716
|
+
const extraProperties = operation.parameters.parameters
|
|
1717
|
+
.filter((parameter) => parameter.type === "path" ||
|
|
1718
|
+
parameter.type === "query" ||
|
|
1719
|
+
parameter.type === "header")
|
|
1720
|
+
/**
|
|
1721
|
+
* Named as the WIRE names it, which is what the emitted validator keys on.
|
|
1722
|
+
* `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
|
|
1723
|
+
* `"x-thing"` here too: a consumer checking its handlers against these types otherwise
|
|
1724
|
+
* fails to typecheck against a server that is correct.
|
|
1725
|
+
*/
|
|
1726
|
+
.map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
|
|
1727
|
+
// `return` rather than `continue`: this body is a callback now, and returning from it
|
|
1728
|
+
// skips the operation exactly as the `continue` did.
|
|
1729
|
+
if (body === undefined && extraProperties.length === 0)
|
|
1730
|
+
return;
|
|
1731
|
+
/**
|
|
1732
|
+
* A `bytes` body reaches the use-case as a NAMED property holding the unparsed body, not as
|
|
1733
|
+
* a bare value, so the wire type is `{ rawBody: ... }`, matching what a server actually
|
|
1734
|
+
* builds. Referencing the scalar directly would type the whole input as that value and
|
|
1735
|
+
* quietly drop the headers beside it.
|
|
1736
|
+
*/
|
|
1737
|
+
const rawBody = body?.kind === "Scalar" && body.name === "bytes"
|
|
1738
|
+
? (operation.parameters.body?.property?.name ?? "body")
|
|
1739
|
+
: undefined;
|
|
1740
|
+
/**
|
|
1741
|
+
* **Raw binary is `ArrayBuffer`; base64 inside a JSON payload is `string`.** The same
|
|
1742
|
+
* `isRawBinaryMediaType` rule the response side and the multipart parts already follow,
|
|
1743
|
+
* applied here for the first time.
|
|
1744
|
+
*
|
|
1745
|
+
* This type said `string` for every `bytes` body whatever it was served as. A server reading
|
|
1746
|
+
* an `application/octet-stream` body hands the handler bytes, so a consumer checking its
|
|
1747
|
+
* handlers against these types could not typecheck against a correct server. It is the
|
|
1748
|
+
* request half of the corruption already fixed on the reader: decoding raw bytes as text
|
|
1749
|
+
* replaces every byte outside ASCII, and typing them as text is what invites it.
|
|
1750
|
+
*/
|
|
1751
|
+
const rawBodyType = isRawBinaryMediaType([
|
|
1752
|
+
...(operation.parameters.body?.contentTypes ?? []),
|
|
1753
|
+
])
|
|
1754
|
+
? "ArrayBuffer"
|
|
1755
|
+
: "string";
|
|
1756
|
+
entries.push({
|
|
1757
|
+
operationId: operationIdOf(program, operation.operation),
|
|
1758
|
+
bodyRef: rawBody !== undefined
|
|
1759
|
+
? `{\n\t${rawBody}: ${rawBodyType};\n}`
|
|
1760
|
+
: multipart !== undefined
|
|
1761
|
+
? multipartTsOf(multipart, registry)
|
|
1762
|
+
: body === undefined
|
|
1763
|
+
? undefined
|
|
1764
|
+
: registry.expressionFor(body),
|
|
1765
|
+
extraProperties,
|
|
1766
|
+
});
|
|
1688
1767
|
});
|
|
1689
1768
|
}
|
|
1690
1769
|
}
|
|
@@ -1701,7 +1780,15 @@ function collectRequestTypes(program, registry, service) {
|
|
|
1701
1780
|
*/
|
|
1702
1781
|
function collectResponseTypes(program, registry, service) {
|
|
1703
1782
|
const entries = [];
|
|
1704
|
-
|
|
1783
|
+
/**
|
|
1784
|
+
* **Pinned to `Read` explicitly, rather than inheriting whatever the caller last set.**
|
|
1785
|
+
*
|
|
1786
|
+
* A response is the canonical projection whatever verb produced it, and the same rule is stated
|
|
1787
|
+
* for the route walk: resolving responses under the ambient REQUEST visibility declared six
|
|
1788
|
+
* components under the wrong suffix. Saying it here rather than relying on the default is what
|
|
1789
|
+
* makes that impossible from either direction.
|
|
1790
|
+
*/
|
|
1791
|
+
withVisibility(program, Visibility.Read, () => {
|
|
1705
1792
|
for (const operation of service.operations) {
|
|
1706
1793
|
const status = successStatusOf(operation);
|
|
1707
1794
|
if (status === undefined)
|
|
@@ -1714,7 +1801,7 @@ function collectResponseTypes(program, registry, service) {
|
|
|
1714
1801
|
ref: registry.expressionFor(body),
|
|
1715
1802
|
});
|
|
1716
1803
|
}
|
|
1717
|
-
}
|
|
1804
|
+
});
|
|
1718
1805
|
return entries;
|
|
1719
1806
|
}
|
|
1720
1807
|
/**
|
|
@@ -1728,10 +1815,23 @@ function collectResponseTypes(program, registry, service) {
|
|
|
1728
1815
|
*/
|
|
1729
1816
|
function renderRequestTypes(entries, responses, registry) {
|
|
1730
1817
|
const declarations = registry.declarations().map((declaration) => {
|
|
1731
|
-
|
|
1732
|
-
|
|
1818
|
+
/**
|
|
1819
|
+
* **A vocabulary alias is exported like any other declaration.**
|
|
1820
|
+
*
|
|
1821
|
+
* It used to stay module-local, on the stated ground that "the vocabularies artefact already
|
|
1822
|
+
* exports these names as runtime tuples, and exporting both collides on every one". That was
|
|
1823
|
+
* true of a shape this emitter no longer produces: `vocabularies.gen.ts` has exactly one
|
|
1824
|
+
* top-level export, `SPEC_VOCABULARIES`, and the enum names are KEYS inside it - see the
|
|
1825
|
+
* docblock on `renderVocabularies`, which records why the keyed form replaced individually
|
|
1826
|
+
* named tuples. There is nothing left to collide with.
|
|
1827
|
+
*
|
|
1828
|
+
* The stale rationale was load-bearing: `wire-contract.gen.ts` names `Contracts.<EnumName>`
|
|
1829
|
+
* for an enum used as a request body, and a barrel cannot re-export a declaration that is not
|
|
1830
|
+
* exported. So that file did not compile for any such spec. `TS2694`, found the first time
|
|
1831
|
+
* anything compiled the conformance corpus rather than reading it.
|
|
1832
|
+
*/
|
|
1733
1833
|
if (declaration.isVocabulary)
|
|
1734
|
-
return `\
|
|
1834
|
+
return `\nexport type ${declaration.name} = ${declaration.source};\n`;
|
|
1735
1835
|
return declaration.isObject
|
|
1736
1836
|
? `\nexport interface ${declaration.name} ${declaration.source}\n`
|
|
1737
1837
|
: `\nexport type ${declaration.name} = ${declaration.source};\n`;
|
|
@@ -1970,9 +2070,20 @@ type Declared<T> = T extends (...args: never[]) => unknown
|
|
|
1970
2070
|
: readonly Declared<Element>[]
|
|
1971
2071
|
: T extends object
|
|
1972
2072
|
? [DeclaredKeyOf<T>] extends [never]
|
|
1973
|
-
?
|
|
1974
|
-
//
|
|
1975
|
-
|
|
2073
|
+
? [T[keyof T]] extends [never]
|
|
2074
|
+
? // **An indexer whose VALUE type is \`never\` admits no property, so it is an EMPTY
|
|
2075
|
+
// OBJECT rather than a dictionary.** Zod spells an empty object that way:
|
|
2076
|
+
// \`z.strictObject({})\` infers \`Record<string, never>\` under Zod 4, while this
|
|
2077
|
+
// walk emits \`{}\` for the same model. The two describe one shape and compared
|
|
2078
|
+
// unequal, so \`wire-contract.gen.ts\` did not compile for any spec declaring an
|
|
2079
|
+
// empty model. Normalising both to \`{}\` is what makes them agree, and it cannot
|
|
2080
|
+
// equate a real dictionary with an empty object: a dictionary whose values are
|
|
2081
|
+
// \`never\` accepts nothing either.
|
|
2082
|
+
{}
|
|
2083
|
+
: // A shape with no declared keys is a dictionary: it has no optional property to
|
|
2084
|
+
// widen, and an index signature satisfies \`{} extends Pick<T, K>\` for every
|
|
2085
|
+
// value type.
|
|
2086
|
+
{ [K in keyof T]: Declared<T[K]> }
|
|
1976
2087
|
: {
|
|
1977
2088
|
[K in keyof T as string extends K
|
|
1978
2089
|
? never
|
|
@@ -2031,6 +2142,49 @@ export const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
|
|
|
2031
2142
|
* copies that will not, and the symptom would be a subtly different set of declarations in a file
|
|
2032
2143
|
* whose name is the contract.
|
|
2033
2144
|
*/
|
|
2145
|
+
/**
|
|
2146
|
+
* The doc tags TypeSpec understands. Anything else parses as an unknown tag, and the description
|
|
2147
|
+
* ends where it began. Written out rather than imported: `SyntaxKind` lives behind
|
|
2148
|
+
* `@typespec/compiler/ast`, whose exports map declares no `types` condition, and comparing the tag
|
|
2149
|
+
* NAME needs no AST import at all.
|
|
2150
|
+
*/
|
|
2151
|
+
const KNOWN_DOC_TAGS = new Set(["param", "template", "prop", "returns", "return", "errors"]);
|
|
2152
|
+
/** See the `truncated-doc-comment` docblock in `lib.ts` for why this reads the node, not the string. */
|
|
2153
|
+
function reportTruncatedDocComments(program) {
|
|
2154
|
+
const check = (type) => {
|
|
2155
|
+
if (getLocationContext(program, type).type !== "project")
|
|
2156
|
+
return;
|
|
2157
|
+
// An `@doc(...)` decorator wins, and `@` inside a string literal truncates nothing.
|
|
2158
|
+
if (getDocData(program, type)?.source !== "comment")
|
|
2159
|
+
return;
|
|
2160
|
+
const docs = type.node?.docs;
|
|
2161
|
+
for (const doc of docs ?? []) {
|
|
2162
|
+
for (const tag of doc.tags) {
|
|
2163
|
+
if (KNOWN_DOC_TAGS.has(tag.tagName.sv))
|
|
2164
|
+
continue;
|
|
2165
|
+
reportDiagnostic(program, {
|
|
2166
|
+
code: "truncated-doc-comment",
|
|
2167
|
+
target: type,
|
|
2168
|
+
format: {
|
|
2169
|
+
name: typeof type.name === "string" && type.name !== "" ? type.name : "(anonymous)",
|
|
2170
|
+
tag: tag.tagName.sv,
|
|
2171
|
+
},
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
};
|
|
2176
|
+
navigateProgram(program, {
|
|
2177
|
+
model: check,
|
|
2178
|
+
modelProperty: check,
|
|
2179
|
+
operation: check,
|
|
2180
|
+
enum: check,
|
|
2181
|
+
enumMember: check,
|
|
2182
|
+
union: check,
|
|
2183
|
+
unionVariant: check,
|
|
2184
|
+
interface: check,
|
|
2185
|
+
scalar: check,
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2034
2188
|
export async function emitHttpZod(context, wrapper = {}) {
|
|
2035
2189
|
const options = context.options;
|
|
2036
2190
|
const defaultRuntimeModule = wrapper.defaultRuntimeModule ?? DEFAULT_RUNTIME_MODULE;
|
|
@@ -2105,6 +2259,11 @@ export async function emitHttpZod(context, wrapper = {}) {
|
|
|
2105
2259
|
format: { sealed: sealed.join(", "), open: open.join(", ") },
|
|
2106
2260
|
});
|
|
2107
2261
|
}
|
|
2262
|
+
/**
|
|
2263
|
+
* Once per program, before the per-service loop: a doc comment belongs to a type, not to a
|
|
2264
|
+
* service, and a type reachable from two services would otherwise be named twice.
|
|
2265
|
+
*/
|
|
2266
|
+
reportTruncatedDocComments(context.program);
|
|
2108
2267
|
for (const snapshot of snapshots) {
|
|
2109
2268
|
beginOperationIds();
|
|
2110
2269
|
const service = snapshot.service;
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -155,6 +155,9 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
155
155
|
"default-on-required-property": {
|
|
156
156
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
157
157
|
};
|
|
158
|
+
"truncated-doc-comment": {
|
|
159
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "tag", "tag"]>;
|
|
160
|
+
};
|
|
158
161
|
"undeclared-discriminator": {
|
|
159
162
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
|
|
160
163
|
};
|
|
@@ -177,7 +180,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
|
|
|
177
180
|
readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
|
|
178
181
|
};
|
|
179
182
|
}, EmitterOptions, never>;
|
|
180
|
-
export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
183
|
+
export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "truncated-doc-comment" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
|
|
181
184
|
"unsupported-type": {
|
|
182
185
|
readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
|
|
183
186
|
};
|
|
@@ -187,6 +190,9 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
187
190
|
"default-on-required-property": {
|
|
188
191
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
189
192
|
};
|
|
193
|
+
"truncated-doc-comment": {
|
|
194
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "tag", "tag"]>;
|
|
195
|
+
};
|
|
190
196
|
"undeclared-discriminator": {
|
|
191
197
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
|
|
192
198
|
};
|
|
@@ -218,6 +224,9 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
|
|
|
218
224
|
"default-on-required-property": {
|
|
219
225
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
|
|
220
226
|
};
|
|
227
|
+
"truncated-doc-comment": {
|
|
228
|
+
readonly default: import("@typespec/compiler").CallableMessage<["name", "tag", "tag"]>;
|
|
229
|
+
};
|
|
221
230
|
"undeclared-discriminator": {
|
|
222
231
|
readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
|
|
223
232
|
};
|
package/dist/src/lib.js
CHANGED
|
@@ -114,6 +114,50 @@ const diagnostics = {
|
|
|
114
114
|
default: paramMessage `'${"name"}' is required, so its default can never apply: a required property is never absent. The OpenAPI document publishes it in 'required' and carries the default only as an annotation, and this validator does the same - a request omitting '${"name"}' is refused. Declare it optional, '${"name"}?: ... = ...', if callers may omit it.`,
|
|
115
115
|
},
|
|
116
116
|
},
|
|
117
|
+
/**
|
|
118
|
+
* **A doc comment the compiler truncated, so the description this emitter passes on is cut short.**
|
|
119
|
+
*
|
|
120
|
+
* **An unescaped `@` in a doc comment begins a doc tag, and everything from there to the end of
|
|
121
|
+
* the comment leaves the description.** Measured against `@typespec/compiler@1.15.0` through its
|
|
122
|
+
* own parser: `parseDocContent` breaks its loop on `Token.At` outside a code fence, and the
|
|
123
|
+
* doc-mode scanner turns every `@` into that token. `parseDocTag` then parses the remainder as a
|
|
124
|
+
* tag and, for a name it does not know, keeps it as `DocUnknownTag` - which `getDoc` never reads.
|
|
125
|
+
* No diagnostic is raised anywhere in that path.
|
|
126
|
+
*
|
|
127
|
+
* So it is not only `{@link}`. `support@example.com` in prose truncates at `support`, and
|
|
128
|
+
* "use @maxLength to bound it" truncates at "use", with no brace anywhere to notice. A consumer
|
|
129
|
+
* measured 14 of 1,006 published descriptions cut this way and found it by reading the emitted
|
|
130
|
+
* document by hand.
|
|
131
|
+
*
|
|
132
|
+
* **The check reads the AST, because the truncated string cannot answer.** By the time `getDoc`
|
|
133
|
+
* returns, the `@` is gone, so a check over the emitted text would find nothing to match - and
|
|
134
|
+
* pattern-matching emitted text is the thing this package refuses to decide anything by. The fact
|
|
135
|
+
* is on the node: a tag that is not `param`, `template`, `prop`, `returns`, `return` or `errors`
|
|
136
|
+
* parses as `DocUnknownTag`. That is exact, with no heuristic and no false positive on the four
|
|
137
|
+
* spellings that survive: a brace in prose, a backticked brace, `\{`, and `\@`.
|
|
138
|
+
*
|
|
139
|
+
* **Gated on the description actually coming from the comment**, via `getDocData().source`. A type
|
|
140
|
+
* carrying an `@doc(...)` decorator takes its description from there, where `@` inside a string
|
|
141
|
+
* literal means nothing, so warning would be a false positive - and a warning that fires on
|
|
142
|
+
* correct specs is one a consumer turns off wholesale.
|
|
143
|
+
*
|
|
144
|
+
* **A WARNING, for the reason `default-on-required-property` is one.** The spec is representable:
|
|
145
|
+
* `@typespec/openapi3` publishes the same truncated description happily, so refusing it would make
|
|
146
|
+
* one spec emit under one emitter and not the other. An error would also set `program.hasError()`
|
|
147
|
+
* and cost the whole document over one comment. What is wrong is not that it cannot be served, but
|
|
148
|
+
* that the author has written something that does not say what they meant.
|
|
149
|
+
*
|
|
150
|
+
* **Upstream this is intended, and the escape is sanctioned.** `microsoft/typespec#3374` reported
|
|
151
|
+
* exactly this, naming both `{@link}` and email addresses, and was closed as a duplicate of
|
|
152
|
+
* `#1358`, which is open on the Backlog. `#3375` then added the `\@` escape deliberately. So the
|
|
153
|
+
* remedy this message names is upstream's own, and only the silence is unaccounted for.
|
|
154
|
+
*/
|
|
155
|
+
"truncated-doc-comment": {
|
|
156
|
+
severity: "warning",
|
|
157
|
+
messages: {
|
|
158
|
+
default: paramMessage `'${"name"}' has a doc comment that stops at '@${"tag"}': an unescaped '@' begins a doc tag, so everything after it is dropped from the description this emitter and the OpenAPI document beside it both publish. Use '\\@${"tag"}' to escape it, wrap it in backticks, or set the description with '@doc(...)' instead.`,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
117
161
|
/**
|
|
118
162
|
* **A discriminated union whose discriminator the document never publishes.**
|
|
119
163
|
*
|
package/dist/src/registry.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ModelProperty, Program, Type } from "@typespec/compiler";
|
|
2
|
+
import { Visibility } from "@typespec/http";
|
|
2
3
|
/**
|
|
3
4
|
* Assigns each named TypeSpec model/enum a single Zod declaration, emitted in dependency order.
|
|
4
5
|
*
|
|
@@ -54,6 +55,46 @@ interface Declaration {
|
|
|
54
55
|
/** Whether the declaration is annotated `: z.ZodType<typeName>`, which `z.lazy()` requires. */
|
|
55
56
|
readonly annotated?: boolean;
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* One model can need MORE THAN ONE declaration.
|
|
60
|
+
*
|
|
61
|
+
* **Keyed on `(type, visibility)`, because a model is not one shape.** `@visibility` and HTTP
|
|
62
|
+
* metadata both mean the same declaration projects differently by position: `@typespec/openapi3`
|
|
63
|
+
* emits `VisibilityModel` and `VisibilityModelCreate` as separate components for exactly this
|
|
64
|
+
* reason. Keyed on the type alone, whichever position was walked first won and every other position
|
|
65
|
+
* silently got its shape - measured on the corpus, one strict model carrying all six lifecycle
|
|
66
|
+
* properties, so a create request was required to send read-only fields.
|
|
67
|
+
*
|
|
68
|
+
* The suffix rule is openapi3's: canonical (`Read`) keeps the bare name, and another visibility only
|
|
69
|
+
* earns a suffix when the model is actually TRANSFORMED at it. Same rule, so the identifiers line up
|
|
70
|
+
* with the component names the document publishes.
|
|
71
|
+
*/
|
|
72
|
+
/**
|
|
73
|
+
* The visibility a type is actually DECLARED at, which is not always the one requested.
|
|
74
|
+
*
|
|
75
|
+
* **A type not reshaped at this visibility IS the canonical declaration, not a copy of it.** Keying
|
|
76
|
+
* on the requested visibility while only suffixing transformed types produced two cache entries
|
|
77
|
+
* sharing one identifier and an emitted file that stopped parsing:
|
|
78
|
+
* `Identifier 'approvalTierSchema' has already been declared`. An enum is the same enum in a request
|
|
79
|
+
* and a response, which is what the `Model` guard is for.
|
|
80
|
+
*
|
|
81
|
+
* It is the same collapse `@typespec/openapi3` performs before naming a component, and
|
|
82
|
+
* `isTransformedBy` is its own predicate, `metadataInfo.isTransformed`.
|
|
83
|
+
*
|
|
84
|
+
* **Extracted so both registries answer it identically.** They must: the emitted assertion pairs a
|
|
85
|
+
* validator with a contract type BY NAME, so a rule applied in one walk and paraphrased in the other
|
|
86
|
+
* is a pairing that silently compares the wrong two things.
|
|
87
|
+
*/
|
|
88
|
+
export declare function visibilityFor(program: Program, type: Type, requested: Visibility): Visibility;
|
|
89
|
+
/**
|
|
90
|
+
* A declaration's name at a visibility: the bare name at canonical, suffixed anywhere else.
|
|
91
|
+
*
|
|
92
|
+
* The suffix is `@typespec/http`'s `getVisibilitySuffix`, called with `Visibility.Read` as the
|
|
93
|
+
* canonical exactly as `@typespec/openapi3` calls it in `schema-emitter.ts`. Same function, same
|
|
94
|
+
* argument, so these identifiers line up with the component names the document publishes rather than
|
|
95
|
+
* merely resembling them.
|
|
96
|
+
*/
|
|
97
|
+
export declare function nameAt(bare: string, at: Visibility): string;
|
|
57
98
|
export declare class SchemaRegistry {
|
|
58
99
|
#private;
|
|
59
100
|
constructor(program: Program);
|
package/dist/src/registry.js
CHANGED
|
@@ -78,11 +78,22 @@ const spreadSourceName = (type) => spreadSourceOf(type)?.name;
|
|
|
78
78
|
* so a word missing from the list fails a test rather than reaching a consumer - the list is the
|
|
79
79
|
* implementation and the compiler is the oracle.
|
|
80
80
|
*
|
|
81
|
-
*
|
|
82
|
-
* `
|
|
83
|
-
*
|
|
81
|
+
* Most contextual keywords are absent, because they are legal: `interface`, `string`, `number`,
|
|
82
|
+
* `any`, `never` and `public` all name a type without complaint.
|
|
83
|
+
*
|
|
84
|
+
* **Two contextual words are NOT legal, and both were missing from this set.** `as` and `yield` are
|
|
85
|
+
* refused in the declaration positions this emitter writes -- `export type as = ...` is
|
|
86
|
+
* `TS1005: '{' expected`, and `yield` is `TS1214: 'yield' is a reserved word in strict mode` as both
|
|
87
|
+
* a type alias and an interface. Either one made the whole emitted file unparseable, because the
|
|
88
|
+
* parser gives up after the first.
|
|
89
|
+
*
|
|
90
|
+
* **A word belongs here when `tsc` refuses it in EITHER position**, since both are emitted for the
|
|
91
|
+
* same model. Measured that way against tsc 7.0.2, one invocation per word: of the 56 candidates,
|
|
92
|
+
* 39 are refused and 39 are listed. `yield` had been recorded here as measured-legal and is not,
|
|
93
|
+
* which is why the arm that grades this set now takes its words from somewhere else entirely.
|
|
84
94
|
*/
|
|
85
95
|
const RESERVED_DECLARATION_NAMES = new Set([
|
|
96
|
+
"as",
|
|
86
97
|
"await",
|
|
87
98
|
"break",
|
|
88
99
|
"case",
|
|
@@ -120,6 +131,7 @@ const RESERVED_DECLARATION_NAMES = new Set([
|
|
|
120
131
|
"void",
|
|
121
132
|
"while",
|
|
122
133
|
"with",
|
|
134
|
+
"yield",
|
|
123
135
|
]);
|
|
124
136
|
/**
|
|
125
137
|
* The name a declaration is emitted under.
|
|
@@ -185,6 +197,40 @@ const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
|
|
|
185
197
|
* earns a suffix when the model is actually TRANSFORMED at it. Same rule, so the identifiers line up
|
|
186
198
|
* with the component names the document publishes.
|
|
187
199
|
*/
|
|
200
|
+
/**
|
|
201
|
+
* The visibility a type is actually DECLARED at, which is not always the one requested.
|
|
202
|
+
*
|
|
203
|
+
* **A type not reshaped at this visibility IS the canonical declaration, not a copy of it.** Keying
|
|
204
|
+
* on the requested visibility while only suffixing transformed types produced two cache entries
|
|
205
|
+
* sharing one identifier and an emitted file that stopped parsing:
|
|
206
|
+
* `Identifier 'approvalTierSchema' has already been declared`. An enum is the same enum in a request
|
|
207
|
+
* and a response, which is what the `Model` guard is for.
|
|
208
|
+
*
|
|
209
|
+
* It is the same collapse `@typespec/openapi3` performs before naming a component, and
|
|
210
|
+
* `isTransformedBy` is its own predicate, `metadataInfo.isTransformed`.
|
|
211
|
+
*
|
|
212
|
+
* **Extracted so both registries answer it identically.** They must: the emitted assertion pairs a
|
|
213
|
+
* validator with a contract type BY NAME, so a rule applied in one walk and paraphrased in the other
|
|
214
|
+
* is a pairing that silently compares the wrong two things.
|
|
215
|
+
*/
|
|
216
|
+
export function visibilityFor(program, type, requested) {
|
|
217
|
+
return type.kind === "Model" &&
|
|
218
|
+
requested !== Visibility.Read &&
|
|
219
|
+
isTransformedBy(program, type, requested)
|
|
220
|
+
? requested
|
|
221
|
+
: Visibility.Read;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* A declaration's name at a visibility: the bare name at canonical, suffixed anywhere else.
|
|
225
|
+
*
|
|
226
|
+
* The suffix is `@typespec/http`'s `getVisibilitySuffix`, called with `Visibility.Read` as the
|
|
227
|
+
* canonical exactly as `@typespec/openapi3` calls it in `schema-emitter.ts`. Same function, same
|
|
228
|
+
* argument, so these identifiers line up with the component names the document publishes rather than
|
|
229
|
+
* merely resembling them.
|
|
230
|
+
*/
|
|
231
|
+
export function nameAt(bare, at) {
|
|
232
|
+
return `${bare}${at === Visibility.Read ? "" : getVisibilitySuffix(at, Visibility.Read)}`;
|
|
233
|
+
}
|
|
188
234
|
function keyFor(type, visibility) {
|
|
189
235
|
/**
|
|
190
236
|
* **Keyed on the SPREAD SOURCE where there is one, or one component gets two declarations.**
|
|
@@ -244,12 +290,7 @@ export class SchemaRegistry {
|
|
|
244
290
|
* request and a response. Collapsing onto `Read` first is what makes the key and the name agree,
|
|
245
291
|
* and it is the same collapse openapi3 performs before naming a component.
|
|
246
292
|
*/
|
|
247
|
-
const
|
|
248
|
-
const at = type.kind === "Model" &&
|
|
249
|
-
requested !== Visibility.Read &&
|
|
250
|
-
isTransformedBy(this.#program, type, requested)
|
|
251
|
-
? requested
|
|
252
|
-
: Visibility.Read;
|
|
293
|
+
const at = visibilityFor(this.#program, type, currentVisibility());
|
|
253
294
|
const key = keyFor(type, at);
|
|
254
295
|
const existing = this.#declarations.get(key);
|
|
255
296
|
if (existing !== undefined)
|
|
@@ -275,7 +316,7 @@ export class SchemaRegistry {
|
|
|
275
316
|
this.#cyclic.add(inFlight);
|
|
276
317
|
return pending;
|
|
277
318
|
}
|
|
278
|
-
const name =
|
|
319
|
+
const name = nameAt(bare, at);
|
|
279
320
|
const identifier = `${lowerFirst(name)}Schema`;
|
|
280
321
|
this.#inProgress.set(key, identifier);
|
|
281
322
|
const rendered = captureBackEdges(() => this.#inline(type));
|
|
@@ -459,17 +500,32 @@ function isSingleObjectLiteral(source) {
|
|
|
459
500
|
}
|
|
460
501
|
export class TypeRegistry {
|
|
461
502
|
#program;
|
|
503
|
+
/**
|
|
504
|
+
* **Keyed on `(type, visibility)`, exactly as {@link SchemaRegistry} is, and for the same reason.**
|
|
505
|
+
*
|
|
506
|
+
* One model is not one shape. Keyed on the `Type` alone, whichever position was walked first won
|
|
507
|
+
* and every other position silently got its shape - measured on `type/model/visibility`, where a
|
|
508
|
+
* `GET` reached the model first and the create body's contract type came out carrying the read
|
|
509
|
+
* projection. The two registries pair BY NAME in the emitted assertion, so they have to agree
|
|
510
|
+
* about how many declarations there are before the pairing means anything.
|
|
511
|
+
*
|
|
512
|
+
* Both now go through the shared {@link visibilityFor} and {@link nameAt}: paraphrasing either
|
|
513
|
+
* rule in one walk is how a validator ends up compared against a type describing something else.
|
|
514
|
+
*/
|
|
462
515
|
#declarations = new Map();
|
|
463
516
|
#order = [];
|
|
464
|
-
#inProgress = new
|
|
517
|
+
#inProgress = new Map();
|
|
465
518
|
constructor(program) {
|
|
466
519
|
this.#program = program;
|
|
467
520
|
}
|
|
468
521
|
expressionFor(type) {
|
|
469
|
-
const
|
|
470
|
-
if (
|
|
522
|
+
const bare = declaredNameOf(type);
|
|
523
|
+
if (bare === undefined)
|
|
471
524
|
return this.#inline(type);
|
|
472
|
-
const
|
|
525
|
+
const at = visibilityFor(this.#program, type, currentVisibility());
|
|
526
|
+
const key = keyFor(type, at);
|
|
527
|
+
const name = nameAt(bare, at);
|
|
528
|
+
const existing = this.#declarations.get(key);
|
|
473
529
|
if (existing !== undefined)
|
|
474
530
|
return existing.name;
|
|
475
531
|
/**
|
|
@@ -477,12 +533,17 @@ export class TypeRegistry {
|
|
|
477
533
|
* may refer to itself - `interface InnerModel { children?: InnerModel[] }` is the ordinary way
|
|
478
534
|
* to write a tree - so the name alone is the whole answer. The Zod registry has to defer the
|
|
479
535
|
* same edge behind a getter only because a `const` cannot read itself while initialising.
|
|
536
|
+
*
|
|
537
|
+
* The in-flight name is the SUFFIXED one, not the bare one: a recursive model reached at
|
|
538
|
+
* `Create` must refer to `ThingCreate`, or it self-references a declaration that is not the
|
|
539
|
+
* one being written.
|
|
480
540
|
*/
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
541
|
+
const pending = this.#inProgress.get(key);
|
|
542
|
+
if (pending !== undefined)
|
|
543
|
+
return pending;
|
|
544
|
+
this.#inProgress.set(key, name);
|
|
484
545
|
const source = this.#inline(type);
|
|
485
|
-
this.#inProgress.delete(
|
|
546
|
+
this.#inProgress.delete(key);
|
|
486
547
|
const declaration = {
|
|
487
548
|
name,
|
|
488
549
|
/**
|
|
@@ -508,7 +569,7 @@ export class TypeRegistry {
|
|
|
508
569
|
isVocabulary: type.kind === "Enum",
|
|
509
570
|
source,
|
|
510
571
|
};
|
|
511
|
-
this.#declarations.set(
|
|
572
|
+
this.#declarations.set(key, declaration);
|
|
512
573
|
/**
|
|
513
574
|
* **Declared once per NAME, not once per `Type`, and the two are not the same thing.**
|
|
514
575
|
*
|
package/dist/src/types.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getDiscriminatedUnion, getEncode, isNeverType, resolveEncodedName, } from "@typespec/compiler";
|
|
2
2
|
import { reportDiagnostic } from "./lib.js";
|
|
3
|
-
import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
|
|
3
|
+
import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
|
|
4
4
|
/**
|
|
5
5
|
* TypeSpec type -> plain TypeScript type text.
|
|
6
6
|
*
|
|
@@ -196,7 +196,20 @@ function modelToTs(program, model) {
|
|
|
196
196
|
* that catches this emitter disagreeing with itself, and it cannot catch a disagreement that
|
|
197
197
|
* aborts the compile first.
|
|
198
198
|
*/
|
|
199
|
+
/**
|
|
200
|
+
* **Metadata is filtered out here, as it is on the Zod side, and the two must agree.**
|
|
201
|
+
*
|
|
202
|
+
* `metadataInfo.isPayloadProperty` is what decides whether a property is in the BODY at this
|
|
203
|
+
* position: a `@query` or `@header` property is carried separately, and an `@invisible` one is
|
|
204
|
+
* carried nowhere. `modelToZod` has asked since it was written; this walk never did, so a
|
|
205
|
+
* contract type listed properties the validator beside it refuses as unknown keys.
|
|
206
|
+
*
|
|
207
|
+
* **Applied AFTER the dictionary check above, deliberately.** That check reads the UNFILTERED
|
|
208
|
+
* list, because a model whose only properties are metadata is still a declared object rather than
|
|
209
|
+
* a dictionary, and filtering first would flip it to `Record<string, T>` on one side alone.
|
|
210
|
+
*/
|
|
199
211
|
const entries = declared
|
|
212
|
+
.filter((property) => isPayloadProperty(program, property))
|
|
200
213
|
.filter((property) => !isNeverType(property.type))
|
|
201
214
|
.map((property) => `\t${withDeclarationSite(property, () => propertyToTs(program, property))}`);
|
|
202
215
|
/**
|
|
@@ -226,8 +239,8 @@ function modelToTs(program, model) {
|
|
|
226
239
|
* of the failure - see `test/contractshape/`.
|
|
227
240
|
*
|
|
228
241
|
* What arrives is still described honestly, on the surface derived from the validator:
|
|
229
|
-
* `schemas.gen.ts` exports `
|
|
230
|
-
*
|
|
242
|
+
* `schemas.gen.ts` exports `z.infer<typeof x>`, which carries the index signature because a
|
|
243
|
+
* `.loose()` parse really does. Two surfaces, two directions, neither lying.
|
|
231
244
|
*/
|
|
232
245
|
return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
|
|
233
246
|
}
|
|
@@ -266,9 +279,9 @@ export function propertyToTs(program, property, wireName) {
|
|
|
266
279
|
* optionality per property fixes every depth at once, because there is no depth to reach.
|
|
267
280
|
*
|
|
268
281
|
* **The two directions of the emitted set differ here on purpose**, exactly as they do on
|
|
269
|
-
* openness: `schemas.gen.ts` exports `
|
|
270
|
-
*
|
|
271
|
-
*
|
|
282
|
+
* openness: `schemas.gen.ts` exports `z.infer<...>`, the narrow view of what ARRIVES, in which a
|
|
283
|
+
* default has fired and the property is present. `wire-contract.gen.ts` pairs this walk against
|
|
284
|
+
* `z.input` for the same reason.
|
|
272
285
|
*
|
|
273
286
|
* A defaulted property that reaches here is one the document publishes as optional, because
|
|
274
287
|
* `propertyToZod` reports `default-on-required-property` for the other spelling and emits no
|
|
@@ -278,7 +291,7 @@ export function propertyToTs(program, property, wireName) {
|
|
|
278
291
|
* **`?: T | undefined`, and dropping the `| undefined` was tried in `0.19.0` and reverted.**
|
|
279
292
|
*
|
|
280
293
|
* It looks wrong - JSON has no `undefined`, so a wire type saying `?: T` reads more honest, and
|
|
281
|
-
* it would make this walk agree with `
|
|
294
|
+
* it would make this walk agree with `z.infer<...>` on the schemas side. **Measured, it
|
|
282
295
|
* breaks the most ordinary handler there is.** `createWidget: (ctx, input) => ok(input)` - return
|
|
283
296
|
* what you were given - stopped compiling, because what a handler RECEIVES carries
|
|
284
297
|
* `?: T | undefined` and what it would then have to SUPPLY did not.
|
|
@@ -291,7 +304,15 @@ export function propertyToTs(program, property, wireName) {
|
|
|
291
304
|
* The two emitted surfaces differ here on purpose, exactly as they do on openness:
|
|
292
305
|
* `schemas.gen.ts` exports the narrow received view, this walk emits the permissive floor.
|
|
293
306
|
*/
|
|
294
|
-
|
|
307
|
+
/**
|
|
308
|
+
* **Optional AT THIS POSITION, not merely optional in the declaration.** A PATCH body's properties
|
|
309
|
+
* are optional whether or not the spec marked them so, which is `metadataInfo.isOptional` and is
|
|
310
|
+
* what `propertyToZod` has always consulted. This walk read `property.optional` alone, so an
|
|
311
|
+
* update contract demanded every field while the validator beside it accepted any subset.
|
|
312
|
+
*/
|
|
313
|
+
return property.optional || isOptionalAt(program, property)
|
|
314
|
+
? `${key}?: ${value} | undefined;`
|
|
315
|
+
: `${key}: ${value};`;
|
|
295
316
|
}
|
|
296
317
|
/** Installed by the registry so a named model is declared once and referenced everywhere else. */
|
|
297
318
|
let resolveRef = () => undefined;
|
package/dist/src/zod.d.ts
CHANGED
|
@@ -2,6 +2,10 @@ import { type Enum, type Model, type ModelProperty, type Program, type Type } fr
|
|
|
2
2
|
import { Visibility } from "@typespec/http";
|
|
3
3
|
export declare function withSealedObjects<T>(sealed: boolean, run: () => T): T;
|
|
4
4
|
export declare function withVisibility<T>(program: Program, next: Visibility, run: () => T): T;
|
|
5
|
+
/** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */
|
|
6
|
+
export declare function isOptionalAt(program: Program, property: ModelProperty): boolean;
|
|
7
|
+
/** Whether a property belongs in the BODY at the current position, rather than being metadata. */
|
|
8
|
+
export declare function isPayloadProperty(program: Program, property: ModelProperty): boolean;
|
|
5
9
|
/** Whether this model is shaped differently at `visibility` than at the canonical `Read`. */
|
|
6
10
|
export declare function isTransformedBy(program: Program, model: Model, at: Visibility): boolean;
|
|
7
11
|
/** The visibility the current walk is emitting for - the registry keys declarations on it. */
|
|
@@ -122,6 +126,29 @@ export declare function wireKindOf(program: Program, type: Type): "number" | "bo
|
|
|
122
126
|
* Exported so there is one answer to this question. There were two sites and only one of them knew.
|
|
123
127
|
*/
|
|
124
128
|
export declare function objectKey(name: string): string;
|
|
129
|
+
/**
|
|
130
|
+
* A doc string as a JSDoc comment, indented, or `""` when there is none.
|
|
131
|
+
*
|
|
132
|
+
* **Spec-authored text going into emitted source is the same question `objectKey` answers for a
|
|
133
|
+
* property name, and it had the same two-sites-one-answer shape.** A summary was interpolated raw
|
|
134
|
+
* into a `/** ... *\/` block by this estate's two server emitters. Measured: `@summary("a *\/ b")` is
|
|
135
|
+
* an ordinary string literal, so the text reaches here intact, closes the comment early and turns
|
|
136
|
+
* the remainder into code. `TS1131`, `TS1434`, `TS1161` - one operation costing the whole file,
|
|
137
|
+
* exactly like a model named `as`.
|
|
138
|
+
*
|
|
139
|
+
* **The bar is idiomatic output, not merely parseable output.** Two consequences:
|
|
140
|
+
*
|
|
141
|
+
* - A terminator is escaped as `*\/`, which is how TypeScript itself writes one inside a comment, so
|
|
142
|
+
* the sentence survives and reads correctly. Dropping or replacing the characters would lose an
|
|
143
|
+
* author's words to a mechanical problem they cannot see from their spec.
|
|
144
|
+
* - A multi-line description is rendered as a multi-line BLOCK. Interpolating the newlines into a
|
|
145
|
+
* one-line form produces a comment whose continuation lines carry no ` * `, which no formatter
|
|
146
|
+
* would emit and no reviewer would write.
|
|
147
|
+
*
|
|
148
|
+
* `indent` is passed rather than assumed because the two call sites sit at different depths, and a
|
|
149
|
+
* comment indented differently from the member it documents is the tell that a generator wrote it.
|
|
150
|
+
*/
|
|
151
|
+
export declare function jsDocComment(text: string | undefined, indent: string): string;
|
|
125
152
|
export declare function propertyToZod(program: Program, property: ModelProperty): string;
|
|
126
153
|
export declare function withRefResolver<T>(resolver: (type: Type) => string | undefined, run: () => T): T;
|
|
127
154
|
/** Called by the registry when it hands out the identifier of a declaration that is still open. */
|
package/dist/src/zod.js
CHANGED
|
@@ -139,12 +139,12 @@ export function withVisibility(program, next, run) {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
/** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */
|
|
142
|
-
function isOptionalAt(program, property) {
|
|
142
|
+
export function isOptionalAt(program, property) {
|
|
143
143
|
metadataInfo ??= createMetadataInfo(program);
|
|
144
144
|
return metadataInfo.isOptional(property, visibility);
|
|
145
145
|
}
|
|
146
146
|
/** Whether a property belongs in the BODY at the current position, rather than being metadata. */
|
|
147
|
-
function isPayloadProperty(program, property) {
|
|
147
|
+
export function isPayloadProperty(program, property) {
|
|
148
148
|
metadataInfo ??= createMetadataInfo(program);
|
|
149
149
|
return metadataInfo.isPayloadProperty(property, visibility);
|
|
150
150
|
}
|
|
@@ -874,6 +874,49 @@ export function wireKindOf(program, type) {
|
|
|
874
874
|
export function objectKey(name) {
|
|
875
875
|
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
|
|
876
876
|
}
|
|
877
|
+
/**
|
|
878
|
+
* A doc string as a JSDoc comment, indented, or `""` when there is none.
|
|
879
|
+
*
|
|
880
|
+
* **Spec-authored text going into emitted source is the same question `objectKey` answers for a
|
|
881
|
+
* property name, and it had the same two-sites-one-answer shape.** A summary was interpolated raw
|
|
882
|
+
* into a `/** ... *\/` block by this estate's two server emitters. Measured: `@summary("a *\/ b")` is
|
|
883
|
+
* an ordinary string literal, so the text reaches here intact, closes the comment early and turns
|
|
884
|
+
* the remainder into code. `TS1131`, `TS1434`, `TS1161` - one operation costing the whole file,
|
|
885
|
+
* exactly like a model named `as`.
|
|
886
|
+
*
|
|
887
|
+
* **The bar is idiomatic output, not merely parseable output.** Two consequences:
|
|
888
|
+
*
|
|
889
|
+
* - A terminator is escaped as `*\/`, which is how TypeScript itself writes one inside a comment, so
|
|
890
|
+
* the sentence survives and reads correctly. Dropping or replacing the characters would lose an
|
|
891
|
+
* author's words to a mechanical problem they cannot see from their spec.
|
|
892
|
+
* - A multi-line description is rendered as a multi-line BLOCK. Interpolating the newlines into a
|
|
893
|
+
* one-line form produces a comment whose continuation lines carry no ` * `, which no formatter
|
|
894
|
+
* would emit and no reviewer would write.
|
|
895
|
+
*
|
|
896
|
+
* `indent` is passed rather than assumed because the two call sites sit at different depths, and a
|
|
897
|
+
* comment indented differently from the member it documents is the tell that a generator wrote it.
|
|
898
|
+
*/
|
|
899
|
+
export function jsDocComment(text, indent) {
|
|
900
|
+
if (text === undefined)
|
|
901
|
+
return "";
|
|
902
|
+
const lines = text
|
|
903
|
+
.replaceAll("*/", "*\\/")
|
|
904
|
+
.split("\n")
|
|
905
|
+
.map((line) => line.trimEnd());
|
|
906
|
+
while (lines.length > 0 && lines[lines.length - 1] === "")
|
|
907
|
+
lines.pop();
|
|
908
|
+
while (lines.length > 0 && lines[0] === "")
|
|
909
|
+
lines.shift();
|
|
910
|
+
if (lines.length === 0)
|
|
911
|
+
return "";
|
|
912
|
+
const [only] = lines;
|
|
913
|
+
if (lines.length === 1 && only !== undefined)
|
|
914
|
+
return `${indent}/** ${only} */\n`;
|
|
915
|
+
const body = lines
|
|
916
|
+
.map((line) => (line === "" ? `${indent} *` : `${indent} * ${line}`))
|
|
917
|
+
.join("\n");
|
|
918
|
+
return `${indent}/**\n${body}\n${indent} */\n`;
|
|
919
|
+
}
|
|
877
920
|
/**
|
|
878
921
|
* The name a property carries **on the wire**, which is not always the name the spec gave it.
|
|
879
922
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-http-zod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.1",
|
|
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",
|
|
@@ -43,33 +43,33 @@
|
|
|
43
43
|
"provenance": true
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@types/node": "^26.
|
|
47
|
-
"@typespec/compiler": "1.
|
|
48
|
-
"@typespec/events": "0.
|
|
49
|
-
"@typespec/http": "1.
|
|
46
|
+
"@types/node": "^26.5.1",
|
|
47
|
+
"@typespec/compiler": "1.16.0",
|
|
48
|
+
"@typespec/events": "0.86.0",
|
|
49
|
+
"@typespec/http": "1.16.0",
|
|
50
50
|
"@typespec/http-specs": "0.1.0-alpha.41",
|
|
51
|
-
"@typespec/openapi": "1.
|
|
52
|
-
"@typespec/openapi3": "1.
|
|
53
|
-
"@typespec/rest": "0.
|
|
54
|
-
"@typespec/sse": "0.
|
|
55
|
-
"@typespec/streams": "0.
|
|
56
|
-
"@typespec/versioning": "0.
|
|
57
|
-
"@typespec/xml": "0.
|
|
51
|
+
"@typespec/openapi": "1.16.0",
|
|
52
|
+
"@typespec/openapi3": "1.16.0",
|
|
53
|
+
"@typespec/rest": "0.86.0",
|
|
54
|
+
"@typespec/sse": "0.86.0",
|
|
55
|
+
"@typespec/streams": "0.86.0",
|
|
56
|
+
"@typespec/versioning": "0.86.0",
|
|
57
|
+
"@typespec/xml": "0.86.0",
|
|
58
58
|
"ajv": "^8.20.0",
|
|
59
|
-
"oxfmt": "^0.
|
|
60
|
-
"oxlint": "^1.
|
|
59
|
+
"oxfmt": "^0.67.0",
|
|
60
|
+
"oxlint": "^1.82.0",
|
|
61
61
|
"typescript": "~7.0.2",
|
|
62
62
|
"typespec-http-zod": "link:.",
|
|
63
|
-
"vitest": "^
|
|
64
|
-
"yaml": "^2.9.
|
|
63
|
+
"vitest": "^5.0.0",
|
|
64
|
+
"yaml": "^2.9.1",
|
|
65
65
|
"zod": "^4.5.2"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"@typespec/compiler": "^1.15.0",
|
|
69
69
|
"@typespec/http": "^1.15.0",
|
|
70
70
|
"@typespec/openapi": "^1.15.0",
|
|
71
|
-
"@typespec/streams": "^0.85.0",
|
|
72
|
-
"@typespec/versioning": "^0.85.0",
|
|
71
|
+
"@typespec/streams": "^0.85.0 || ^0.86.0",
|
|
72
|
+
"@typespec/versioning": "^0.85.0 || ^0.86.0",
|
|
73
73
|
"zod": "^4.5.0"
|
|
74
74
|
},
|
|
75
75
|
"peerDependenciesMeta": {
|