typespec-hono 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,10 +58,16 @@ model Widget {
58
58
  name: string;
59
59
  }
60
60
 
61
+ @error
62
+ model NotFound {
63
+ @statusCode statusCode: 404;
64
+ message: string;
65
+ }
66
+
61
67
  @route("/widgets")
62
68
  interface WidgetRoutes {
63
69
  @get list(@query limit?: int32): Widget[];
64
- @get read(@path id: string): Widget;
70
+ @get read(@path id: string): Widget | NotFound;
65
71
  }
66
72
  ```
67
73
 
@@ -71,79 +77,108 @@ pnpm exec tsp compile .
71
77
 
72
78
  ### `src/index.ts`
73
79
 
74
- `input` and the return type are both known from the spec, so a handler that does not match the
75
- contract does not compile:
80
+ A handler returns `{ status, body }` for any response its operation declares, the failures included.
81
+ `input` and every declared response are known from the spec, so a handler returning a status the spec
82
+ does not declare, or the wrong body for a status, does not compile:
76
83
 
77
84
  ```ts
78
85
  import { Hono } from "hono";
79
- import { registerRoutes } from "./generated/app.gen.js";
80
- import { deps } from "./deps.js";
81
-
82
- const handlersFor = () => ({
83
- WidgetRoutes_list: (ctx, input) => widgets.slice(0, input.limit ?? 20),
84
- WidgetRoutes_read: (ctx, input) => widgets.find((w) => w.id === input.id),
85
- });
86
+ import { registerRoutes, type Operations } from "./generated/app.gen.js";
87
+ import { deps, type Caller } from "./deps.js";
88
+
89
+ const handlers = {
90
+ WidgetRoutes_list: (ctx, input) => ({ status: 200, body: widgets.slice(0, input.limit ?? 20) }),
91
+ WidgetRoutes_read: (ctx, input) => {
92
+ const widget = widgets.find((w) => w.id === input.id);
93
+ return widget === undefined
94
+ ? { status: 404, body: { message: `no widget ${input.id}` } }
95
+ : { status: 200, body: widget };
96
+ },
97
+ } satisfies Operations<Caller>;
86
98
 
87
- export default registerRoutes(new Hono(), handlersFor, deps);
99
+ export default registerRoutes(new Hono(), () => handlers, deps);
88
100
  ```
89
101
 
90
- Leave `handlersFor` unannotated: annotating it widens the value and disables the check that catches a
91
- handler for an operation the spec no longer declares. It is a factory rather than an object because a
92
- Workers service binding lives on `c.env` and exists only for the duration of a request.
102
+ `satisfies` keeps each `status` a literal, which is what selects the response it belongs to. Without
103
+ it, `status: 404` widens to `number`, which no declared response admits.
104
+
105
+ The generated route serves each response with the Hono call for it - `c.json(body, 404)` for the
106
+ failure above - after checking the body against the schema the document publishes for that status. So
107
+ Hono's RPC client narrows a response body by its status, and a body the document does not permit
108
+ reaches `app.onError` as a `ResponseContractError` rather than a caller.
109
+
110
+ Leave the factory passed to `registerRoutes` unannotated: annotating it widens the value and disables
111
+ the check that catches a handler for an operation the spec no longer declares. It is a factory rather
112
+ than an object because a Workers service binding lives on `c.env` and exists only for the duration of
113
+ a request.
93
114
 
94
115
  ### `src/deps.ts`
95
116
 
96
- Six hooks, each answering something the spec does not contain:
117
+ Five hooks, each answering something the spec does not contain:
97
118
 
98
- | hook | the spec says | you say |
99
- | --------------- | ------------------------------------------- | ------------------------------------ |
100
- | `authorize` | which schemes and scopes an operation needs | whether this caller satisfies them |
101
- | `context` | whether a caller is required | who the caller is |
102
- | `noContext` | | what to answer when there is not one |
103
- | `notAcceptable` | which media types are offered | what to answer when none match |
104
- | `invalid` | the schema | what a validation failure looks like |
105
- | `respond` | every status arm and its schema | which arm this result is |
119
+ | hook | the spec says | you say |
120
+ | --------------- | ---------------------------------------------- | ------------------------------------ |
121
+ | `authorize` | which schemes and scopes an operation needs | whether this caller satisfies them |
122
+ | `context` | whether a caller is none, optional or required | who the caller is |
123
+ | `noContext` | | what to answer when there is not one |
124
+ | `notAcceptable` | which media types are offered | what to answer when none match |
125
+ | `invalid` | the schema | what a validation failure looks like |
106
126
 
107
127
  ```ts
108
- import type { RouteDeps } from "./generated/runtime.gen.js";
128
+ import type { AppEnv, RouteDeps } from "./generated/runtime.gen.js";
109
129
 
