typespec-hono 0.21.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.
@@ -103,3 +103,110 @@ export function selectContentType(accept, offered) {
103
103
  * type. Measured: `hc<typeof app>` resolved a wrapped route's body to `unknown`.
104
104
  */
105
105
  export const headOnly = async (c, next) => c.req.method === "HEAD" ? next() : c.notFound();
106
+ /**
107
+ * **A route whose template writes a query string, `/items?fixed=true{&param}`, is only that route when
108
+ * the request carries it.** A router matches paths, so the pairs are checked here, and a request
109
+ * without them gets the 404 any unrouted request gets, through whatever `app.notFound()` the
110
+ * application has set. In the runtime for the same reason as {@link headOnly}.
111
+ */
112
+ export const literalQuery = (pairs) => async (c, next) => pairs.every(([name, value]) => c.req.query(name) === value) ? next() : c.notFound();
113
+ /**
114
+ * A response body that does not match the schema the document publishes for its status.
115
+ *
116
+ * **Thrown, so an application decides what a contract failure answers with in `app.onError`**,
117
+ * which is where Hono puts that decision. Before this existed every consumer made the same check in
118
+ * its own `respond` and answered differently - a 500, a 502, a 502 with the issues in the body, a 502
119
+ * with them redacted - and only one of them checked failure bodies at all.
120
+ *
121
+ * `issues` are Zod's, so they carry paths and codes. They also carry the offending VALUES; an
122
+ * application that logs them should decide whether its responses may contain anything it would not
123
+ * log.
124
+ */
125
+ export class ResponseContractError extends Error {
126
+ operationId;
127
+ status;
128
+ issues;
129
+ constructor(operationId, status, issues) {
130
+ super(`${operationId} answered ${status} with a body its document does not permit`);
131
+ this.operationId = operationId;
132
+ this.status = status;
133
+ this.issues = issues;
134
+ this.name = "ResponseContractError";
135
+ }
136
+ }
137
+ /**
138
+ * A handler answered with a status its operation does not declare.
139
+ *
140
+ * **Unreachable from a typed handler.** The generated `Operations` interface types every result as
141
+ * the union of the declared statuses, so a literal outside it does not compile. This is what a CAST
142
+ * produces, or a result that crossed a boundary the type system cannot see into, such as untyped
143
+ * data from a service binding. Thrown rather than served, because serving it would publish a status
144
+ * the contract does not state.
145
+ */
146
+ export class UndeclaredStatusError extends Error {
147
+ operationId;
148
+ result;
149
+ constructor(operationId, result) {
150
+ const status = typeof result === "object" && result !== null && "status" in result
151
+ ? String(result.status)
152
+ : "no status";
153
+ super(`${operationId} answered ${status}, which its document does not declare`);
154
+ this.operationId = operationId;
155
+ this.result = result;
156
+ this.name = "UndeclaredStatusError";
157
+ }
158
+ }
159
+ /**
160
+ * The body a response SERVES: what the handler returned, parsed against the schema the document
161
+ * publishes for that status.
162
+ *
163
+ * **What is served is the PARSED value, not the one the handler returned.** A schema that strips
164
+ * undeclared keys therefore strips them from the wire too, which is how an internal field such as a
165
+ * tenant id is kept out of a response the document does not publish it in. Seven of the nine
166
+ * consumer surfaces surveyed relied on exactly that, each in its own hand-written `respond`.
167
+ *
168
+ * Synchronous, because nothing this emitter writes is asynchronous - `test/sync.test.ts` asserts it
169
+ * over the whole corpus - and the asynchronous path costs 2.6x per parse.
170
+ */
171
+ export function servedBody(schema, value, operationId, status) {
172
+ const parsed = schema.safeParse(value);
173
+ if (!parsed.success)
174
+ throw new ResponseContractError(operationId, status, parsed.error.issues);
175
+ return parsed.data;
176
+ }
177
+ /**
178
+ * Declared response headers, as the strings a response carries.
179
+ *
180
+ * An optional header the handler did not supply is omitted rather than sent as `"undefined"`, and a
181
+ * typed value - a `retry-after` declared `int32` - is written as its text. Hono's `HeaderRecord`
182
+ * refuses `undefined`, so dropping it here is also what keeps the generated call sites free of a
183
+ * conditional per header.
184
+ */
185
+ export function headersOf(declared) {
186
+ const headers = {};
187
+ for (const [name, value] of Object.entries(declared)) {
188
+ if (value !== undefined)
189
+ headers[name] = String(value);
190
+ }
191
+ return headers;
192
+ }
193
+ /** RFC 9110 `token`, less `*`, which names a range rather than a type. */
194
+ const MEDIA_TOKEN = "[!#$%&'+.^_`|~0-9A-Za-z-]+";
195
+ const SERVED_MEDIA_TYPE = new RegExp(`^(${MEDIA_TOKEN})/${MEDIA_TOKEN}\\s*(;.*)?$`);
196
+ /**
197
+ * Whether a media type a handler answers with lies inside a range the document offers.
198
+ *
199
+ * **A range is not a type a response can be sent as**, so where a status offers `image/*` the handler
200
+ * names the concrete type, and its result type already refuses one outside the range. This is what a
201
+ * CAST reaches, or data from a service binding: `text/html` under `image/*`, or `image/*` itself,
202
+ * would otherwise be served with a `Content-Type` the document does not permit. Type names compare
203
+ * case-insensitively (RFC 9110 section 8.3.1), and parameters such as `charset` are allowed.
204
+ */
205
+ export function mediaTypeWithin(served, range) {
206
+ const type = SERVED_MEDIA_TYPE.exec(served)?.[1];
207
+ if (type === undefined)
208
+ return false;
209
+ if (range === "*/*")
210
+ return true;
211
+ return range.endsWith("/*") && type.toLowerCase() === range.slice(0, -2).toLowerCase();
212
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-hono",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "TypeSpec emitter: generate a Hono server, and the Zod validators it enforces, from an HTTP service definition, agreeing with the OpenAPI document @typespec/openapi3 publishes from the same source.",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -44,23 +44,23 @@
44
44
  "provenance": true
45
45
  },
46
46
  "dependencies": {
47
- "typespec-http-zod": "^0.24.0"
47
+ "typespec-http-zod": "^0.26.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@hono/zod-openapi": "^1.4.0",
51
51
  "@hono/zod-validator": "^0.9.0",
52
52
  "@types/node": "^26.0.0",
53
- "@typespec/compiler": "1.15.0",
54
- "@typespec/events": "0.85.0",
55
- "@typespec/http": "1.15.0",
53
+ "@typespec/compiler": "1.16.0",
54
+ "@typespec/events": "0.86.0",
55
+ "@typespec/http": "1.16.0",
56
56
  "@typespec/http-specs": "0.1.0-alpha.41",
57
- "@typespec/openapi": "1.15.0",
58
- "@typespec/openapi3": "1.15.0",
59
- "@typespec/rest": "0.85.0",
60
- "@typespec/sse": "0.85.0",
61
- "@typespec/streams": "0.85.0",
62
- "@typespec/versioning": "0.85.0",
63
- "@typespec/xml": "0.85.0",
57
+ "@typespec/openapi": "1.16.0",
58
+ "@typespec/openapi3": "1.16.0",
59
+ "@typespec/rest": "0.86.0",
60
+ "@typespec/sse": "0.86.0",
61
+ "@typespec/streams": "0.86.0",
62
+ "@typespec/versioning": "0.86.0",
63
+ "@typespec/xml": "0.86.0",
64
64
  "hono": "^4.12.26",
65
65
  "oxfmt": "^0.63.0",
66
66
  "oxlint": "^1.78.0",
package/src/runtime.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Context, Env, Input, MiddlewareHandler } from "hono";
2
- import type { ZodType } from "zod";
2
+ import type { output, ZodError, ZodType } from "zod";
3
3
 
4
4
  /**
5
5
  * One arm of an operation's declared response set, as the document publishes it.
@@ -14,36 +14,14 @@ export interface ResponseArm {
14
14
  readonly status: number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
15
15
  readonly schema: ZodType | undefined;
16
16
  /**
17
- * The media types this response offers, where the document names MORE than one.
18
- *
19
- * Absent where it names one, which is what an application already assumes, so "one type" and
20
- * "not carried" are the same state rather than two to tell apart. Present, it is the set to
21
- * negotiate against: `selectContentType` takes the caller's `Accept` and these.
17
+ * Every media type this response offers, including a single one. Absent where there is no body.
22
18
  */
23
19
  readonly contentTypes?: readonly string[];
24
20
  /**
25
- * The headers this response declares, as the document publishes them.
26
- *
27
- * **Two names, because two different things need them.** `name` is the WIRE name, which is what
28
- * the response sets; `property` is the name on the value the handler returned, which is where the
29
- * value is read from. `@header("x-correlation-id") correlationId: string` is `x-correlation-id`
30
- * on the wire and `correlationId` in the result, and they differ for any header with a hyphen.
31
- *
32
- * Absent where the response declares none.
21
+ * The headers this response declares, by the WIRE name the response sets. `optional` is the
22
+ * document's `required: false`. Absent where the response declares none.
33
23
  */
34
- readonly headers?: readonly { readonly name: string; readonly property: string }[];
35
- readonly when?: {
36
- readonly property: string;
37
- /**
38
- * **A number too, because a `@statusCode` union selects by the status itself.**
39
- *
40
- * `model Created { @statusCode statusCode: 200 | 201 }` names the property that chooses, and
41
- * its values are the statuses. The discriminator case carries a boolean or string literal off
42
- * the body instead; both are the same question - which arm did the handler mean - so both use
43
- * this one field.
44
- */
45
- readonly value: boolean | number | string;
46
- };
24
+ readonly headers?: readonly { readonly name: string; readonly optional: boolean }[];
47
25
  }
