typespec-hono 0.22.0 → 0.24.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,125 @@ 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> = {
135
+ // The requirements are the document's, verbatim: `[{ BearerAuth: [] }]`. Satisfying any ONE of
136
+ // them authorises; every scheme within one must be satisfied together. Waving a scheme through
137
+ // because you do not check it is how a gated route ends up ungated.
111
138
  authorize: (requirements) => async (c, next) => {
139
+ const bearer = /^Bearer\s+(\S+)$/i.exec(c.req.header("authorization") ?? "")?.[1];
140
+ const satisfied = requirements.some((requirement) =>
141
+ Object.entries(requirement).every(
142
+ ([scheme, scopes]) =>
143
+ scheme === "BearerAuth" && bearer !== undefined && scopes.length === 0,
144
+ ),
145
+ );
146
+ if (!satisfied) return c.json({ error: "unauthorized" }, 401);
112
147
  await next();
148
+ return undefined;
149
+ },
150
+ context: (c) => {
151
+ const userId = c.req.header("x-user");
152
+ return userId === undefined ? null : { userId };
113
153
  },
114
- context: (c) => ({ userId: c.req.header("x-user") }),
115
154
  noContext: (c) => c.json({ error: "unauthorized" }, 401),
116
155
  notAcceptable: (c, offered) => c.json({ error: "not_acceptable", offered }, 406),
117
156
  invalid: (result, c) => (result.success ? undefined : c.json({ error: "invalid" }, 400)),
118
- respond: (c, arms, result) => c.json(result as never, 200),
119
157
  };
120
158
  ```
121
159
 
122
- Routing, request validation and the handler types come from the spec. What is left is the four files
123
- above.
160
+ **`authorize` is the gate the document publishes, and an empty one is an open door.** A requirement
161
+ naming a scheme you do not check must not be satisfied: `[{}]`, which `@useAuth(NoAuth | X)` publishes
162
+ for its anonymous alternative, is the only requirement every caller satisfies, and it is satisfied by
163
+ `Object.entries({}).every(...)` being vacuously true rather than by a special case.
164
+
165
+ `context` is told `"none"` (the operation needs nobody), `"optional"` (anonymous access is one
166
+ alternative, so read a credential if one was presented) or `"required"`. Whatever it returns is what
167
+ every handler receives as `ctx`. Your Hono environment - bindings
168
+ and variables - is an augmentation of the emitted `AppEnv`:
169
+
170
+ ```ts
171
+ declare module "./generated/runtime.gen.js" {
172
+ interface AppEnv {
173
+ Bindings: { DB: D1Database };
174
+ Variables: { requestId: string };
175
+ }
176
+ }
177
+ ```
178
+
179
+ Routing, request validation, the handler types and how each declared response is served come from the
180
+ spec. What is left is the four files above.
124
181
 
125
182
  ## What it emits
126
183
 
127
184
  Into your output directory:
128
185
 
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 |
186
+ | file | what it is |
187
+ | ---------------------- | -------------------------------------------------------------------------------- |
188
+ | `app.gen.ts` | the server: routes, validators, what each operation may answer, and the handlers |
189
+ | `runtime.gen.ts` | the types your `deps` implements against, and the helpers the server calls |
190
+ | `schemas.gen.ts` | a Zod schema for every request and response, and the status arms each declares |
191
+ | `vocabularies.gen.ts` | shared enums, where the spec declares them |
192
+ | `requests.gen.ts` | request types, when `contracts-output-dir` is set |
193
+ | `wire-contract.gen.ts` | assertions that the schemas and the types agree, with the same option |
137
194
 
138
195
  The last four are `typespec-http-zod`'s; see its README for what they contain.
139
196
 
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.
197
+ `runtime.gen.ts` is written on every compile. Your own code imports from it and augments `AppEnv` in
198
+ it, and nothing replaces it.
147
199
 
148
200
  Routes are grouped into a sub-app per resource and mounted with `app.route()`, following
149
201
  [Hono's best-practices guide](https://hono.dev/docs/guides/best-practices). Handlers are written
@@ -152,27 +204,15 @@ handler in another file cannot infer its path parameters. A resource with a sing
152
204
  sub-app.
153
205
 
154
206
  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.
207
+ a document from the code, which would compete with the one openapi3 publishes from the spec. The
208
+ responses a route serves are the ones `@hono/zod-openapi` would require of it: across the conformance
209
+ corpus, what Hono's RPC client infers for 619 routes is compared with what `RouteConfigToTypedResponse`
210
+ derives from the published document.
171
211
 
172
212
  ## Docs
173
213
 
174
- - [Guides](docs/guides.md): middleware, the RPC client, authentication, base paths, HEAD operations,
175
- request bodies, streaming, observability
214
+ - [Guides](docs/guides.md): declared failures, middleware, the RPC client, authentication, base
215
+ paths, HEAD operations, request bodies, streaming, observability, upgrading
176
216
  - [Cloudflare Workers](docs/cloudflare-workers.md): which router to pick, and what the bundle costs
177
217
  - [Reference](docs/reference.md): every option, every diagnostic, and the known limits
178
218
  - [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,67 @@ 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`.
