typespec-http-zod 0.25.1 → 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 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
- readonly statusCode: number;
157
- readonly statusCodes: readonly number[];
158
- /** Media types the success body can be served as - the basis for `Accept` negotiation. */
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{&param}` 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
- * The media types each response status offers.
357
+ * **Whether the operation needs a caller, in the three states its `security` can say.**
288
358
  *
289
- * **`responseContentTypes` above carries this for the PRIMARY status only, and an arm carried
290
- * none at all.** An operation whose one status offers `application/json` and `text/event-stream`
291
- * emitted a single arm holding the JSON schema, and the alternative was dropped from the list
292
- * entirely, so `deps.respond` could not tell what it was answering with and a non-JSON operation
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
- * Whether a tenant exists follows from whether the request was authenticated - there is no user,
303
- * account or membership to build one from otherwise. Reading it here is what lets a server
304
- * derive an operation's call shape rather than a hand-written table restating it per row.
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 noAuth: boolean;
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` and `noAuth` are lossy projections of this, and publishing only those was a defect
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. Empty where the operation declares `@useAuth(NoAuth)` or nothing, which is
329
- * a fact rather than an absence.
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
- readonly response: string | undefined;
371
- readonly alternateResponse: string | undefined;
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
- /** What the generated files import their runtime contract from when nothing says otherwise. */
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[]>;