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.
- package/README.md +85 -62
- package/dist/src/app.d.ts +63 -43
- package/dist/src/app.js +643 -231
- package/dist/src/emitter.d.ts +3 -3
- package/dist/src/emitter.js +52 -45
- package/dist/src/lib.d.ts +13 -4
- package/dist/src/lib.js +41 -1
- package/dist/src/runtime.d.ts +123 -79
- package/dist/src/runtime.js +107 -0
- package/package.json +12 -12
- package/src/runtime.ts +167 -84
- package/dist/src/security.d.ts +0 -29
- package/dist/src/security.js +0 -48
package/dist/src/runtime.js
CHANGED
|
@@ -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{¶m}`, 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.
|
|
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.
|
|
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.
|
|
54
|
-
"@typespec/events": "0.
|
|
55
|
-
"@typespec/http": "1.
|
|
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.
|
|
58
|
-
"@typespec/openapi3": "1.
|
|
59
|
-
"@typespec/rest": "0.
|
|
60
|
-
"@typespec/sse": "0.
|
|
61
|
-
"@typespec/streams": "0.
|
|
62
|
-
"@typespec/versioning": "0.
|
|
63
|
-
"@typespec/xml": "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
|
-
*
|
|
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,
|
|
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
|
|
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
|
|
89
|
-
*
|
|
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
|
-
*
|
|
72
|
+
* The Hono environment the generated server mounts on.
|
|
94
73
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
*
|
|
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
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
-
|
|
113
|
-
export
|
|
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
|
-
* **
|
|
202
|
+
* **A route whose template writes a query string, `/items?fixed=true{¶m}`, 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
|
-
*
|
|
220
|
-
*
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
*
|
|
228
|
-
* names
|
|
229
|
-
*
|
|
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
|
-
* **`
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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 =
|
|
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
|
|
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:
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
}
|
package/dist/src/security.d.ts
DELETED
|
@@ -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;
|
package/dist/src/security.js
DELETED
|
@@ -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
|
-
}
|