48
26
 
49
27
  /**
@@ -85,32 +63,39 @@ export type SecurityRequirement = Readonly<Record<string, readonly string[]>>;
85
63
  * assertion invented to put the guarantee back.
86
64
  *
87
65
  * What is left for the app to supply is genuinely app-specific: how a request becomes a caller's
88
- * context, and how a result becomes a response. Everything else, routing, validation, which
89
- * validator applies to which target, what status each arm answers, is generated.
66
+ * context, and what a refusal looks like. Everything else, routing, validation, which validator
67
+ * applies to which target, which statuses an operation may answer with and how each one is served,
68
+ * is generated.
90
69
  */
91
70
 
92
71
  /**
93
- * How an operation's return value is wrapped.
72
+ * The Hono environment the generated server mounts on.
94
73
  *
95
- * Identity by default, so an operation may simply return its value. An app with a result envelope
96
- * points `runtime-module` at its own module and re-declares this as, say, `ServiceResult<T>`, which
97
- * is what keeps the generated `Operations` interface concretely typed end to end instead of falling
98
- * back to `unknown` and reintroducing the cast this whole change exists to delete.
99
- */
100
- export type Result<T> = T;
101
-
102
- /**
103
- * The Hono environment the generated server mounts on, and the caller context its operations take.
74
+ * **An interface, so an application AUGMENTS it rather than replacing this module.**
75
+ *
76
+ * ```ts
77
+ * declare module "./generated/runtime.gen.js" {
78
+ * interface AppEnv {
79
+ * Bindings: { BACKEND: Service<Backend> };
80
+ * Variables: { principal: Principal };
81
+ * }
82
+ * }
83
+ * ```
104
84
  *
105
- * **Concrete on purpose.** Making `registerRoutes` generic over the environment does not work:
106
- * Hono narrows `Context` per route and its conditional types cannot reduce
107
- * `IfAnyThenEmptyObject<E extends Env ? ...>` while `E` is an unbound parameter, so nothing the app
108
- * supplies is ever assignable and every call site needs a cast. Naming the types here instead, an
109
- * app points `runtime-module` at its own module and re-declares them, keeps every generated call
110
- * site concrete and cast-free. Identity defaults, so an app with neither can ignore both.
85
+ * That is the idiom Hono itself uses for `ContextVariableMap`, and it is what lets this module be
86
+ * emitted beside the generated code on every compile instead of being copied into an application and
87
+ * aged there. A copy was the only other way to name an environment: a gateway ran a runtime from
88
+ * `0.10.1` while the emitter reached `0.21.0`, carrying a content-negotiation defect fixed eleven
89
+ * releases earlier.
90
+ *
91
+ * **Concrete rather than a type parameter of `registerRoutes`, and that was measured twice.** Hono
92
+ * narrows `Context` per route, and its conditional types cannot reduce
93
+ * `IfAnyThenEmptyObject<E extends Env ? ...>` while `E` is an unbound parameter, so nothing an
94
+ * application supplies is ever assignable and every call site needs a cast. Confirmed again on hono
95
+ * 4.13.1 with TypeScript 7.0.2: three `TS2345`s on a three-route probe.
111
96
  */