110
- export const deps: RouteDeps = {
130
+ export interface Caller {
131
+ readonly userId: string;
132
+ }
133
+
134
+ export const deps: RouteDeps<AppEnv, Caller> = {
111
135
  authorize: (requirements) => async (c, next) => {
112
136
  await next();
113
137
  },
114
- context: (c) => ({ userId: c.req.header("x-user") }),
138
+ context: (c) => {
139
+ const userId = c.req.header("x-user");
140
+ return userId === undefined ? null : { userId };
141
+ },
115
142
  noContext: (c) => c.json({ error: "unauthorized" }, 401),
116
143
  notAcceptable: (c, offered) => c.json({ error: "not_acceptable", offered }, 406),
117
144
  invalid: (result, c) => (result.success ? undefined : c.json({ error: "invalid" }, 400)),
118
- respond: (c, arms, result) => c.json(result as never, 200),
119
145
  };
120
146
  ```
121
147
 
122
- Routing, request validation and the handler types come from the spec. What is left is the four files
123
- above.
148
+ `context` is told `"none"` (the operation needs nobody), `"optional"` (anonymous access is one
149
+ alternative, so read a credential if one was presented) or `"required"`. Whatever it returns is what
150
+ every handler receives as `ctx`. Your Hono environment - bindings
151
+ and variables - is an augmentation of the emitted `AppEnv`:
152
+
153
+ ```ts
154
+ declare module "./generated/runtime.gen.js" {
155
+ interface AppEnv {
156
+ Bindings: { DB: D1Database };
157
+ Variables: { requestId: string };
158
+ }
159
+ }
160
+ ```
161
+
162
+ Routing, request validation, the handler types and how each declared response is served come from the
163
+ spec. What is left is the four files above.
124
164
 
125
165
  ## What it emits
126
166
 
127
167
  Into your output directory:
128
168
 
129
- | file | what it is |
130
- | ---------------------- | ------------------------------------------------------------------------------ |
131
- | `app.gen.ts` | the server: routes, validators, and the handler interface you implement |
132
- | `runtime.gen.ts` | the types your `deps` implements against, and the helpers the server calls |
133
- | `schemas.gen.ts` | a Zod schema for every request and response, and the status arms each declares |
134
- | `vocabularies.gen.ts` | shared enums, where the spec declares them |
135
- | `requests.gen.ts` | request types, when `contracts-output-dir` is set |
136
- | `wire-contract.gen.ts` | assertions that the schemas and the types agree, with the same option |
169
+ | file | what it is |
170
+ | ---------------------- | -------------------------------------------------------------------------------- |
171
+ | `app.gen.ts` | the server: routes, validators, what each operation may answer, and the handlers |
172
+ | `runtime.gen.ts` | the types your `deps` implements against, and the helpers the server calls |
173
+ | `schemas.gen.ts` | a Zod schema for every request and response, and the status arms each declares |
174
+ | `vocabularies.gen.ts` | shared enums, where the spec declares them |
175
+ | `requests.gen.ts` | request types, when `contracts-output-dir` is set |
176
+ | `wire-contract.gen.ts` | assertions that the schemas and the types agree, with the same option |
137
177
 
138
178
  The last four are `typespec-http-zod`'s; see its README for what they contain.
139
179
 
140
- Your own code imports from `runtime.gen.ts`:
141
-
142
- ```ts
143
- import type { Ctx, RouteDeps } from "./generated/runtime.gen.js";
144
- ```
145
-
146
- Setting `runtime-module` replaces it with a module of your own, and it is then not written.
180
+ `runtime.gen.ts` is written on every compile. Your own code imports from it and augments `AppEnv` in
181
+ it, and nothing replaces it.
147
182
 
148
183
  Routes are grouped into a sub-app per resource and mounted with `app.route()`, following
149
184
  [Hono's best-practices guide](https://hono.dev/docs/guides/best-practices). Handlers are written
@@ -152,27 +187,15 @@ handler in another file cannot infer its path parameters. A resource with a sing
152
187
  sub-app.
153
188
 
154
189
  The output is plain `Hono` and `@hono/zod-validator`, not `@hono/zod-openapi`. That package generates
155
- a document from the code, which would compete with the one openapi3 publishes from the spec.
156
-
157
- ```ts
158
- import { Hono } from "hono";
159
- import { registerRoutes } from "./generated/app.gen.js";
160
-
161
- // Leave handlersFor unannotated. Annotating it widens the value and disables the
162
- // exhaustiveness check that catches a handler for an operation the spec no longer declares.
163
- const handlersFor = (c) => backendFor(c.env);
164
- const routes = registerRoutes(new Hono<AppEnv>(), handlersFor, deps);
165
-
166
- export default routes;
167
- ```
168
-
169
- `handlersFor` is a factory rather than an object because a Workers service binding lives on `c.env`
170
- and exists only for the duration of a request.
190
+ a document from the code, which would compete with the one openapi3 publishes from the spec. The
191
+ responses a route serves are the ones `@hono/zod-openapi` would require of it: across the conformance
192
+ corpus, what Hono's RPC client infers for 619 routes is compared with what `RouteConfigToTypedResponse`
193
+ derives from the published document.
171
194
 
172
195
  ## Docs
173
196
 
174
- - [Guides](docs/guides.md): middleware, the RPC client, authentication, base paths, HEAD operations,
175
- request bodies, streaming, observability
197
+ - [Guides](docs/guides.md): declared failures, middleware, the RPC client, authentication, base
198
+ paths, HEAD operations, request bodies, streaming, observability, upgrading
176
199
  - [Cloudflare Workers](docs/cloudflare-workers.md): which router to pick, and what the bundle costs
177
200
  - [Reference](docs/reference.md): every option, every diagnostic, and the known limits
178
201
  - [Releasing](docs/releasing.md): rehearsing a two-package release against a local registry,
package/dist/src/app.d.ts CHANGED
@@ -1,5 +1,45 @@
1
- import { type SecurityRequirement } from "./security.js";
2
- import { type EmittedRoute, type EmittedService } from "typespec-http-zod";
1
+ import { type EmittedPathSegment, type EmittedRoute, type EmittedService, type StatusKey } from "typespec-http-zod";
2
+ /**
3
+ * Hono's status codes, group by group, exactly as `hono/utils/http-status` declares them.
4
+ *
5
+ * **The generated route needs the LITERALS, not only the type.** A range arm is served from a
6
+ * `switch` on the result's status, and TypeScript narrows the result to one member only through a
7
+ * `case` label per literal: a type guard narrows the status and leaves the result a union, and
8
+ * merging the range into `default` pairs every body with every status. Both measured on hono 4.13.1.
9
+ *
10
+ * `test/status-codes.test.ts` holds these equal to Hono's own unions, so a status Hono adds fails a
11
+ * test rather than silently falling out of a range.
12
+ */
13
+ export declare const STATUS_GROUPS: {
14
+ readonly 1: {
15
+ readonly type: "InfoStatusCode";
16
+ readonly codes: readonly [100, 101, 102, 103];
17
+ };
18
+ readonly 2: {
19
+ readonly type: "SuccessStatusCode";
20
+ readonly codes: readonly [200, 201, 202, 203, 204, 205, 206, 207, 208, 226];
21
+ };
22
+ readonly 3: {
23
+ readonly type: "RedirectStatusCode";
24
+ readonly codes: readonly [300, 301, 302, 303, 304, 305, 306, 307, 308];
25
+ };
26
+ readonly 4: {
27
+ readonly type: "ClientErrorStatusCode";
28
+ readonly codes: readonly [400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 429, 431, 451];
29
+ };
30
+ readonly 5: {
31
+ readonly type: "ServerErrorStatusCode";
32
+ readonly codes: readonly [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511];
33
+ };
34
+ };
35
+ /**
36
+ * Where every generated file imports its runtime from: the copy this package writes beside them.
37
+ *
38
+ * Declared here, where the import is rendered, and re-exported by the emitter that writes the file.
39
+ */
40
+ export declare const DEFAULT_RUNTIME_MODULE = "./runtime.gen.js";
41
+ /** Hono's `ContentlessStatusCode`: a status that cannot carry a body, so `c.json` refuses it. */
42
+ export declare const CONTENTLESS_STATUS_CODES: readonly [101, 204, 205, 304];
3
43
  /**
4
44
  * The header every emitted file carries.
5
45
  *
@@ -10,46 +50,35 @@ import { type EmittedRoute, type EmittedService } from "typespec-http-zod";
10
50
  */
11
51
  export declare function generatedBanner(hint: string | undefined): string;
12
52
  /**
13
- * TypeSpec publishes `/widgets/{widget-id}`; Hono routes on `/widgets/:widget-id`.
53
+ * The Hono route for a route template, from the segments `typespec-http-zod` reads out of the
54
+ * operation's RFC 6570 `uriTemplate`.
14
55
  *
15
- * **This used to match `\w+`, so any parameter carrying a hyphen was left ALONE.**
16
- * `@path("thing-id")` produced the literal route `/things/{thing-id}`, mounted, counted by every arm
17
- * that counts routes, and reachable by nobody. It answered 404 to the only requests it was for. Hono
18
- * handles `:thing-id` and `:x.y` perfectly well; the narrow character class was ours.
56
+ * **This used to convert `route.path`, which has every operator stripped**, so `array{.param*}`,
57
+ * `array{;param}` and `optional{/name}` were mounted as `array:param` and `optional:name`, which Hono
58
+ * does not match at all. Measured by request: 31 of the URIs `@typespec/http-specs` `routes` and
59
+ * `parameters/path` declare answered 404 from a server generated from them.
19
60
  *
20
- * A name that is not plain is REFUSED rather than approximated, and the route stays at the literal
21
- * template so it matches nothing rather than matching the wrong thing.
61
+ * - A whole-segment expression is `:name`; a reserved or exploding `/` one crosses `/`, so it is
62
+ * `:name{.+}` (Hono's spelling of greedy); an optional last segment is `:name?`.
63
+ * - An expression written beside literal text, or with a label or matrix operator, is a pattern
64
+ * parameter matching the whole segment: `:param{array\.[^\x2F]*}`. The captured text is the
65
+ * segment, and the path validator undoes the expansion. `\x2F` rather than `/`, so the mounted
66
+ * path still splits into its segments on `/` for the ordering and sub-app rules below.
22
67
  *
23
- * **This is about the NAME, not about RFC 6570 operators.** An earlier version of this comment
24
- * claimed `{+path}` or `{tag*}` would survive into the name and that `*` would become Hono's
25
- * wildcard. Measured, that is false: `@typespec/http` resolves the operator before this emitter sees
26
- * the path, and `@typespec/openapi3` strips it from the published document too, so both artefacts say
27
- * `/files{path}` and agree. What actually reaches here is a wire name from `@path("...")`, and the
28
- * forms that fail are a space, `+` and `!`. `*` is rejected by `@typespec/http` before it arrives.
68
+ * **A name Hono cannot carry is REFUSED rather than approximated**, and so is a segment holding two
69
+ * expressions, which no segment router can split. The segment stays literal, so it matches nothing
70
+ * rather than the wrong thing. A space, `+` and `!` are the characters that fail; a hyphen, a dot and
71
+ * a tilde do not.
29
72
  *
30
- * **This runs at RENDER time, not during collection.** It used to run inside `collectRoutes`, which
31
- * put one framework's spelling into the shared intermediate representation and refused the whole
32
- * operation (validators included) over a template no router could mount. What a request body must
33
- * look like does not depend on that.
73
+ * **This runs at RENDER time, not during collection**, so what a request body must look like never
74
+ * depends on whether one framework's router can express the path.
34
75
  */
35
- export declare function toHonoPath(template: string, refuse: (template: string, name: string) => void,
36
- /**
37
- * Wire names the document says carry RFC 6570 reserved expansion, so their value may contain `/`.
38
- *
39
- * **A hierarchical identifier is ONE value, not several segments.** An Obsidian note is
40
- * `areas/health.md`; an S3 key and a GitHub file path are the same shape. A router that stops at
41
- * the first `/` binds `areas` and 404s the rest. Hono spells the greedy form `:name{.+}`.
42
- *
43
- * Read from `EmittedRoute.reservedPathParameters`, which the library resolves from `allowReserved`
44
- * on the parameter. **Never from the template**: the operator does not survive to `route.path`,
45
- * `@typespec/http` strips it, and it can also be set with no operator in the template at all, so
46
- * the template is a derived artefact rather than the source of truth.
47
- */
48
- reserved?: ReadonlySet<string>): string;
76
+ export declare function toHonoPath(segments: readonly EmittedPathSegment[], refuse: (template: string, name: string) => void): string;
49
77
  /** The one thing a Hono server cannot express, handed back rather than thrown. */
50
78
  export interface RenderRefusals {
51
79
  readonly unsupportedPathTemplate: (route: EmittedRoute, template: string, name: string) => void;
52
80
  readonly unvalidatableMediaType: (route: EmittedRoute, types: readonly string[]) => void;
81
+ readonly unvalidatedResponseMediaType: (route: EmittedRoute, status: StatusKey, types: readonly string[]) => void;
53
82
  }
54
83
  /**
55
84
  * The generated Hono server.
@@ -77,13 +106,4 @@ export declare function renderApp(emitted: EmittedService, refuse: RenderRefusal
77
106
  * the document publishes `/api/v1/accounts`. Mounting at the root made every client generated from
78
107
  * the document, and every "try it" in a rendered document, 404.
79
108
  */
80
- basePaths?: readonly string[],
81
- /**
82
- * What the DOCUMENT says a caller must satisfy, per operation id.
83
- *
84
- * **Resolved by the caller rather than read off `EmittedRoute`**, because which schemes an
85
- * operation accepts is a fact about the HTTP program and not part of the validator IR the library
86
- * publishes. Keeping it out of that IR is what stops a Hono concern leaking into a package whose
87
- * audience is wider.
88
- */
89
- securityFor?: (verb: string, path: string) => readonly SecurityRequirement[]): string;
109
+ basePaths?: readonly string[]): string;