14
- *
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.
19
- *
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.
22
- *
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.
29
- *
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.
53
+ * The Hono route for a route template, from the segments `typespec-http-zod` reads out of the
54
+ * operation's RFC 6570 `uriTemplate`.
55
+ *
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.
60
+ *
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.
67
+ *
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.
72
+ *
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.
75
+ */
76
+ export declare function toHonoPath(segments: readonly EmittedPathSegment[], refuse: (template: string, name: string) => void): string;
77
+ /**
78
+ * Whether Hono's `RegExpRouter` can compile this route set, and the route it names if not.
79
+ *
80
+ * **Hono's own router, imported statically.** `hono` is a required peer - `runtime.ts` imports it and
81
+ * the generated server cannot run without it - so there is nothing to degrade gracefully around, and
82
+ * a guarded import would claim a resilience this package does not need. Running the real router is
83
+ * what stops the check drifting from a rule Hono never documented.
84
+ *
85
+ * **`match` as well as `add`.** `RegExpRouter` builds its expressions lazily, so a conflict added
86
+ * through `add` alone can go unreported until the first request. One `match` forces the build.
34
87
  */
35
- export declare function toHonoPath(template: string, refuse: (template: string, name: string) => void,
88
+ export declare function regExpRouterRefusal(routes: readonly (readonly [string, string])[]): {
89
+ path: string;
90
+ reason: string;
91
+ } | undefined;
36
92
  /**
37
- * Wire names the document says carry RFC 6570 reserved expansion, so their value may contain `/`.
93
+ * **The `[method, path]` pairs a router is given for one service**, exactly as the emitted server
94
+ * registers them.
38
95
  *
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{.+}`.
96
+ * One definition, used by the `regexp-router-unsupported` linter rule, so the rule judges the route
97
+ * set the generated server really mounts. Built from the same helpers the render uses - the verb a
98
+ * route registers under, `app.on` falling back to `GET` as Hono itself does, and `toHonoPath` on the
99
+ * route's segments - and prefixed with each base path the service is served under.
42
100
  *
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.
101
+ * **The full path, never the one relative to a sub-app.** The relative form is what the emitted
102
+ * text says and not what any router is given. Judging it reports conflicts between routes that are
103
+ * not siblings at all: measured on five fixtures, two were called unmountable that mount perfectly.
47
104
  */
48
- reserved?: ReadonlySet<string>): string;
105
+ export declare function routerTableFor(routes: readonly {
106
+ readonly verb: string;
107
+ readonly pathSegments: readonly EmittedPathSegment[];
108
+ }[], basePaths: readonly string[]): (readonly [string, string])[];
49
109
  /** The one thing a Hono server cannot express, handed back rather than thrown. */
50
110
  export interface RenderRefusals {
51
111
  readonly unsupportedPathTemplate: (route: EmittedRoute, template: string, name: string) => void;
52
112
  readonly unvalidatableMediaType: (route: EmittedRoute, types: readonly string[]) => void;
113
+ readonly unvalidatedResponseMediaType: (route: EmittedRoute, status: StatusKey, types: readonly string[]) => void;
53
114
  }
54
115
  /**
55
116
  * The generated Hono server.
@@ -77,13 +138,4 @@ export declare function renderApp(emitted: EmittedService, refuse: RenderRefusal
77
138
  * the document publishes `/api/v1/accounts`. Mounting at the root made every client generated from
78
139
  * the document, and every "try it" in a rendered document, 404.
79
140
  */
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;
141
+ basePaths?: readonly string[]): string;