112
- export type AppEnv = Env;
113
- export type Ctx = unknown;
97
+ // oxlint-disable-next-line typescript/no-empty-interface -- augmented by the application.
98
+ export interface AppEnv extends Env {}
114
99
 
115
100
  /** Anything an operation may hand back: the value, or a promise of it. */
116
101
  export type Awaitable<T> = T | Promise<T>;
@@ -214,20 +199,120 @@ export const headOnly: MiddlewareHandler = async (c, next) =>
214
199
  c.req.method === "HEAD" ? next() : c.notFound();
215
200
 
216
201
  /**
217
- * **The request-body middleware used to live here, and it moved into `app.gen.ts`.**
202
+ * **A route whose template writes a query string, `/items?fixed=true{&param}`, is only that route when
203
+ * the request carries it.** A router matches paths, so the pairs are checked here, and a request
204
+ * without them gets the 404 any unrouted request gets, through whatever `app.notFound()` the
205
+ * application has set. In the runtime for the same reason as {@link headOnly}.
206
+ */
207
+ export const literalQuery =
208
+ (pairs: readonly (readonly [string, string])[]): MiddlewareHandler =>
209
+ async (c, next) =>
210
+ pairs.every(([name, value]) => c.req.query(name) === value) ? next() : c.notFound();
211
+
212
+ /**
213
+ * A response body that does not match the schema the document publishes for its status.
214
+ *
215
+ * **Thrown, so an application decides what a contract failure answers with in `app.onError`**,
216
+ * which is where Hono puts that decision. Before this existed every consumer made the same check in
217
+ * its own `respond` and answered differently - a 500, a 502, a 502 with the issues in the body, a 502
218
+ * with them redacted - and only one of them checked failure bodies at all.
219
+ *
220
+ * `issues` are Zod's, so they carry paths and codes. They also carry the offending VALUES; an
221
+ * application that logs them should decide whether its responses may contain anything it would not
222
+ * log.
223
+ */
224
+ export class ResponseContractError extends Error {
225
+ constructor(
226
+ readonly operationId: string,
227
+ readonly status: number,
228
+ readonly issues: ZodError["issues"],
229
+ ) {
230
+ super(`${operationId} answered ${status} with a body its document does not permit`);
231
+ this.name = "ResponseContractError";
232
+ }
233
+ }
234
+
235
+ /**
236
+ * A handler answered with a status its operation does not declare.
237
+ *
238
+ * **Unreachable from a typed handler.** The generated `Operations` interface types every result as
239
+ * the union of the declared statuses, so a literal outside it does not compile. This is what a CAST
240
+ * produces, or a result that crossed a boundary the type system cannot see into, such as untyped
241
+ * data from a service binding. Thrown rather than served, because serving it would publish a status
242
+ * the contract does not state.
243
+ */
244
+ export class UndeclaredStatusError extends Error {
245
+ constructor(
246
+ readonly operationId: string,
247
+ readonly result: unknown,
248
+ ) {
249
+ const status =
250
+ typeof result === "object" && result !== null && "status" in result
251
+ ? String(result.status)
252
+ : "no status";
253
+ super(`${operationId} answered ${status}, which its document does not declare`);
254
+ this.name = "UndeclaredStatusError";
255
+ }
256
+ }
257
+
258
+ /**
259
+ * The body a response SERVES: what the handler returned, parsed against the schema the document
260
+ * publishes for that status.
261
+ *
262
+ * **What is served is the PARSED value, not the one the handler returned.** A schema that strips
263
+ * undeclared keys therefore strips them from the wire too, which is how an internal field such as a
264
+ * tenant id is kept out of a response the document does not publish it in. Seven of the nine
265
+ * consumer surfaces surveyed relied on exactly that, each in its own hand-written `respond`.
218
266
  *
219
- * `byContentType` and `optionalBody` were exported from this module and imported by the generated
220
- * server, which made them part of a SECOND contract this package has: what an application that
221
- * points `runtime-module` at a module of its own must export. That contract is easy to break without
222
- * noticing, and it was the reason a required single-media-type body kept `zValidator` - whose
223
- * `HTTPException` on an unreadable body is a `text/plain` 400 raised before `deps.invalid`, escaping
224
- * the app's error envelope. Routing those through `byContentType` closed the gap in one line and
225
- * made that export mandatory for every substituting app: measured, 15 arms red.
267
+ * Synchronous, because nothing this emitter writes is asynchronous - `test/sync.test.ts` asserts it
268
+ * over the whole corpus - and the asynchronous path costs 2.6x per parse.
269
+ */
270
+ export function servedBody<S extends ZodType>(
271
+ schema: S,
272
+ value: unknown,
273
+ operationId: string,
274
+ status: number,
275
+ ): output<S> {
276
+ const parsed = schema.safeParse(value);
277
+ if (!parsed.success) throw new ResponseContractError(operationId, status, parsed.error.issues);
278
+ return parsed.data;
279
+ }
280
+
281
+ /**
282
+ * Declared response headers, as the strings a response carries.
283
+ *
284
+ * An optional header the handler did not supply is omitted rather than sent as `"undefined"`, and a
285
+ * typed value - a `retry-after` declared `int32` - is written as its text. Hono's `HeaderRecord`
286
+ * refuses `undefined`, so dropping it here is also what keeps the generated call sites free of a
287
+ * conditional per header.
288
+ */
289
+ export function headersOf(declared: Readonly<Record<string, unknown>>): Record<string, string> {
290
+ const headers: Record<string, string> = {};
291
+ for (const [name, value] of Object.entries(declared)) {
292
+ if (value !== undefined) headers[name] = String(value);
293
+ }
294
+ return headers;
295
+ }
296
+
297
+ /** RFC 9110 `token`, less `*`, which names a range rather than a type. */
298
+ const MEDIA_TOKEN = "[!#$%&'+.^_`|~0-9A-Za-z-]+";
299
+ const SERVED_MEDIA_TYPE = new RegExp(`^(${MEDIA_TOKEN})/${MEDIA_TOKEN}\\s*(;.*)?$`);
300
+
301
+ /**
302
+ * Whether a media type a handler answers with lies inside a range the document offers.
226
303
  *
227
- * Emitting the middleware instead closes the gap and SHRINKS the runtime contract by these two
228
- * names. Nothing generated imports them, so keeping them here would leave a second implementation of
229
- * body reading that nothing exercises - which is how two copies of one rule drift apart.
304
+ * **A range is not a type a response can be sent as**, so where a status offers `image/*` the handler
305
+ * names the concrete type, and its result type already refuses one outside the range. This is what a
306
+ * CAST reaches, or data from a service binding: `text/html` under `image/*`, or `image/*` itself,
307
+ * would otherwise be served with a `Content-Type` the document does not permit. Type names compare
308
+ * case-insensitively (RFC 9110 section 8.3.1), and parameters such as `charset` are allowed.
230
309
  */
