typespec-http-zod 0.25.0 → 0.26.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/dist/src/api.d.ts +150 -116
- package/dist/src/api.js +353 -332
- package/dist/src/lib.d.ts +0 -19
- package/dist/src/lib.js +0 -2
- package/dist/src/registry.js +26 -3
- package/dist/src/runtime.d.ts +20 -34
- package/dist/src/types.d.ts +7 -0
- package/dist/src/types.js +13 -4
- package/dist/src/uri-template.d.ts +106 -0
- package/dist/src/uri-template.js +254 -0
- package/dist/src/zod.d.ts +1 -0
- package/dist/src/zod.js +37 -1
- package/package.json +18 -18
package/dist/src/api.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type EmitContext, type Program, type Type } from "@typespec/compiler";
|
|
2
2
|
import { type HttpOperation, type HttpService } from "@typespec/http";
|
|
3
3
|
import { SchemaRegistry } from "./registry.js";
|
|
4
|
+
import { type EmittedPathSegment } from "./uri-template.js";
|
|
5
|
+
export type { EmittedPathSegment } from "./uri-template.js";
|
|
4
6
|
/**
|
|
5
7
|
* `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
|
|
6
8
|
* service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
|
|
@@ -69,6 +71,70 @@ export declare function successStatusesOf(operation: HttpOperation): number[];
|
|
|
69
71
|
* listed above", and inventing one would be answering a question the contract left open.
|
|
70
72
|
*/
|
|
71
73
|
export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
|
|
74
|
+
/**
|
|
75
|
+
* A header one response declares, as a handler has to supply it.
|
|
76
|
+
*
|
|
77
|
+
* **The WIRE name only.** A result a handler returns is keyed by what the response sets, which is
|
|
78
|
+
* what the document publishes under `responses.<status>.headers`. The TypeSpec property behind it
|
|
79
|
+
* used to be carried beside the name so a runtime could read the value off a flattened result; that
|
|
80
|
+
* made the handler contract depend on a property name the document never states.
|
|
81
|
+
*/
|
|
82
|
+
export interface EmittedResponseHeader {
|
|
83
|
+
readonly name: string;
|
|
84
|
+
/** The TypeScript type of the value, so a server emitter can put it in a signature. */
|
|
85
|
+
readonly type: string;
|
|
86
|
+
/** The document's `required: false`. */
|
|
87
|
+
readonly optional: boolean;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* One response the document declares for an operation, keyed the way the document keys it.
|
|
91
|
+
*
|
|
92
|
+
* **Complete on its own, because a server emitter types a handler's return one member per arm.**
|
|
93
|
+
* The route used to carry these facts in five separate projections - a primary status and body, a
|
|
94
|
+
* list of failure arms, headers for numeric statuses only, media types for numeric statuses only,
|
|
95
|
+
* and a selector for choosing between successes - and each projection had a hole the others did not:
|
|
96
|
+
*
|
|
97
|
+
* - a range or `default` arm never carried its headers or media types;
|
|
98
|
+
* - a second success status carried the FIRST status's body, because the selector assumed one model;
|
|
99
|
+
* - a failure's inline body was an expression nothing outside the arm list could name;
|
|
100
|
+
* - an operation whose only success was a `2XX` range was dropped without a diagnostic.
|
|
101
|
+
*
|
|
102
|
+
* One record per status, built in one pass over `@typespec/http`'s own resolution, has no such gaps
|
|
103
|
+
* to keep in step.
|
|
104
|
+
*/
|
|
105
|
+
export interface EmittedResponse {
|
|
106
|
+
readonly status: StatusKey;
|
|
107
|
+
/** This status's body, as an emitted Zod expression, or `undefined` where it declares none. */
|
|
108
|
+
readonly schema: string | undefined;
|
|
109
|
+
/**
|
|
110
|
+
* Every media type the document names for this status, in declaration order.
|
|
111
|
+
*
|
|
112
|
+
* Empty exactly when the response carries no body. **A single type is still carried**, because
|
|
113
|
+
* assuming the one type is JSON is how a `text/plain` arm became indistinguishable from a JSON one.
|
|
114
|
+
*/
|
|
115
|
+
readonly contentTypes: readonly string[];
|
|
116
|
+
readonly headers: readonly EmittedResponseHeader[];
|
|
117
|
+
/**
|
|
118
|
+
* The body is raw binary, so `schema` is `z.unknown()` - see {@link isRawBinaryMediaType}. A server
|
|
119
|
+
* serves such a body as bytes or a stream rather than serialising it.
|
|
120
|
+
*/
|
|
121
|
+
readonly binary: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* The body is a stream of events or lines (`@typespec/streams`). The schema describes the stream as
|
|
124
|
+
* the document does, a string, which is not something a server validates a stream against.
|
|
125
|
+
*/
|
|
126
|
+
readonly streamed: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* The body's type is a string, so under a media type that is not JSON it IS the text served.
|
|
129
|
+
*
|
|
130
|
+
* **The fact a server needs to serve a non-JSON body at all.** A `string` declared as `text/plain`
|
|
131
|
+
* is validated and written as is; a MODEL declared as `application/xml` has no serialisation an
|
|
132
|
+
* emitter can derive from the document, so the text has to come from the application. Forty arms in
|
|
133
|
+
* the conformance corpus are the second kind and three are the first, and a server that could not
|
|
134
|
+
* tell them apart would either refuse the text or validate XML against a JSON schema.
|
|
135
|
+
*/
|
|
136
|
+
readonly textual: boolean;
|
|
137
|
+
}
|
|
72
138
|
/**
|
|
73
139
|
* Whether the operation declares `@useAuth(NoAuth)` - no caller is established for it.
|
|
74
140
|
*
|
|
@@ -153,9 +219,36 @@ export interface EmittedRoute {
|
|
|
153
219
|
* carries one of each.
|
|
154
220
|
*/
|
|
155
221
|
readonly reservedPathParameters: readonly string[];
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
222
|
+
/**
|
|
223
|
+
* **The route as a server has to mount it: one entry per `/` segment, read from the operation's
|
|
224
|
+
* RFC 6570 `uriTemplate`.** `path` cannot say this, because `@typespec/http` strips every operator
|
|
225
|
+
* from it: `array{.param*}` (`array.a.b`), `array{;param}` (`array;param=a,b`) and
|
|
226
|
+
* `array{/param}` (`array/a,b`) all reach `path` as `array{param}`. A router mounted from `path`
|
|
227
|
+
* answered 404 to 31 of the requests `@typespec/http-specs` declares conformant.
|
|
228
|
+
*
|
|
229
|
+
* An expression segment names its parameter's wire name, the literal text around it in the same
|
|
230
|
+
* segment, its operator and explode modifier, and whether it is optional or reserved. A server
|
|
231
|
+
* hands `pathSchema` the TEXT of that segment (for an exploding `/` expression, of every segment
|
|
232
|
+
* after it), and `pathSchema` undoes the expansion. A segment holding more than one expression is
|
|
233
|
+
* `unsupported`: no segment router can split it.
|
|
234
|
+
*/
|
|
235
|
+
readonly pathSegments: readonly EmittedPathSegment[];
|
|
236
|
+
/**
|
|
237
|
+
* A query string written into the route itself, as the pairs it spells:
|
|
238
|
+
* `/items?fixed=true{¶m}` carries `[["fixed", "true"]]`. It identifies the route as much as
|
|
239
|
+
* the path does, and it is never part of `pathSegments`. Empty when there is none.
|
|
240
|
+
*/
|
|
241
|
+
readonly literalQuery: readonly (readonly [string, string])[];
|
|
242
|
+
/**
|
|
243
|
+
* Every response the document declares, one per status key, in OpenAPI's precedence order. See
|
|
244
|
+
* {@link EmittedResponse}.
|
|
245
|
+
*/
|
|
246
|
+
readonly responses: readonly EmittedResponse[];
|
|
247
|
+
/**
|
|
248
|
+
* Media types the lowest exact success status can be served as - the basis for `Accept`
|
|
249
|
+
* negotiation between several operations sharing one route. Empty where that status carries no
|
|
250
|
+
* body, or where the operation declares no exact success status at all.
|
|
251
|
+
*/
|
|
159
252
|
readonly responseContentTypes: readonly string[];
|
|
160
253
|
/**
|
|
161
254
|
* Media types the request body may be SENT as.
|
|
@@ -177,6 +270,20 @@ export interface EmittedRoute {
|
|
|
177
270
|
* writes the server, exactly as `responseContentTypes` leaves negotiation to it.
|
|
178
271
|
*/
|
|
179
272
|
readonly requestContentTypes: readonly string[];
|
|
273
|
+
/**
|
|
274
|
+
* Whether the request body IS the text a caller sends, rather than a structure parsed out of it.
|
|
275
|
+
*
|
|
276
|
+
* **A server cannot answer this from the media type alone**, and `typespec-hono` had no way to ask:
|
|
277
|
+
* `@body body: string` under `text/plain` has no JSON, form or multipart reader, so the body was
|
|
278
|
+
* never read and the handler was handed nothing. The same media type carrying a MODEL has no
|
|
279
|
+
* serialisation this emitter can derive, and must still be refused - so the question is about the
|
|
280
|
+
* TYPE, which only this package resolves.
|
|
281
|
+
*
|
|
282
|
+
* True where the body's type is a string (a scalar based on `string`, a literal, a template, or a
|
|
283
|
+
* union of those - the same rule {@link EmittedResponse.textual} applies to a response) and the
|
|
284
|
+
* document declares no JSON media type for it.
|
|
285
|
+
*/
|
|
286
|
+
readonly requestTextual: boolean;
|
|
180
287
|
readonly summary: string | undefined;
|
|
181
288
|
readonly requestSchema: string | undefined;
|
|
182
289
|
/**
|
|
@@ -192,6 +299,13 @@ export interface EmittedRoute {
|
|
|
192
299
|
*/
|
|
193
300
|
readonly pathSchema: string | undefined;
|
|
194
301
|
readonly querySchema: string | undefined;
|
|
302
|
+
/**
|
|
303
|
+
* The query OBJECT itself, which differs from `querySchema` only where a form-exploded record or
|
|
304
|
+
* model is gathered into it: `querySchema` is then `z.preprocess(gather, <this>)`, the validator a
|
|
305
|
+
* request's raw query goes through, and this is the object with a `.shape` a consumer building
|
|
306
|
+
* arguments from already-structured JSON can spread. Equal to `querySchema` otherwise.
|
|
307
|
+
*/
|
|
308
|
+
readonly queryFieldsSchema: string | undefined;
|
|
195
309
|
readonly headerSchema: string | undefined;
|
|
196
310
|
/** The header schema minus `accept`, used when this route's operations negotiate. */
|
|
197
311
|
readonly negotiatedHeaderSchema: string | undefined;
|
|
@@ -200,21 +314,6 @@ export interface EmittedRoute {
|
|
|
200
314
|
readonly name: string;
|
|
201
315
|
readonly value: string;
|
|
202
316
|
} | undefined;
|
|
203
|
-
/**
|
|
204
|
-
* `undefined` when the operation's success response carries no body - a `204`, or any `@delete`
|
|
205
|
-
* returning `void`.
|
|
206
|
-
*
|
|
207
|
-
* **This used to be required, and requiring it silently deleted routes.** `collectRoutes`
|
|
208
|
-
* skipped any operation `successBodyOf` could not answer for, with no diagnostic: measured across
|
|
209
|
-
* `@typespec/http-specs`, the document declared 540 operations and this emitter mounted 256, with
|
|
210
|
-
* 282 of the 284 missing ones bodyless. `payload/multipart` produced `GENERATED_ROUTES = []`
|
|
211
|
-
* against seventeen declared operations and compiled clean. Even `parameters/path` lost both of
|
|
212
|
-
* its operations, because they answer `NoContentResponse`.
|
|
213
|
-
*
|
|
214
|
-
* A bodyless success is ordinary HTTP, not an edge case. The route is emitted; a server
|
|
215
|
-
* answers the declared status with no body.
|
|
216
|
-
*/
|
|
217
|
-
readonly responseSchema: string | undefined;
|
|
218
317
|
/**
|
|
219
318
|
* The input property that receives the UNPARSED body, when the operation declares one.
|
|
220
319
|
*
|
|
@@ -254,56 +353,19 @@ export interface EmittedRoute {
|
|
|
254
353
|
* permits, in a shape that contract forbids.
|
|
255
354
|
*/
|
|
256
355
|
readonly optionalBody: boolean;
|
|
257
|
-
/** Declared failures, each with the schema for its OWN body, in OpenAPI's precedence order. */
|
|
258
|
-
readonly errorArms: readonly {
|
|
259
|
-
readonly status: StatusKey;
|
|
260
|
-
readonly schema: string | undefined;
|
|
261
|
-
}[];
|
|
262
|
-
/**
|
|
263
|
-
* The headers each response status declares, as the document publishes them.
|
|
264
|
-
*
|
|
265
|
-
* **`deps.respond` could not set a header the contract promises.** A spec may declare `@header` on
|
|
266
|
-
* a response model - a `Location` on a 302, a `Link`, a correlation echo - and the document
|
|
267
|
-
* publishes it under `responses.<code>.headers`, but the arm carried a status and a body schema and
|
|
268
|
-
* nothing else. So the server could not answer faithfully, and a redirect could not be served at
|
|
269
|
-
* all. Reported as blocking by a consumer, and it is a fact the document already states.
|
|
270
|
-
*
|
|
271
|
-
* **Both names are carried, because two different things need them.** `name` is the WIRE name,
|
|
272
|
-
* which is what the response sets; `property` is the name on the returned value, which is where the
|
|
273
|
-
* value is read from. `@header("x-correlation-id") correlationId: string` is `x-correlation-id` on
|
|
274
|
-
* the wire and `correlationId` in the handler's result, and an emitter given only one of them would
|
|
275
|
-
* have to guess the other.
|
|
276
|
-
*/
|
|
277
|
-
readonly responseHeaders: readonly {
|
|
278
|
-
readonly status: StatusKey;
|
|
279
|
-
readonly headers: readonly {
|
|
280
|
-
readonly name: string;
|
|
281
|
-
readonly property: string;
|
|
282
|
-
/** The TypeScript type of the value, so a server emitter can put it in a signature. */
|
|
283
|
-
readonly type: string;
|
|
284
|
-
}[];
|
|
285
|
-
}[];
|
|
286
356
|
/**
|
|
287
|
-
*
|
|
357
|
+
* **Whether the operation needs a caller, in the three states its `security` can say.**
|
|
288
358
|
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* could not be served faithfully. Reported by two consumers independently.
|
|
294
|
-
*/
|
|
295
|
-
readonly responseMediaTypes: readonly {
|
|
296
|
-
readonly status: StatusKey;
|
|
297
|
-
readonly contentTypes: readonly string[];
|
|
298
|
-
}[];
|
|
299
|
-
/**
|
|
300
|
-
* No caller is established, so the operation receives no `ServiceContext`.
|
|
359
|
+
* - `none`: no requirement asks for anything (`@useAuth(NoAuth)`, or no authentication at all).
|
|
360
|
+
* - `optional`: an anonymous alternative sits beside a real one (`NoAuth | BearerAuth`). A caller
|
|
361
|
+
* who presents a credential is authenticated; one who presents none is still admitted.
|
|
362
|
+
* - `required`: every alternative asks for something.
|
|
301
363
|
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
364
|
+
* **This replaced `noAuth`, which could not tell the first two apart**, so a server passing it
|
|
365
|
+
* through treated `NoAuth | BearerAuth` as "no caller", and a caller with a valid token was never
|
|
366
|
+
* seen as one.
|
|
305
367
|
*/
|
|
306
|
-
readonly
|
|
368
|
+
readonly authentication: "none" | "optional" | "required";
|
|
307
369
|
/**
|
|
308
370
|
* The OAuth scopes the operation requires, from `@useAuth`.
|
|
309
371
|
*
|
|
@@ -317,7 +379,7 @@ export interface EmittedRoute {
|
|
|
317
379
|
/**
|
|
318
380
|
* What the DOCUMENT says a caller must satisfy, in the shape the document says it.
|
|
319
381
|
*
|
|
320
|
-
* **`scopes`
|
|
382
|
+
* **`scopes` is a lossy projection of this (and a boolean `noAuth` was another), and publishing only those was a defect
|
|
321
383
|
* both server emitters had to work around.** A flat scope union cannot say WHICH scheme demanded
|
|
322
384
|
* what, so `@useAuth(BearerAuth)` -- which reaches OpenAPI as `security: [{ "BearerAuth": [] }]`
|
|
323
385
|
* -- arrived here as an empty scope list indistinguishable from no authentication at all. An
|
|
@@ -325,30 +387,12 @@ export interface EmittedRoute {
|
|
|
325
387
|
* again and rebuild this itself, and two of them did.
|
|
326
388
|
*
|
|
327
389
|
* Satisfying **any one** requirement authorises; every scheme within a requirement must be
|
|
328
|
-
* satisfied together.
|
|
329
|
-
*
|
|
390
|
+
* satisfied together. An anonymous alternative is the empty requirement `{}`, exactly as the
|
|
391
|
+
* document publishes it: `NoAuth | BearerAuth` is `[{}, { "BearerAuth": [] }]` and
|
|
392
|
+
* `@useAuth(NoAuth)` is `[{}]`. Empty where the operation declares no authentication at all.
|
|
393
|
+
* Whether a caller is needed is {@link EmittedRoute.authentication}.
|
|
330
394
|
*/
|
|
331
395
|
readonly security: readonly SecurityRequirement[];
|
|
332
|
-
/** Which of two success statuses to answer, and the literal property that decides it. */
|
|
333
|
-
readonly statusBy: {
|
|
334
|
-
property: string;
|
|
335
|
-
value: boolean | string;
|
|
336
|
-
status: number;
|
|
337
|
-
} | undefined;
|
|
338
|
-
/**
|
|
339
|
-
* The property a handler sets to choose between several declared success statuses, and the
|
|
340
|
-
* statuses it may carry - read from a `@statusCode` typed as a union of literals.
|
|
341
|
-
*
|
|
342
|
-
* **Published because the handler has to be able to SAY it.** The property is HTTP metadata, so it
|
|
343
|
-
* is stripped from the body schema (correctly - it is not body), which means a server emitter has
|
|
344
|
-
* to add it back to the type a handler returns or the arms name something unsatisfiable.
|
|
345
|
-
*/
|
|
346
|
-
readonly statusSelector: {
|
|
347
|
-
property: string;
|
|
348
|
-
statuses: readonly number[];
|
|
349
|
-
} | undefined;
|
|
350
|
-
/** The schema for the SECOND success status, when the operation declares two distinct arms. */
|
|
351
|
-
readonly alternateResponseSchema: string | undefined;
|
|
352
396
|
}
|
|
353
397
|
export declare function collectRoutes(program: Program, registry: SchemaRegistry, service: HttpService): EmittedRoute[];
|
|
354
398
|
/** Whether emitted source references `name` as an identifier rather than as part of a longer one. */
|
|
@@ -365,10 +409,26 @@ export interface RouteSchemaNames {
|
|
|
365
409
|
readonly operationId: string;
|
|
366
410
|
readonly path: string | undefined;
|
|
367
411
|
readonly query: string | undefined;
|
|
412
|
+
/**
|
|
413
|
+
* The query object's identifier, with a `.shape`. Equal to `query` except where a form-exploded
|
|
414
|
+
* record or model is gathered, when `query` is `z.preprocess(gather, <queryFields>)` and has no
|
|
415
|
+
* `.shape`. A consumer spreading request fields reads this; a validator of a raw request reads `query`.
|
|
416
|
+
*/
|
|
417
|
+
readonly queryFields: string | undefined;
|
|
368
418
|
readonly header: string | undefined;
|
|
369
419
|
readonly body: string | undefined;
|
|
370
|
-
|
|
371
|
-
|
|
420
|
+
/**
|
|
421
|
+
* The identifier each response's body is declared under, parallel to `EmittedRoute.responses` and
|
|
422
|
+
* in the same order. `schema` is `undefined` exactly where that response carries no body.
|
|
423
|
+
*
|
|
424
|
+
* **An identifier for every arm, failures included.** A success body used to be named and a
|
|
425
|
+
* failure's inline body stayed an expression inside the arm list, so a server emitter could not
|
|
426
|
+
* write `z.infer<typeof ...>` for the one response it most needs a handler to be able to return.
|
|
427
|
+
*/
|
|
428
|
+
readonly arms: readonly {
|
|
429
|
+
readonly status: StatusKey;
|
|
430
|
+
readonly schema: string | undefined;
|
|
431
|
+
}[];
|
|
372
432
|
/**
|
|
373
433
|
* The `readonly ResponseArm[]` const - what the operation may answer with, and with which body.
|
|
374
434
|
*
|
|
@@ -407,33 +467,7 @@ export interface ResolvedServiceOptions {
|
|
|
407
467
|
/** Whether every emitted validator is wrapped in `z.compile()`. See the option's own docblock. */
|
|
408
468
|
readonly compileSchemas: boolean;
|
|
409
469
|
readonly keyVocabularies: readonly string[];
|
|
410
|
-
readonly runtimeModule: string;
|
|
411
470
|
/** The command that regenerates, for the banner. `undefined` keeps the generic line. */
|
|
412
471
|
readonly regenerateHint: string | undefined;
|
|
413
472
|
}
|
|
414
|
-
|
|
415
|
-
export declare const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
|
|
416
|
-
/**
|
|
417
|
-
* What a WRAPPING emitter may change about this one, without changing what a consumer may set.
|
|
418
|
-
*
|
|
419
|
-
* **`defaultRuntimeModule` is a default, not an override.** A consumer's `runtime-module` still
|
|
420
|
-
* wins; this only decides what `schemas.gen.ts` and any file a wrapper emits beside it import from
|
|
421
|
-
* when the consumer says nothing.
|
|
422
|
-
*
|
|
423
|
-
* **It exists because this package's own default is wrong for a wrapper, and shipped that way.**
|
|
424
|
-
* `typespec-hono` emits `import type { AppEnv, Awaitable, Ctx, Result, RouteDeps }` and
|
|
425
|
-
* `import { selectContentType }` from whatever this resolves to. None of those six is exported here -
|
|
426
|
-
* this module exports `ResponseArm` and `armFor`, and nothing else. Worse, `typespec-http-zod` is a
|
|
427
|
-
* TRANSITIVE dependency of a `typespec-hono` consumer, so under a strict `node_modules` the specifier
|
|
428
|
-
* does not resolve at all whatever it exports. Measured in a fresh project installed from tarballs:
|
|
429
|
-
* `tsp compile` succeeded with zero diagnostics and `tsc` then reported **two `TS2307`s**, one in each
|
|
430
|
-
* generated file.
|
|
431
|
-
*
|
|
432
|
-
* Nothing in either suite saw it, because every compile in both harnesses sets `runtime-module`
|
|
433
|
-
* explicitly. The default branch was ungraded across 240 tests.
|
|
434
|
-
*/
|
|
435
|
-
export interface EmitHttpZodOptions {
|
|
436
|
-
/** The specifier generated files import their runtime contract from when the consumer sets none. */
|
|
437
|
-
readonly defaultRuntimeModule?: string;
|
|
438
|
-
}
|
|
439
|
-
export declare function emitHttpZod(context: EmitContext, wrapper?: EmitHttpZodOptions): Promise<readonly EmittedService[]>;
|
|
473
|
+
export declare function emitHttpZod(context: EmitContext): Promise<readonly EmittedService[]>;
|