typespec-hono 0.1.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/LICENSE +21 -0
- package/README.md +333 -0
- package/dist/src/app.d.ts +61 -0
- package/dist/src/app.js +609 -0
- package/dist/src/base-path.d.ts +16 -0
- package/dist/src/base-path.js +47 -0
- package/dist/src/emitter.d.ts +24 -0
- package/dist/src/emitter.js +108 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +12 -0
- package/dist/src/lib.d.ts +71 -0
- package/dist/src/lib.js +123 -0
- package/dist/src/runtime.d.ts +174 -0
- package/dist/src/runtime.js +63 -0
- package/dist/src/security.d.ts +29 -0
- package/dist/src/security.js +48 -0
- package/dist/src/tsp-index.d.ts +9 -0
- package/dist/src/tsp-index.js +9 -0
- package/lib/main.tsp +3 -0
- package/package.json +90 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getServers } from "@typespec/http";
|
|
2
|
+
/**
|
|
3
|
+
* The path prefix the DOCUMENT says this service is served under.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ **Without this the two artefacts from one spec disagree, which is the thing this project exists
|
|
6
|
+
* to prevent.** `@server("/api/v1")` reaches OpenAPI as `servers: [{ url: "/api/v1" }]`, and an
|
|
7
|
+
* OpenAPI path is relative to its server — so the document says `/api/v1/accounts` while the
|
|
8
|
+
* generated router answered `/accounts`. Measured: every client generated from the document, and
|
|
9
|
+
* every "try it" button in a rendered document, 404s.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ **A prefix is only taken where the document is unambiguous about it.** Guessing wrong is worse
|
|
12
|
+
* than not applying one: a route mounted under the wrong prefix still matches, still answers, and
|
|
13
|
+
* answers the wrong URL.
|
|
14
|
+
*/
|
|
15
|
+
/** The static path of a server URL, or `undefined` when it has none this can rely on. */
|
|
16
|
+
function pathOf(server) {
|
|
17
|
+
const url = server.url;
|
|
18
|
+
/**
|
|
19
|
+
* ⚠️ **A templated URL is not a mismatch and must not warn.** `@server("{endpoint}")` — which most
|
|
20
|
+
* of `@typespec/http-specs` uses — means the whole origin is supplied by the caller, so the paths
|
|
21
|
+
* the document publishes are already relative to whatever they choose. Mounting at the root is
|
|
22
|
+
* correct there, and warning would raise noise on the majority of real specs.
|
|
23
|
+
*/
|
|
24
|
+
if (url.includes("{"))
|
|
25
|
+
return undefined;
|
|
26
|
+
// An absolute URL carries a host; only its path is a prefix.
|
|
27
|
+
const path = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? (URL.parse(url)?.pathname ?? "/") : url;
|
|
28
|
+
const trimmed = path.replace(/\/+$/, "");
|
|
29
|
+
return trimmed === "" || trimmed === "/" ? undefined : trimmed;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read the service's declared servers and decide what to mount under.
|
|
33
|
+
*
|
|
34
|
+
* - no `@server`, or a templated one → the root, which is what the document means;
|
|
35
|
+
* - one static path, or several that agree → that path;
|
|
36
|
+
* - several that DISAGREE → the root, and the caller reports it. There is no answer that serves all
|
|
37
|
+
* of them, and picking one would silently serve the wrong URLs for the others.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveBasePath(program, namespace) {
|
|
40
|
+
const servers = getServers(program, namespace) ?? [];
|
|
41
|
+
const paths = [...new Set(servers.map(pathOf).filter((path) => path !== undefined))];
|
|
42
|
+
if (paths.length === 0)
|
|
43
|
+
return { basePath: undefined, ambiguous: [] };
|
|
44
|
+
if (paths.length === 1)
|
|
45
|
+
return { basePath: paths[0], ambiguous: [] };
|
|
46
|
+
return { basePath: undefined, ambiguous: paths.toSorted() };
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type EmitContext } from "@typespec/compiler";
|
|
2
|
+
/**
|
|
3
|
+
* What the generated files import their runtime contract from when the consumer sets nothing.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ **THIS package's runtime, not the library's, and the distinction is the whole of a defect that
|
|
6
|
+
* shipped.** `app.gen.ts` names `AppEnv`, `Awaitable`, `Ctx`, `Result`, `RouteDeps` and
|
|
7
|
+
* `selectContentType`; every one of them is declared in `src/runtime.ts` here. The library's runtime
|
|
8
|
+
* exports `ResponseArm` and `armFor` and nothing else — and it is a TRANSITIVE dependency of a
|
|
9
|
+
* consumer of this package, so under a strict `node_modules` its specifier does not resolve from
|
|
10
|
+
* consumer code at all.
|
|
11
|
+
*
|
|
12
|
+
* Pointing at `typespec-hono/runtime` fixes both halves at once, because this module RE-EXPORTS
|
|
13
|
+
* `ResponseArm` and `armFor` (see `runtime.ts`) — which is what `schemas.gen.ts` imports. One
|
|
14
|
+
* specifier, present in the consumer's own dependency, carrying every name both generated files
|
|
15
|
+
* reference.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ **Measured in a fresh project installed from `pnpm pack` tarballs, because no test could see it:**
|
|
18
|
+
* every compile in both harnesses sets `runtime-module` explicitly, so the default branch was ungraded
|
|
19
|
+
* across 240 tests. `tsp compile` succeeded with zero diagnostics and `tsc` then reported two
|
|
20
|
+
* `TS2307`s — `Cannot find module 'typespec-http-zod/runtime'` — one in each generated file.
|
|
21
|
+
* `test/adopter.test.ts` is the arm that now opens that branch.
|
|
22
|
+
*/
|
|
23
|
+
export declare const DEFAULT_RUNTIME_MODULE = "typespec-hono/runtime";
|
|
24
|
+
export declare function $onEmit(context: EmitContext): Promise<void>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { emitFile, resolvePath } from "@typespec/compiler";
|
|
2
|
+
import { emitHttpZod } from "typespec-http-zod";
|
|
3
|
+
import { renderApp } from "./app.js";
|
|
4
|
+
import { resolveBasePath } from "./base-path.js";
|
|
5
|
+
import { securityFor } from "./security.js";
|
|
6
|
+
import { reportDiagnostic } from "./lib.js";
|
|
7
|
+
/**
|
|
8
|
+
* This emitter's entry point — **the whole of `typespec-http-zod`, plus one file**.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ **A consumer lists ONE emitter, and this is why.** The validators and the server share a naming
|
|
11
|
+
* contract: `app.gen.ts` imports `readWidgetPath` and `readWidgetResponses` from `schemas.gen.js` by
|
|
12
|
+
* name. Two separate TypeSpec emitters would each get their own `$onEmit` and their own registry, and
|
|
13
|
+
* would have to arrive at identical identifiers by coincidence. Running the library here means it
|
|
14
|
+
* mints the names, writes them, and hands them back — so agreement is structural rather than hoped
|
|
15
|
+
* for.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ **It uses nothing `typespec-http-zod` does not export.** The package's `exports` map makes a deep
|
|
18
|
+
* import impossible, so this file is the proof that the published API is sufficient to build a server
|
|
19
|
+
* generator on. Anything it cannot do from here is a gap in that API, to be fixed there.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The HTTP operation an emitted route came from, keyed on verb and path.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ **Keyed on the ROUTE, not on the name, and the name was wrong for every interface.**
|
|
25
|
+
* `EmittedRoute.operationId` is the id the document publishes — `Accounts_list`, with the interface
|
|
26
|
+
* prefix `resolveOperationId` inserts — while `operation.operation.name` is the bare `list`. Matching
|
|
27
|
+
* them never succeeded for an operation declared inside an `interface`, which is most of them.
|
|
28
|
+
*
|
|
29
|
+
* Two things rested on that lookup and both were silently wrong: every diagnostic pointed at the
|
|
30
|
+
* service namespace instead of the operation that caused it, and the security requirements resolved
|
|
31
|
+
* to none, so a scheme-gated route emitted no gate. Verb and path identify a route exactly, and are
|
|
32
|
+
* what both sides already agree on.
|
|
33
|
+
*/
|
|
34
|
+
function operationFor(emitted, verb, path) {
|
|
35
|
+
return emitted.service.operations.find((candidate) => candidate.verb.toUpperCase() === verb.toUpperCase() && candidate.path === path);
|
|
36
|
+
}
|
|
37
|
+
/** The declaration a diagnostic should point at, falling back to the service when it cannot be found. */
|
|
38
|
+
function targetFor(emitted, verb, path) {
|
|
39
|
+
return operationFor(emitted, verb, path)?.operation ?? emitted.service.namespace;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* What the generated files import their runtime contract from when the consumer sets nothing.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ **THIS package's runtime, not the library's, and the distinction is the whole of a defect that
|
|
45
|
+
* shipped.** `app.gen.ts` names `AppEnv`, `Awaitable`, `Ctx`, `Result`, `RouteDeps` and
|
|
46
|
+
* `selectContentType`; every one of them is declared in `src/runtime.ts` here. The library's runtime
|
|
47
|
+
* exports `ResponseArm` and `armFor` and nothing else — and it is a TRANSITIVE dependency of a
|
|
48
|
+
* consumer of this package, so under a strict `node_modules` its specifier does not resolve from
|
|
49
|
+
* consumer code at all.
|
|
50
|
+
*
|
|
51
|
+
* Pointing at `typespec-hono/runtime` fixes both halves at once, because this module RE-EXPORTS
|
|
52
|
+
* `ResponseArm` and `armFor` (see `runtime.ts`) — which is what `schemas.gen.ts` imports. One
|
|
53
|
+
* specifier, present in the consumer's own dependency, carrying every name both generated files
|
|
54
|
+
* reference.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ **Measured in a fresh project installed from `pnpm pack` tarballs, because no test could see it:**
|
|
57
|
+
* every compile in both harnesses sets `runtime-module` explicitly, so the default branch was ungraded
|
|
58
|
+
* across 240 tests. `tsp compile` succeeded with zero diagnostics and `tsc` then reported two
|
|
59
|
+
* `TS2307`s — `Cannot find module 'typespec-http-zod/runtime'` — one in each generated file.
|
|
60
|
+
* `test/adopter.test.ts` is the arm that now opens that branch.
|
|
61
|
+
*/
|
|
62
|
+
export const DEFAULT_RUNTIME_MODULE = "typespec-hono/runtime";
|
|
63
|
+
export async function $onEmit(context) {
|
|
64
|
+
for (const emitted of await emitHttpZod(context, {
|
|
65
|
+
defaultRuntimeModule: DEFAULT_RUNTIME_MODULE,
|
|
66
|
+
})) {
|
|
67
|
+
/**
|
|
68
|
+
* ⚠️ **The path the DOCUMENT says this service is served under.** An OpenAPI path is relative to
|
|
69
|
+
* its server, so `@server("/api/v1")` plus `/accounts` publishes `/api/v1/accounts`. Mounting at
|
|
70
|
+
* the root made every client generated from the document 404.
|
|
71
|
+
*/
|
|
72
|
+
const base = resolveBasePath(context.program, emitted.service.namespace);
|
|
73
|
+
if (base.ambiguous.length > 0) {
|
|
74
|
+
reportDiagnostic(context.program, {
|
|
75
|
+
code: "ambiguous-server-path",
|
|
76
|
+
format: { paths: base.ambiguous.join(", ") },
|
|
77
|
+
target: emitted.service.namespace,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
await emitFile(context.program, {
|
|
81
|
+
path: resolvePath(emitted.outputDir, "app.gen.ts"),
|
|
82
|
+
content: renderApp(emitted, {
|
|
83
|
+
/**
|
|
84
|
+
* Reported rather than thrown, so a spec with one unmountable path still names every
|
|
85
|
+
* other problem in the same compile — and so the validators for the rest of the service
|
|
86
|
+
* are still written. A path this router cannot express is not a reason to emit nothing.
|
|
87
|
+
*/
|
|
88
|
+
unsupportedPathTemplate: (route, template, name) => {
|
|
89
|
+
reportDiagnostic(context.program, {
|
|
90
|
+
code: "unsupported-path-template",
|
|
91
|
+
format: { template, name },
|
|
92
|
+
target: targetFor(emitted, route.verb, route.path),
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
unroutableVerb: (route) => {
|
|
96
|
+
reportDiagnostic(context.program, {
|
|
97
|
+
code: "unroutable-verb",
|
|
98
|
+
format: { operationId: route.operationId, verb: route.verb },
|
|
99
|
+
target: targetFor(emitted, route.verb, route.path),
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
}, base.basePath, (verb, path) => {
|
|
103
|
+
const operation = operationFor(emitted, verb, path);
|
|
104
|
+
return operation === undefined ? [] : securityFor(context.program, operation);
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package's entry point: what a consumer may use, and the emitter written against it.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **`typespec-http-zod` is re-exported deliberately.** This emitter runs the whole of it, so a
|
|
5
|
+
* consumer of the generated server is already a consumer of those validators and their types. Making
|
|
6
|
+
* them reach for a second package to name a schema this one caused to exist would be an accident of
|
|
7
|
+
* packaging showing through.
|
|
8
|
+
*/
|
|
9
|
+
export * from "typespec-http-zod";
|
|
10
|
+
export { $lib } from "./lib.js";
|
|
11
|
+
export { $onEmit } from "./emitter.js";
|
|
12
|
+
export { renderApp, toHonoPath } from "./app.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package's entry point: what a consumer may use, and the emitter written against it.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **`typespec-http-zod` is re-exported deliberately.** This emitter runs the whole of it, so a
|
|
5
|
+
* consumer of the generated server is already a consumer of those validators and their types. Making
|
|
6
|
+
* them reach for a second package to name a schema this one caused to exist would be an accident of
|
|
7
|
+
* packaging showing through.
|
|
8
|
+
*/
|
|
9
|
+
export * from "typespec-http-zod";
|
|
10
|
+
export { $lib } from "./lib.js";
|
|
11
|
+
export { $onEmit } from "./emitter.js";
|
|
12
|
+
export { renderApp, toHonoPath } from "./app.js";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type CallableMessage, type DiagnosticReport, type JSONSchemaType, type Program, type TypeSpecLibrary } from "@typespec/compiler";
|
|
2
|
+
import { type EmitterOptions as HttpZodOptions } from "typespec-http-zod";
|
|
3
|
+
/**
|
|
4
|
+
* The library definition, kept in its own module so `tsp-index.ts` and the emitter can both reach it
|
|
5
|
+
* without importing each other.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Everything `typespec-http-zod` accepts, plus what a Hono server needs on top.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ **DERIVED, never restated, and the distinction is load-bearing.** This emitter runs the whole of
|
|
11
|
+
* `typespec-http-zod` and adds one file; every option that package accepts has to reach it. Written
|
|
12
|
+
* as a second literal list, an option added there would be rejected here as unknown — or worse,
|
|
13
|
+
* accepted and silently dropped, which produces output that is wrong in a way no test of either
|
|
14
|
+
* package would see. `test/options.test.ts` asserts the forwarding as a CLASS.
|
|
15
|
+
*
|
|
16
|
+
* There is currently nothing to add: `runtime-module` belongs to the library, because the library is
|
|
17
|
+
* what emits the annotated response arms that need it. This type exists as the seam rather than
|
|
18
|
+
* because it carries anything today — the moment a Hono-only option appears it goes here, and the
|
|
19
|
+
* derivation keeps the rest honest.
|
|
20
|
+
*/
|
|
21
|
+
export type EmitterOptions = HttpZodOptions;
|
|
22
|
+
/**
|
|
23
|
+
* ⚠️ **A spread of the published schema, so a new key arrives for free.**
|
|
24
|
+
*
|
|
25
|
+
* `properties` is spread rather than re-listed for the same reason the type is aliased rather than
|
|
26
|
+
* re-declared. If this ever needs a Hono-only key, it is added to a spread of `httpZodOptions.properties`
|
|
27
|
+
* — never by copying the list.
|
|
28
|
+
*/
|
|
29
|
+
declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
|
|
30
|
+
/**
|
|
31
|
+
* ⚠️ **Spelled out rather than inferred, and every type in it imported through THIS package's own
|
|
32
|
+
* specifier.** Both this package and `typespec-http-zod` declare `@typespec/compiler` as a peer, so a
|
|
33
|
+
* side-by-side checkout resolves two physically distinct copies of the identical version — measured,
|
|
34
|
+
* two `.pnpm` paths differing only in which repository they sit under. TypeScript then had a choice
|
|
35
|
+
* of which copy to name in the emitted declarations and chose the other package's, producing five
|
|
36
|
+
* `TS2883`s whose message is exactly the problem: *"this is likely not portable"*.
|
|
37
|
+
*
|
|
38
|
+
* A published `.d.ts` naming a `node_modules/.pnpm/...` path is broken for everyone who installed
|
|
39
|
+
* differently. Naming these through the direct import pins them to the specifier a consumer resolves,
|
|
40
|
+
* whatever their tree looks like.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ **A consumer never hits this**, because a peer dependency is installed once and both packages
|
|
43
|
+
* share it. That is precisely why it is worth guarding against here rather than trusting: the failure
|
|
44
|
+
* exists only in the arrangement that BUILDS the package, and would ship silently.
|
|
45
|
+
*/
|
|
46
|
+
type Diagnostics = {
|
|
47
|
+
"unroutable-verb": {
|
|
48
|
+
readonly default: CallableMessage<["operationId", "verb"]>;
|
|
49
|
+
};
|
|
50
|
+
"unsupported-path-template": {
|
|
51
|
+
readonly default: CallableMessage<["template", "name"]>;
|
|
52
|
+
};
|
|
53
|
+
"ambiguous-server-path": {
|
|
54
|
+
readonly default: CallableMessage<["paths"]>;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* ⚠️ **Annotated rather than inferred, and the reason is a packaging fact rather than a style
|
|
59
|
+
* preference.** This package and `typespec-http-zod` each resolve their own `@typespec/compiler` —
|
|
60
|
+
* that is what a peer dependency does, and a consumer installing both gets one copy while a
|
|
61
|
+
* side-by-side checkout gets two. Inferring the type here makes the emitted `.d.ts` name a compiler
|
|
62
|
+
* through a path that exists only in the tree it was built in: `TS2883`, five of them, and the
|
|
63
|
+
* message says it outright — *"this is likely not portable"*.
|
|
64
|
+
*
|
|
65
|
+
* A published declaration file that names a `node_modules/.pnpm/...` path is broken for everyone who
|
|
66
|
+
* installed differently. Naming the type explicitly is what makes the declaration stand on its own,
|
|
67
|
+
* and this is exactly the class of defect a package built inside one workspace never has to face.
|
|
68
|
+
*/
|
|
69
|
+
export declare const $lib: TypeSpecLibrary<Diagnostics, EmitterOptions>;
|
|
70
|
+
export declare const reportDiagnostic: <C extends keyof Diagnostics, M extends keyof Diagnostics[C]>(program: Program, diagnostic: DiagnosticReport<Diagnostics, C, M>) => void;
|
|
71
|
+
export { EmitterOptionsSchema };
|
package/dist/src/lib.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createTypeSpecLibrary, paramMessage, } from "@typespec/compiler";
|
|
2
|
+
import { EmitterOptionsSchema as httpZodOptions, } from "typespec-http-zod";
|
|
3
|
+
/**
|
|
4
|
+
* ⚠️ **A spread of the published schema, so a new key arrives for free.**
|
|
5
|
+
*
|
|
6
|
+
* `properties` is spread rather than re-listed for the same reason the type is aliased rather than
|
|
7
|
+
* re-declared. If this ever needs a Hono-only key, it is added to a spread of `httpZodOptions.properties`
|
|
8
|
+
* — never by copying the list.
|
|
9
|
+
*/
|
|
10
|
+
const EmitterOptionsSchema = {
|
|
11
|
+
...httpZodOptions,
|
|
12
|
+
properties: { ...httpZodOptions.properties },
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* What this emitter refuses that the library does not.
|
|
16
|
+
*
|
|
17
|
+
* The library refuses what no OpenAPI document can state. Everything here is narrower: what no *Hono
|
|
18
|
+
* router* can mount. Two different questions, and keeping them in separate libraries is what stops a
|
|
19
|
+
* consumer who wants validators without a server inheriting a refusal about routing.
|
|
20
|
+
*/
|
|
21
|
+
const diagnostics = {
|
|
22
|
+
/**
|
|
23
|
+
* An operation on a verb Hono cannot dispatch to — in practice, `HEAD`.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ **Hono rewrites every HEAD request to GET BEFORE routing, unconditionally.** Read from its own
|
|
26
|
+
* source, `hono-base.js`:
|
|
27
|
+
*
|
|
28
|
+
* ```js
|
|
29
|
+
* if (method === "HEAD") {
|
|
30
|
+
* return (async () => new Response(null, await this.#dispatch(request, executionCtx, env, "GET")))();
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* So a route registered under `HEAD` is categorically unreachable. Measured on Hono 4.13.1: with a
|
|
35
|
+
* HEAD route and no GET, a HEAD request answers **404**; with both, the GET handler answers and the
|
|
36
|
+
* HEAD handler is dead code. `on("PURGE", …)` and `on("OPTIONS", …)` both work, so this is HEAD
|
|
37
|
+
* specifically and not a limitation of `on`.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ **This emitter shipped exactly that dead code, and every route-counting arm called it mounted**
|
|
40
|
+
* — including a differential written specifically to catch unreachable routes, because `app.routes`
|
|
41
|
+
* lists a registration Hono will never dispatch to. Fifteen of the seventeen HEAD operations in
|
|
42
|
+
* `@typespec/http-specs` have no sibling GET, so they were 404s that counted as present.
|
|
43
|
+
*
|
|
44
|
+
* **Refused rather than compensated for.** The workarounds are worse: registering the handler under
|
|
45
|
+
* GET invents an operation the document does not declare, and guarding it on `c.req.method` is not
|
|
46
|
+
* something a Hono author would write. The remedy is in the spec, and the message says it.
|
|
47
|
+
*
|
|
48
|
+
* ⚠️ **Hono's own best-practices guide says the same thing outright**, which this refusal predates:
|
|
49
|
+
* *"Don't create dedicated `app.head()` handlers — they won't execute as HEAD requests are converted
|
|
50
|
+
* before route matching."* The rule was derived here by reading `hono-base.js` and measuring; finding
|
|
51
|
+
* it stated in the documentation afterwards is corroboration rather than the source.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ **A refusal about the TARGET FRAMEWORK, which is why it lives here.** `typespec-http-zod` emits
|
|
54
|
+
* correct validators for these operations; only a Hono server cannot route them.
|
|
55
|
+
*/
|
|
56
|
+
"unroutable-verb": {
|
|
57
|
+
severity: "warning",
|
|
58
|
+
messages: {
|
|
59
|
+
default: paramMessage `'${"operationId"}' is declared on '${"verb"}', which Hono cannot dispatch to: it rewrites every HEAD request to GET before matching, so a route registered under HEAD is never reached — 404 where the path has no GET, and dead code where it has one. Declare the operation as '@get' instead; Hono answers HEAD from it automatically with the body stripped, which is what RFC 9110 requires.`,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
/**
|
|
63
|
+
* The document declares several servers whose paths disagree, so there is no prefix to mount under.
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ **Reported rather than resolved, because every resolution is wrong for somebody.** An OpenAPI
|
|
66
|
+
* path is relative to its server, so `@server("/api/v1")` plus `/accounts` means the document
|
|
67
|
+
* publishes `/api/v1/accounts`. Where one static path is declared this emitter mounts under it and
|
|
68
|
+
* the two artefacts agree. Where several disagree there is no single answer: picking one serves
|
|
69
|
+
* the wrong URL for every other, and a route mounted under a wrong prefix still matches and still
|
|
70
|
+
* answers, which is worse than one that fails.
|
|
71
|
+
*
|
|
72
|
+
* Routes are mounted at the root in this case, which is at least predictable, and this says so.
|
|
73
|
+
*/
|
|
74
|
+
"ambiguous-server-path": {
|
|
75
|
+
severity: "warning",
|
|
76
|
+
messages: {
|
|
77
|
+
default: paramMessage `The service declares servers with different base paths (${"paths"}), so routes are mounted at the root. An OpenAPI path is relative to its server, so callers following the document will prefix one of these — mount the returned app under the prefix you serve, or declare a single base path.`,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
/**
|
|
81
|
+
* A path template this emitter will not translate into a route.
|
|
82
|
+
*
|
|
83
|
+
* ⚠️ **Refused rather than approximated, because the failure mode is a route that WORKS and is
|
|
84
|
+
* wrong.** Hono reads `:name` up to the next `/`, so an RFC 6570 modifier survives into the
|
|
85
|
+
* parameter name — `{id*}` becomes `:id*`, where `*` is Hono's own wildcard. That mounts a route
|
|
86
|
+
* which matches, answers, and binds the wrong thing, which is strictly worse than one that 404s.
|
|
87
|
+
*
|
|
88
|
+
* ⚠️ **The refusal lands HERE and not in the library, which is the whole point of the split.** The
|
|
89
|
+
* validators for such an operation are correct and are still emitted: what a request body must
|
|
90
|
+
* look like does not depend on whether some particular router can express the path. Only the
|
|
91
|
+
* server is impossible.
|
|
92
|
+
*
|
|
93
|
+
* Plain names are translated, including the hyphens and dots that `\w` used to drop on the floor —
|
|
94
|
+
* a parameter carrying a hyphen was left alone entirely, so `@path("thing-id")` produced the
|
|
95
|
+
* literal route `/things/{thing-id}`: mounted, counted by every arm that counted routes, and
|
|
96
|
+
* reachable by nobody.
|
|
97
|
+
*/
|
|
98
|
+
"unsupported-path-template": {
|
|
99
|
+
severity: "warning",
|
|
100
|
+
messages: {
|
|
101
|
+
default: paramMessage `'${"template"}' is not a path template this emitter can mount: the parameter '${"name"}' is not a plain name. Hono reads a parameter up to the next '/', so an RFC 6570 operator or modifier would become part of the name — or, for '*', a wildcard — and the route would match the wrong requests rather than fail. Name the parameter with letters, digits, '_', '-', '.' or '~'.`,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* ⚠️ **Annotated rather than inferred, and the reason is a packaging fact rather than a style
|
|
107
|
+
* preference.** This package and `typespec-http-zod` each resolve their own `@typespec/compiler` —
|
|
108
|
+
* that is what a peer dependency does, and a consumer installing both gets one copy while a
|
|
109
|
+
* side-by-side checkout gets two. Inferring the type here makes the emitted `.d.ts` name a compiler
|
|
110
|
+
* through a path that exists only in the tree it was built in: `TS2883`, five of them, and the
|
|
111
|
+
* message says it outright — *"this is likely not portable"*.
|
|
112
|
+
*
|
|
113
|
+
* A published declaration file that names a `node_modules/.pnpm/...` path is broken for everyone who
|
|
114
|
+
* installed differently. Naming the type explicitly is what makes the declaration stand on its own,
|
|
115
|
+
* and this is exactly the class of defect a package built inside one workspace never has to face.
|
|
116
|
+
*/
|
|
117
|
+
export const $lib = createTypeSpecLibrary({
|
|
118
|
+
name: "typespec-hono",
|
|
119
|
+
diagnostics,
|
|
120
|
+
emitter: { options: EmitterOptionsSchema },
|
|
121
|
+
});
|
|
122
|
+
export const reportDiagnostic = $lib.reportDiagnostic;
|
|
123
|
+
export { EmitterOptionsSchema };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Context, Env, Input, MiddlewareHandler } from "hono";
|
|
2
|
+
import type { ResponseArm } from "typespec-http-zod/runtime";
|
|
3
|
+
/**
|
|
4
|
+
* ⚠️ **`ResponseArm` and `armFor` live in `typespec-http-zod` and are re-exported here.**
|
|
5
|
+
*
|
|
6
|
+
* The library is what emits the response arms — `schemas.gen.ts` declares them and annotates them
|
|
7
|
+
* with `satisfies readonly ResponseArm[]` — so the type belongs to the package that produces it, and
|
|
8
|
+
* the rule for reading an array containing `4XX` and `default` belongs beside it. A consumer serving
|
|
9
|
+
* those validators from Express needs both and should not depend on a Hono emitter to get them.
|
|
10
|
+
*
|
|
11
|
+
* Re-exported rather than merely available, so an application still has ONE runtime import and the
|
|
12
|
+
* `runtime-module` substitution keeps working unchanged: a module an app points that option at has to
|
|
13
|
+
* supply every name the generated files reference, and they reference these.
|
|
14
|
+
*/
|
|
15
|
+
export { armFor, type ResponseArm } from "typespec-http-zod/runtime";
|
|
16
|
+
/**
|
|
17
|
+
* One acceptable combination of credentials, exactly as OpenAPI's `security` states it: scheme id to
|
|
18
|
+
* the scopes that scheme requires. Every entry in one requirement must be satisfied TOGETHER, and
|
|
19
|
+
* satisfying ANY ONE requirement authorises the caller.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ **Declared here rather than beside the code that derives it**, because `./runtime` is what a
|
|
22
|
+
* running server imports and must stay free of every build-time dependency — a packaging arm asserts
|
|
23
|
+
* it names no `@typespec/*` package at all. An application should not drag a compiler into its
|
|
24
|
+
* Worker to read one type.
|
|
25
|
+
*/
|
|
26
|
+
export type SecurityRequirement = Readonly<Record<string, readonly string[]>>;
|
|
27
|
+
/**
|
|
28
|
+
* The contract between the GENERATED server and the app that mounts it.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ **This exists because "here is a data table, write your own router" is not a deliverable.**
|
|
31
|
+
* The emitter used to produce `GENERATED_ROUTES` — one array of plain objects — and every consumer
|
|
32
|
+
* had to hand-write a loop that interpreted it at run time. In this repository that loop is 220
|
|
33
|
+
* lines, it sits outside every oracle the emitter is judged by, and it carries a cast that exists
|
|
34
|
+
* *only* because iterating a homogeneous array throws away the per-operation types the emitter knew:
|
|
35
|
+
* `backend[operationId]` is a union of 104 differently-typed methods, so nothing about the call can
|
|
36
|
+
* be checked. Generating the server removes the loop, the cast, and the compensating type-level
|
|
37
|
+
* assertion invented to put the guarantee back.
|
|
38
|
+
*
|
|
39
|
+
* What is left for the app to supply is genuinely app-specific: how a request becomes a caller's
|
|
40
|
+
* context, and how a result becomes a response. Everything else — routing, validation, which
|
|
41
|
+
* validator applies to which target, what status each arm answers — is generated.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* How an operation's return value is wrapped.
|
|
45
|
+
*
|
|
46
|
+
* Identity by default, so an operation may simply return its value. An app with a result envelope
|
|
47
|
+
* points `runtime-module` at its own module and re-declares this as, say, `ServiceResult<T>` — which
|
|
48
|
+
* is what keeps the generated `Operations` interface concretely typed end to end instead of falling
|
|
49
|
+
* back to `unknown` and reintroducing the cast this whole change exists to delete.
|
|
50
|
+
*/
|
|
51
|
+
export type Result<T> = T;
|
|
52
|
+
/**
|
|
53
|
+
* The Hono environment the generated server mounts on, and the caller context its operations take.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ **Concrete on purpose.** Making `registerRoutes` generic over the environment does not work:
|
|
56
|
+
* Hono narrows `Context` per route and its conditional types cannot reduce
|
|
57
|
+
* `IfAnyThenEmptyObject<E extends Env ? …>` while `E` is an unbound parameter, so nothing the app
|
|
58
|
+
* supplies is ever assignable and every call site needs a cast. Naming the types here instead — an
|
|
59
|
+
* app points `runtime-module` at its own module and re-declares them — keeps every generated call
|
|
60
|
+
* site concrete and cast-free. Identity defaults, so an app with neither can ignore both.
|
|
61
|
+
*/
|
|
62
|
+
export type AppEnv = Env;
|
|
63
|
+
export type Ctx = unknown;
|
|
64
|
+
/** Anything an operation may hand back: the value, or a promise of it. */
|
|
65
|
+
export type Awaitable<T> = T | Promise<T>;
|
|
66
|
+
/**
|
|
67
|
+
* Pick the media type to serve, per RFC 9110 §12.5.1.
|
|
68
|
+
*
|
|
69
|
+
* ⚠️ **In the runtime rather than in {@link RouteDeps}, deliberately.** The test for admitting
|
|
70
|
+
* anything to `deps` is *the generated code cannot proceed without an answer*, and this is not that:
|
|
71
|
+
* which media types an operation offers is a contract fact the emitter reads from the document, and
|
|
72
|
+
* how `Accept` selects among them is specified by the RFC. Both sides are derivable, so an app that
|
|
73
|
+
* had to supply this would be re-implementing the standard — and could get it wrong differently from
|
|
74
|
+
* everybody else.
|
|
75
|
+
*
|
|
76
|
+
* The rules that matter, and that a naive `includes()` gets wrong:
|
|
77
|
+
* - **absent or empty `Accept` means anything is acceptable** — serve the first offer;
|
|
78
|
+
* - **`q=0` is a REFUSAL**, not a weak preference, so a range scoring zero can never be chosen;
|
|
79
|
+
* - **specificity breaks ties before quality does**: `text/plain` beats `text/*` beats `*/*` at
|
|
80
|
+
* equal `q`, which is why the score is a pair and not a number;
|
|
81
|
+
* - parameters after the media range (`;charset=utf-8`) are not part of the match.
|
|
82
|
+
*
|
|
83
|
+
* Returns `undefined` when nothing offered is acceptable — the caller answers 406, and the
|
|
84
|
+
* difference between "no preference" and "no acceptable option" is exactly what that turns on.
|
|
85
|
+
*/
|
|
86
|
+
export declare function selectContentType(accept: string | undefined, offered: readonly string[]): string | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* What the app provides. One object, passed once, rather than a module the generated file imports by
|
|
89
|
+
* path — a generated server that hard-codes `../../backend.js` is only usable by the project it was
|
|
90
|
+
* generated in, and this one has to be usable by any.
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ **The hooks are generic over Hono's path and input parameters, deliberately.** Hono narrows
|
|
93
|
+
* `Context` per route — by the literal path, and by whatever the validators on that route produced —
|
|
94
|
+
* so a hook typed against a single `Context<E>` is not assignable at any real call site. Making the
|
|
95
|
+
* hooks generic lets the app write functions that ignore both, without a cast anywhere.
|
|
96
|
+
*
|
|
97
|
+
* ⚠️ **`E` and `C` are PARAMETERS, and they have to be.** The defaults keep the bare `RouteDeps` the
|
|
98
|
+
* generated server writes working for an app that substitutes nothing. An app that substitutes
|
|
99
|
+
* anything binds them once — `export type RouteDeps = BaseRouteDeps<AppEnv, Ctx>` in the module it
|
|
100
|
+
* points `runtime-module` at — and every hook is then typed against its own environment and its own
|
|
101
|
+
* caller context.
|
|
102
|
+
*
|
|
103
|
+
* Re-exporting this interface unparameterised instead does not work, and the reason is not obvious:
|
|
104
|
+
* **Hono's `Context` is INVARIANT in its environment**, because `Context.set` takes `E` as an
|
|
105
|
+
* argument. So `Context<AppEnv, …>` is not assignable to `Context<Env, …>` however plain the
|
|
106
|
+
* substituted environment is, and every generated `deps.*` call site fails. Separately, `context`
|
|
107
|
+
* would keep returning the identity `Ctx` — `unknown` — which the app's own handlers then reject.
|
|
108
|
+
* Measured before this was parameterised: **19 errors on a four-operation service.**
|
|
109
|
+
*/
|
|
110
|
+
export interface RouteDeps<E extends Env = AppEnv, C = Ctx> {
|
|
111
|
+
/**
|
|
112
|
+
* The gate the DOCUMENT publishes, as middleware.
|
|
113
|
+
*
|
|
114
|
+
* ⚠️ **Which scopes an operation demands is a contract fact; how a token is verified is not.**
|
|
115
|
+
* `@useAuth(OAuth2Auth<…>)` reaches OpenAPI as `security` per operation, so the requirement is
|
|
116
|
+
* generated and this implements the check — the same split as `context` and `respond`. Emitted
|
|
117
|
+
* only where the operation declares scopes, which is why an internal surface with none is
|
|
118
|
+
* unaffected.
|
|
119
|
+
*
|
|
120
|
+
* Its absence was a real defect for one commit: the generated server carried **zero** references
|
|
121
|
+
* to scopes while the document published eleven, so a surface mounted with its gate silently
|
|
122
|
+
* dropped.
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ **It receives the document's REQUIREMENTS, not a flat list of scopes, and that is the second
|
|
125
|
+
* half of the same defect.** `@useAuth(BearerAuth)` publishes `security: [{ "BearerAuth": [] }]` —
|
|
126
|
+
* no scopes — so a scopes-only gate was emitted for OAuth2 and for nothing else. Bearer, api-key
|
|
127
|
+
* and basic, which is most services, carried no gate at all and rested entirely on `context`
|
|
128
|
+
* returning null. An app whose `context` read a cookie would serve a route the document says needs
|
|
129
|
+
* a bearer token.
|
|
130
|
+
*
|
|
131
|
+
* Satisfying ANY ONE requirement authorises the caller, and every scheme WITHIN a requirement must
|
|
132
|
+
* be satisfied together — which is exactly what an array of OpenAPI `security` objects means.
|
|
133
|
+
*/
|
|
134
|
+
readonly authorize: (requirements: readonly SecurityRequirement[]) => MiddlewareHandler<E>;
|
|
135
|
+
/**
|
|
136
|
+
* The caller's context, or `null` when there is none to establish.
|
|
137
|
+
*
|
|
138
|
+
* `authentication` is what the DOCUMENT says, and only that: `"none"` where the operation
|
|
139
|
+
* declares `@useAuth(NoAuth)` — `security: []` in OpenAPI — and `"required"` otherwise. Deciding
|
|
140
|
+
* it at generation time is the point: the gate the document publishes is the gate that runs.
|
|
141
|
+
*
|
|
142
|
+
* ⚠️ **It used to be `"none" | "account" | "resource"`, and the last two were an invention.** They
|
|
143
|
+
* were chosen by whether the path had parameters, which no OpenAPI keyword expresses and which
|
|
144
|
+
* merely happened to fit the first consumer. A generated server enforcing a rule derived from
|
|
145
|
+
* nothing published is the defect class this emitter exists to remove, so it is gone. An app that
|
|
146
|
+
* needs the distinction can read the request, which is the one thing it definitely has.
|
|
147
|
+
*/
|
|
148
|
+
readonly context: <P extends string, I extends Input>(c: Context<E, P, I>, authentication: "none" | "required") => C | null;
|
|
149
|
+
/** The response when `context` returns `null`. */
|
|
150
|
+
readonly noContext: <P extends string, I extends Input>(c: Context<E, P, I>) => Response;
|
|
151
|
+
/**
|
|
152
|
+
* The response when the caller's `Accept` matches nothing the operation offers — a 406.
|
|
153
|
+
*
|
|
154
|
+
* Emitted only on routes where the document declares more than one media type for a status, so
|
|
155
|
+
* a service without content negotiation never sees it. Same shape of hook as {@link noContext}
|
|
156
|
+
* and admitted on the same test: the status and the `offered` list are contract facts the
|
|
157
|
+
* generated code already has, but the body they are reported in is the app's envelope, and it
|
|
158
|
+
* cannot proceed without one. {@link selectContentType} does the choosing; this reports failure.
|
|
159
|
+
*/
|
|
160
|
+
readonly notAcceptable: <P extends string, I extends Input>(c: Context<E, P, I>, offered: readonly string[]) => Response;
|
|
161
|
+
/**
|
|
162
|
+
* Passed straight to `zValidator`'s hook. Returning `undefined` lets a successful validation
|
|
163
|
+
* through; returning a `Response` is how a rejection becomes the status this API promises rather
|
|
164
|
+
* than the middleware's default.
|
|
165
|
+
*/
|
|
166
|
+
readonly invalid: <P extends string, I extends Input>(result: {
|
|
167
|
+
readonly success: boolean;
|
|
168
|
+
}, c: Context<E, P, I>) => Response | undefined;
|
|
169
|
+
/**
|
|
170
|
+
* Turn an operation's result into a response, checked against the schema the document publishes
|
|
171
|
+
* for the arm that applies. A bodyless success is an arm whose `schema` is `undefined`.
|
|
172
|
+
*/
|
|
173
|
+
readonly respond: <P extends string, I extends Input>(c: Context<E, P, I>, arms: readonly ResponseArm[], result: unknown) => Awaitable<Response>;
|
|
174
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⚠️ **`ResponseArm` and `armFor` live in `typespec-http-zod` and are re-exported here.**
|
|
3
|
+
*
|
|
4
|
+
* The library is what emits the response arms — `schemas.gen.ts` declares them and annotates them
|
|
5
|
+
* with `satisfies readonly ResponseArm[]` — so the type belongs to the package that produces it, and
|
|
6
|
+
* the rule for reading an array containing `4XX` and `default` belongs beside it. A consumer serving
|
|
7
|
+
* those validators from Express needs both and should not depend on a Hono emitter to get them.
|
|
8
|
+
*
|
|
9
|
+
* Re-exported rather than merely available, so an application still has ONE runtime import and the
|
|
10
|
+
* `runtime-module` substitution keeps working unchanged: a module an app points that option at has to
|
|
11
|
+
* supply every name the generated files reference, and they reference these.
|
|
12
|
+
*/
|
|
13
|
+
export { armFor } from "typespec-http-zod/runtime";
|
|
14
|
+
/**
|
|
15
|
+
* Pick the media type to serve, per RFC 9110 §12.5.1.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ **In the runtime rather than in {@link RouteDeps}, deliberately.** The test for admitting
|
|
18
|
+
* anything to `deps` is *the generated code cannot proceed without an answer*, and this is not that:
|
|
19
|
+
* which media types an operation offers is a contract fact the emitter reads from the document, and
|
|
20
|
+
* how `Accept` selects among them is specified by the RFC. Both sides are derivable, so an app that
|
|
21
|
+
* had to supply this would be re-implementing the standard — and could get it wrong differently from
|
|
22
|
+
* everybody else.
|
|
23
|
+
*
|
|
24
|
+
* The rules that matter, and that a naive `includes()` gets wrong:
|
|
25
|
+
* - **absent or empty `Accept` means anything is acceptable** — serve the first offer;
|
|
26
|
+
* - **`q=0` is a REFUSAL**, not a weak preference, so a range scoring zero can never be chosen;
|
|
27
|
+
* - **specificity breaks ties before quality does**: `text/plain` beats `text/*` beats `*/*` at
|
|
28
|
+
* equal `q`, which is why the score is a pair and not a number;
|
|
29
|
+
* - parameters after the media range (`;charset=utf-8`) are not part of the match.
|
|
30
|
+
*
|
|
31
|
+
* Returns `undefined` when nothing offered is acceptable — the caller answers 406, and the
|
|
32
|
+
* difference between "no preference" and "no acceptable option" is exactly what that turns on.
|
|
33
|
+
*/
|
|
34
|
+
export function selectContentType(accept, offered) {
|
|
35
|
+
if (offered.length === 0)
|
|
36
|
+
return undefined;
|
|
37
|
+
const header = accept?.trim();
|
|
38
|
+
if (header === undefined || header === "")
|
|
39
|
+
return offered[0];
|
|
40
|
+
const ranges = header.split(",").map((entry) => {
|
|
41
|
+
const [range = "", ...parameters] = entry.split(";").map((part) => part.trim());
|
|
42
|
+
const quality = parameters
|
|
43
|
+
.map((parameter) => /^q=(?<value>[\d.]+)$/i.exec(parameter)?.groups?.value)
|
|
44
|
+
.find((value) => value !== undefined);
|
|
45
|
+
return { range: range.toLowerCase(), q: quality === undefined ? 1 : Number(quality) };
|
|
46
|
+
});
|
|
47
|
+
let best;
|
|
48
|
+
for (const type of offered) {
|
|
49
|
+
const [group] = type.toLowerCase().split("/");
|
|
50
|
+
for (const { range, q } of ranges) {
|
|
51
|
+
// `q=0` is "I will not accept this", so it never becomes a candidate.
|
|
52
|
+
if (!Number.isFinite(q) || q <= 0)
|
|
53
|
+
continue;
|
|
54
|
+
const specificity = range === type.toLowerCase() ? 2 : range === `${group}/*` ? 1 : range === "*/*" ? 0 : -1;
|
|
55
|
+
if (specificity < 0)
|
|
56
|
+
continue;
|
|
57
|
+
if (best === undefined || q > best.q || (q === best.q && specificity > best.specificity)) {
|
|
58
|
+
best = { type, q, specificity };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return best?.type;
|
|
63
|
+
}
|