310
+ export function mediaTypeWithin(served: string, range: string): boolean {
311
+ const type = SERVED_MEDIA_TYPE.exec(served)?.[1];
312
+ if (type === undefined) return false;
313
+ if (range === "*/*") return true;
314
+ return range.endsWith("/*") && type.toLowerCase() === range.slice(0, -2).toLowerCase();
315
+ }
231
316
 
232
317
  /**
233
318
  * What the app provides. One object, passed once, rather than a module the generated file imports by
@@ -239,26 +324,25 @@ export const headOnly: MiddlewareHandler = async (c, next) =>
239
324
  * so a hook typed against a single `Context<E>` is not assignable at any real call site. Making the
240
325
  * hooks generic lets the app write functions that ignore both, without a cast anywhere.
241
326
  *
242
- * **`E` and `C` are PARAMETERS, and they have to be.** The defaults keep the bare `RouteDeps` the
243
- * generated server writes working for an app that substitutes nothing. An app that substitutes
244
- * anything binds them once, `export type RouteDeps = BaseRouteDeps<AppEnv, Ctx>` in the module it
245
- * points `runtime-module` at, and every hook is then typed against its own environment and its own
246
- * caller context.
327
+ * **`C` is the caller context, and `registerRoutes` INFERS it from `context`.** An application that
328
+ * returns a `Caller` from `context` has handlers typed `(ctx: Caller, input)`, with nothing to
329
+ * declare. It used to be a type an application re-declared in a substituted copy of this module.
247
330
  *
248
- * Re-exporting this interface unparameterised instead does not work, and the reason is not obvious:
249
- * **Hono's `Context` is INVARIANT in its environment**, because `Context.set` takes `E` as an
250
- * argument. So `Context<AppEnv, ...>` is not assignable to `Context<Env, ...>` however plain the
251
- * substituted environment is, and every generated `deps.*` call site fails. Separately, `context`
252
- * would keep returning the identity `Ctx` (`unknown`) which the app's own handlers then reject.
253
- * Measured before this was parameterised: **19 errors on a four-operation service.**
331
+ * **Nothing here renders a successful response, and that is the point.** Which statuses an operation
332
+ * may answer with, which body each one carries and how it is serialised are all things the document
333
+ * states, so the generated route does them. A `respond` hook used to be handed every arm and the
334
+ * handler's result, and every application re-implemented the choice by hand: five status-mapping
335
+ * tables across the consumers surveyed, none consulting the arms, one sending a declared 404 as 400.
336
+ * What remains are the refusals that happen before a handler runs, whose envelope only the
337
+ * application knows.
254
338
  */
255
- export interface RouteDeps<E extends Env = AppEnv, C = Ctx> {
339
+ export interface RouteDeps<E extends Env = AppEnv, C = unknown> {
256
340
  /**
257
341
  * The gate the DOCUMENT publishes, as middleware.
258
342
  *
259
343
  * **Which scopes an operation demands is a contract fact; how a token is verified is not.**
260
344
  * `@useAuth(OAuth2Auth<...>)` reaches OpenAPI as `security` per operation, so the requirement is
261
- * generated and this implements the check. The same split as `context` and `respond`. Emitted
345
+ * generated and this implements the check. The same split as `context`. Emitted
262
346
  * only where the operation declares scopes, which is why an internal surface with none is
263
347
  * unaffected.
264
348
  *
@@ -280,9 +364,17 @@ export interface RouteDeps<E extends Env = AppEnv, C = Ctx> {
280
364
  /**
281
365
  * The caller's context, or `null` when there is none to establish.
282
366
  *
283
- * `authentication` is what the DOCUMENT says, and only that: `"none"` where the operation
284
- * declares `@useAuth(NoAuth)` (`security: []` in OpenAPI) and `"required"` otherwise. Deciding
285
- * it at generation time is the point: the gate the document publishes is the gate that runs.
367
+ * `authentication` is what the DOCUMENT says, and only that:
368
+ *
369
+ * - `"none"`: no requirement asks for anything (`@useAuth(NoAuth)`, or no authentication at all);
370
+ * - `"optional"`: an anonymous alternative sits beside a real one (`NoAuth | BearerAuth`), so
371
+ * `authorize` has admitted this caller either way and a presented credential should still be
372
+ * read;
373
+ * - `"required"`: every alternative asks for something.
374
+ *
375
+ * **`"optional"` is new, and without it the middle case was reported as `"none"`**, so a caller
376
+ * with a valid token on an optional route was never established as a caller. Deciding it at
377
+ * generation time is the point: the gate the document publishes is the gate that runs.
286
378
  *
287
379
  * **It used to be `"none" | "account" | "resource"`, and the last two were an invention.** They
288
380
  * were chosen by whether the path had parameters, which no OpenAPI keyword expresses and which
@@ -292,7 +384,7 @@ export interface RouteDeps<E extends Env = AppEnv, C = Ctx> {
292
384
  */
293
385
  readonly context: <P extends string, I extends Input>(
294
386
  c: Context<E, P, I>,
295
- authentication: "none" | "required",
387
+ authentication: "none" | "optional" | "required",
296
388
  ) => C | null;
297
389
  /** The response when `context` returns `null`. */
298
390
  readonly noContext: <P extends string, I extends Input>(c: Context<E, P, I>) => Response;
@@ -318,13 +410,4 @@ export interface RouteDeps<E extends Env = AppEnv, C = Ctx> {
318
410
  result: { readonly success: boolean },
319
411
  c: Context<E, P, I>,
320
412
  ) => Response | undefined;
321
- /**
322
- * Turn an operation's result into a response, checked against the schema the document publishes
323
- * for the arm that applies. A bodyless success is an arm whose `schema` is `undefined`.
324
- */
325
- readonly respond: <P extends string, I extends Input>(
326
- c: Context<E, P, I>,
327
- arms: readonly ResponseArm[],
328
- result: unknown,
329
- ) => Awaitable<Response>;
330
413
  }
@@ -1,29 +0,0 @@
1
- import { type HttpOperation } from "@typespec/http";
2
- import type { Program } from "@typespec/compiler";
3
- import type { SecurityRequirement } from "./runtime.js";
4
- /**
5
- * What the DOCUMENT says a caller must satisfy, in the shape the document says it.
6
- *
7
- * **The scheme was being thrown away, and only "is a caller needed" survived.** `@useAuth(BearerAuth)`
8
- * reaches OpenAPI as `security: [{ "BearerAuth": [] }]`, and this emitter reduced that to
9
- * `deps.context(c, "required")`. A gate was emitted ONLY when the scheme carried scopes, so for
10
- * bearer, api-key and basic, which is the common case, nothing carried which scheme at all. An
11
- * application whose `context` read a cookie would happily serve a route the document says needs a
12
- * bearer token, and nothing anywhere would notice.
13
- *
14
- * **Passed through, never enforced here.** Which credentials satisfy a scheme is the application's
15
- * business and could not be anything else; which schemes an operation ACCEPTS is a contract fact and
16
- * is now generated. That is the same split as `context` and `respond`, applied to the half that was
17
- * missing.
18
- */
19
- export type { SecurityRequirement } from "./runtime.js";
20
- /**
21
- * The requirements an operation declares. Satisfying **any one** of them authorises the caller,
22
- * which is what an array of `security` objects means in OpenAPI, and why this is a list of lists
23
- * rather than a flat set of scopes.
24
- *
25
- * Empty when the operation declares `@useAuth(NoAuth)` or no authentication at all.
26
- */
27
- export declare function securityFor(program: Program, operation: HttpOperation): SecurityRequirement[];
28
- /** The requirements as a TypeScript literal, for the generated call site. */
29
- export declare function renderSecurity(requirements: readonly SecurityRequirement[]): string;
@@ -1,48 +0,0 @@
1
- import { getAuthenticationForOperation } from "@typespec/http";
2
- /**
3
- * The requirements an operation declares. Satisfying **any one** of them authorises the caller,
4
- * which is what an array of `security` objects means in OpenAPI, and why this is a list of lists
5
- * rather than a flat set of scopes.
6
- *
7
- * Empty when the operation declares `@useAuth(NoAuth)` or no authentication at all.
8
- */
9
- export function securityFor(program, operation) {
10
- const authentication = getAuthenticationForOperation(program, operation.operation);
11
- const requirements = [];
12
- for (const option of authentication?.options ?? []) {
13
- const requirement = {};
14
- let anonymous = false;
15
- for (const scheme of option.schemes) {
16
- /**
17
- * **`NoAuth` inside an option means that option needs nothing**, which is how a spec says
18
- * "authentication is optional here". It is not a scheme to demand, and emitting it as one
19
- * would refuse every anonymous caller the document permits.
20
- */
21
- if (scheme.type === "noAuth") {
22
- anonymous = true;
23
- continue;
24
- }
25
- /**
26
- * Scopes belong to the flows of an OAuth2 scheme; every other kind has none. Read from the
27
- * scheme rather than assumed, and de-duplicated because two flows may name the same scope.
28
- */
29
- const scopes = scheme.type === "oauth2"
30
- ? [...new Set(scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value)))]
31
- : [];
32
- requirement[scheme.id] = scopes;
33
- }
34
- if (anonymous && Object.keys(requirement).length === 0)
35
- continue;
36
- if (Object.keys(requirement).length > 0)
37
- requirements.push(requirement);
38
- }
39
- return requirements;
40
- }
41
- /** The requirements as a TypeScript literal, for the generated call site. */
42
- export function renderSecurity(requirements) {
43
- return `[${requirements
44
- .map((requirement) => `{ ${Object.entries(requirement)
45
- .map(([scheme, scopes]) => `${JSON.stringify(scheme)}: [${scopes.map((s) => JSON.stringify(s)).join(", ")}]`)
46
- .join(", ")} }`)
47
- .join(", ")}]`;
48
- }