typespec-hono 0.1.0 → 0.2.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 +113 -265
- package/dist/src/app.d.ts +25 -19
- package/dist/src/app.js +238 -113
- package/dist/src/base-path.d.ts +18 -9
- package/dist/src/base-path.js +20 -18
- package/dist/src/emitter.d.ts +9 -18
- package/dist/src/emitter.js +51 -26
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/lib.d.ts +13 -16
- package/dist/src/lib.js +28 -68
- package/dist/src/runtime.d.ts +102 -46
- package/dist/src/runtime.js +63 -17
- package/dist/src/security.d.ts +4 -4
- package/dist/src/security.js +2 -2
- package/dist/src/tsp-index.d.ts +1 -1
- package/dist/src/tsp-index.js +1 -1
- package/lib/main.tsp +1 -1
- package/package.json +6 -5
- package/src/runtime.ts +299 -0
package/dist/src/base-path.d.ts
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import type { Namespace, Program } from "@typespec/compiler";
|
|
2
2
|
export interface BasePathResolution {
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Every prefix the document publishes, in declaration order after de-duplication.
|
|
5
|
+
*
|
|
6
|
+
* Empty means the root, which is what a document with no `@server` -- or a templated one -- means.
|
|
7
|
+
* More than one is not a conflict to resolve: the document says the service answers at all of
|
|
8
|
+
* them, and Hono mounts one sub-app under several prefixes without duplicating a single route.
|
|
9
|
+
*/
|
|
10
|
+
readonly basePaths: readonly string[];
|
|
7
11
|
}
|
|
8
12
|
/**
|
|
9
|
-
* Read the service's declared servers and
|
|
13
|
+
* Read the service's declared servers and return every prefix it publishes.
|
|
10
14
|
*
|
|
11
|
-
* - no `@server`, or a templated one
|
|
12
|
-
* - one static path
|
|
13
|
-
* - several
|
|
14
|
-
*
|
|
15
|
+
* - no `@server`, or a templated one, gives none, and the routes mount at the root;
|
|
16
|
+
* - one static path gives that path;
|
|
17
|
+
* - several give all of them, each mounted with its own `app.route()` over one shared sub-app.
|
|
18
|
+
*
|
|
19
|
+
* **Several servers used to be reported as ambiguous and mounted at the root.** That was wrong in
|
|
20
|
+
* the one way that matters: an OpenAPI path is relative to its server, so every caller following the
|
|
21
|
+
* document prefixed one of the declared paths and got a 404. The document was never ambiguous -- it
|
|
22
|
+
* says the service answers at all of them -- and Hono mounts one sub-app under as many prefixes as
|
|
23
|
+
* asked, so there was nothing to choose between in the first place.
|
|
15
24
|
*/
|
|
16
25
|
export declare function resolveBasePath(program: Program, namespace: Namespace): BasePathResolution;
|
package/dist/src/base-path.js
CHANGED
|
@@ -2,22 +2,22 @@ import { getServers } from "@typespec/http";
|
|
|
2
2
|
/**
|
|
3
3
|
* The path prefix the DOCUMENT says this service is served under.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* **Without this the two artefacts from one spec disagree, which is the thing this project exists
|
|
6
6
|
* to prevent.** `@server("/api/v1")` reaches OpenAPI as `servers: [{ url: "/api/v1" }]`, and an
|
|
7
|
-
* OpenAPI path is relative to its server
|
|
7
|
+
* OpenAPI path is relative to its server, so the document says `/api/v1/accounts` while the
|
|
8
8
|
* generated router answered `/accounts`. Measured: every client generated from the document, and
|
|
9
9
|
* every "try it" button in a rendered document, 404s.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* **Every declared prefix is honoured, because the document publishes all of them.** A route
|
|
12
|
+
* mounted under only one of several still matches and still answers, and answers the wrong URL for
|
|
13
|
+
* every caller who followed one of the others.
|
|
14
14
|
*/
|
|
15
15
|
/** The static path of a server URL, or `undefined` when it has none this can rely on. */
|
|
16
16
|
function pathOf(server) {
|
|
17
17
|
const url = server.url;
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
* of `@typespec/http-specs` uses
|
|
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
21
|
* the document publishes are already relative to whatever they choose. Mounting at the root is
|
|
22
22
|
* correct there, and warning would raise noise on the majority of real specs.
|
|
23
23
|
*/
|
|
@@ -29,19 +29,21 @@ function pathOf(server) {
|
|
|
29
29
|
return trimmed === "" || trimmed === "/" ? undefined : trimmed;
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
* Read the service's declared servers and
|
|
32
|
+
* Read the service's declared servers and return every prefix it publishes.
|
|
33
33
|
*
|
|
34
|
-
* - no `@server`, or a templated one
|
|
35
|
-
* - one static path
|
|
36
|
-
* - several
|
|
37
|
-
*
|
|
34
|
+
* - no `@server`, or a templated one, gives none, and the routes mount at the root;
|
|
35
|
+
* - one static path gives that path;
|
|
36
|
+
* - several give all of them, each mounted with its own `app.route()` over one shared sub-app.
|
|
37
|
+
*
|
|
38
|
+
* **Several servers used to be reported as ambiguous and mounted at the root.** That was wrong in
|
|
39
|
+
* the one way that matters: an OpenAPI path is relative to its server, so every caller following the
|
|
40
|
+
* document prefixed one of the declared paths and got a 404. The document was never ambiguous -- it
|
|
41
|
+
* says the service answers at all of them -- and Hono mounts one sub-app under as many prefixes as
|
|
42
|
+
* asked, so there was nothing to choose between in the first place.
|
|
38
43
|
*/
|
|
39
44
|
export function resolveBasePath(program, namespace) {
|
|
40
45
|
const servers = getServers(program, namespace) ?? [];
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (paths.length === 1)
|
|
45
|
-
return { basePath: paths[0], ambiguous: [] };
|
|
46
|
-
return { basePath: undefined, ambiguous: paths.toSorted() };
|
|
46
|
+
return {
|
|
47
|
+
basePaths: [...new Set(servers.map(pathOf).filter((path) => path !== undefined))],
|
|
48
|
+
};
|
|
47
49
|
}
|
package/dist/src/emitter.d.ts
CHANGED
|
@@ -1,24 +1,15 @@
|
|
|
1
1
|
import { type EmitContext } from "@typespec/compiler";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* The generated runtime lands beside the generated code, so nothing the emitter writes imports this
|
|
4
|
+
* package at run time.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* consumer of this package, so under a strict `node_modules` its specifier does not resolve from
|
|
10
|
-
* consumer code at all.
|
|
6
|
+
* An emitter is a build-time tool. `@typespec/openapi3` is a devDependency and its output imports
|
|
7
|
+
* nothing, which is the shape a consumer expects. This package shipped a runtime half instead, which
|
|
8
|
+
* forced it to be a `dependency` and made `--save-dev` fail at deploy and nowhere earlier. Emitting
|
|
9
|
+
* the runtime removes the choice rather than documenting it.
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
11
|
+
* `typespec-hono/runtime` is still exported, for an application that points `runtime-module` at it
|
|
12
|
+
* deliberately. It is no longer what the generated code reaches for by default.
|
|
22
13
|
*/
|
|
23
|
-
export declare const DEFAULT_RUNTIME_MODULE = "
|
|
14
|
+
export declare const DEFAULT_RUNTIME_MODULE = "./runtime.gen.js";
|
|
24
15
|
export declare function $onEmit(context: EmitContext): Promise<void>;
|
package/dist/src/emitter.js
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
import { emitFile, resolvePath } from "@typespec/compiler";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
2
4
|
import { emitHttpZod } from "typespec-http-zod";
|
|
3
5
|
import { renderApp } from "./app.js";
|
|
4
6
|
import { resolveBasePath } from "./base-path.js";
|
|
5
7
|
import { securityFor } from "./security.js";
|
|
6
8
|
import { reportDiagnostic } from "./lib.js";
|
|
7
9
|
/**
|
|
8
|
-
* This emitter's entry point
|
|
10
|
+
* This emitter's entry point. **the whole of `typespec-http-zod`, plus one file**.
|
|
9
11
|
*
|
|
10
|
-
*
|
|
12
|
+
* **A consumer lists ONE emitter, and this is why.** The validators and the server share a naming
|
|
11
13
|
* contract: `app.gen.ts` imports `readWidgetPath` and `readWidgetResponses` from `schemas.gen.js` by
|
|
12
14
|
* name. Two separate TypeSpec emitters would each get their own `$onEmit` and their own registry, and
|
|
13
15
|
* 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
|
|
16
|
+
* mints the names, writes them, and hands them back, so agreement is structural rather than hoped
|
|
15
17
|
* for.
|
|
16
18
|
*
|
|
17
|
-
*
|
|
19
|
+
* **It uses nothing `typespec-http-zod` does not export.** The package's `exports` map makes a deep
|
|
18
20
|
* import impossible, so this file is the proof that the published API is sufficient to build a server
|
|
19
21
|
* generator on. Anything it cannot do from here is a gap in that API, to be fixed there.
|
|
20
22
|
*/
|
|
21
23
|
/**
|
|
22
24
|
* The HTTP operation an emitted route came from, keyed on verb and path.
|
|
23
25
|
*
|
|
24
|
-
*
|
|
25
|
-
* `EmittedRoute.operationId` is the id the document publishes
|
|
26
|
-
* prefix `resolveOperationId` inserts
|
|
26
|
+
* **Keyed on the ROUTE, not on the name, and the name was wrong for every interface.**
|
|
27
|
+
* `EmittedRoute.operationId` is the id the document publishes, `Accounts_list`, with the interface
|
|
28
|
+
* prefix `resolveOperationId` inserts, while `operation.operation.name` is the bare `list`. Matching
|
|
27
29
|
* them never succeeded for an operation declared inside an `interface`, which is most of them.
|
|
28
30
|
*
|
|
29
31
|
* Two things rested on that lookup and both were silently wrong: every diagnostic pointed at the
|
|
@@ -41,48 +43,71 @@ function targetFor(emitted, verb, path) {
|
|
|
41
43
|
/**
|
|
42
44
|
* What the generated files import their runtime contract from when the consumer sets nothing.
|
|
43
45
|
*
|
|
44
|
-
*
|
|
46
|
+
* **THIS package's runtime, not the library's, and the distinction is the whole of a defect that
|
|
45
47
|
* shipped.** `app.gen.ts` names `AppEnv`, `Awaitable`, `Ctx`, `Result`, `RouteDeps` and
|
|
46
48
|
* `selectContentType`; every one of them is declared in `src/runtime.ts` here. The library's runtime
|
|
47
|
-
* exports `ResponseArm` and `armFor` and nothing else
|
|
49
|
+
* exports `ResponseArm` and `armFor` and nothing else, and it is a TRANSITIVE dependency of a
|
|
48
50
|
* consumer of this package, so under a strict `node_modules` its specifier does not resolve from
|
|
49
51
|
* consumer code at all.
|
|
50
52
|
*
|
|
51
53
|
* Pointing at `typespec-hono/runtime` fixes both halves at once, because this module RE-EXPORTS
|
|
52
|
-
* `ResponseArm` and `armFor` (see `runtime.ts`)
|
|
54
|
+
* `ResponseArm` and `armFor` (see `runtime.ts`), which is what `schemas.gen.ts` imports. One
|
|
53
55
|
* specifier, present in the consumer's own dependency, carrying every name both generated files
|
|
54
56
|
* reference.
|
|
55
57
|
*
|
|
56
|
-
*
|
|
58
|
+
* **Measured in a fresh project installed from `pnpm pack` tarballs, because no test could see it:**
|
|
57
59
|
* every compile in both harnesses sets `runtime-module` explicitly, so the default branch was ungraded
|
|
58
60
|
* across 240 tests. `tsp compile` succeeded with zero diagnostics and `tsc` then reported two
|
|
59
|
-
* `TS2307`s
|
|
61
|
+
* `TS2307`s (`Cannot find module 'typespec-http-zod/runtime'`) one in each generated file.
|
|
60
62
|
* `test/adopter.test.ts` is the arm that now opens that branch.
|
|
61
63
|
*/
|
|
62
|
-
|
|
64
|
+
/** The header the emitted runtime carries, matching every other generated file. */
|
|
65
|
+
const GENERATED_RUNTIME_BANNER = `// GENERATED by typespec-hono. DO NOT EDIT.
|
|
66
|
+
// A copy of this package's runtime, emitted so the generated code imports no package at run time.
|
|
67
|
+
// Point \`runtime-module\` at a module of your own to replace it.
|
|
68
|
+
`;
|
|
69
|
+
/**
|
|
70
|
+
* The generated runtime lands beside the generated code, so nothing the emitter writes imports this
|
|
71
|
+
* package at run time.
|
|
72
|
+
*
|
|
73
|
+
* An emitter is a build-time tool. `@typespec/openapi3` is a devDependency and its output imports
|
|
74
|
+
* nothing, which is the shape a consumer expects. This package shipped a runtime half instead, which
|
|
75
|
+
* forced it to be a `dependency` and made `--save-dev` fail at deploy and nowhere earlier. Emitting
|
|
76
|
+
* the runtime removes the choice rather than documenting it.
|
|
77
|
+
*
|
|
78
|
+
* `typespec-hono/runtime` is still exported, for an application that points `runtime-module` at it
|
|
79
|
+
* deliberately. It is no longer what the generated code reaches for by default.
|
|
80
|
+
*/
|
|
81
|
+
export const DEFAULT_RUNTIME_MODULE = "./runtime.gen.js";
|
|
82
|
+
/** Where this package's own runtime source lives, to be copied beside the generated code. */
|
|
83
|
+
const RUNTIME_SOURCE = fileURLToPath(new URL("../../src/runtime.ts", import.meta.url));
|
|
63
84
|
export async function $onEmit(context) {
|
|
64
85
|
for (const emitted of await emitHttpZod(context, {
|
|
65
86
|
defaultRuntimeModule: DEFAULT_RUNTIME_MODULE,
|
|
66
87
|
})) {
|
|
67
88
|
/**
|
|
68
|
-
*
|
|
89
|
+
* Written only when the generated code actually points at it. An application that sets
|
|
90
|
+
* `runtime-module` has supplied its own module and must not have a file it did not ask for
|
|
91
|
+
* appear in its output directory.
|
|
92
|
+
*/
|
|
93
|
+
if (emitted.options.runtimeModule === DEFAULT_RUNTIME_MODULE) {
|
|
94
|
+
await emitFile(context.program, {
|
|
95
|
+
path: resolvePath(emitted.outputDir, "runtime.gen.ts"),
|
|
96
|
+
content: `${GENERATED_RUNTIME_BANNER}${readFileSync(RUNTIME_SOURCE, "utf8")}`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* **The path the DOCUMENT says this service is served under.** An OpenAPI path is relative to
|
|
69
101
|
* its server, so `@server("/api/v1")` plus `/accounts` publishes `/api/v1/accounts`. Mounting at
|
|
70
102
|
* the root made every client generated from the document 404.
|
|
71
103
|
*/
|
|
72
104
|
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
105
|
await emitFile(context.program, {
|
|
81
106
|
path: resolvePath(emitted.outputDir, "app.gen.ts"),
|
|
82
107
|
content: renderApp(emitted, {
|
|
83
108
|
/**
|
|
84
109
|
* Reported rather than thrown, so a spec with one unmountable path still names every
|
|
85
|
-
* other problem in the same compile
|
|
110
|
+
* other problem in the same compile, and so the validators for the rest of the service
|
|
86
111
|
* are still written. A path this router cannot express is not a reason to emit nothing.
|
|
87
112
|
*/
|
|
88
113
|
unsupportedPathTemplate: (route, template, name) => {
|
|
@@ -92,14 +117,14 @@ export async function $onEmit(context) {
|
|
|
92
117
|
target: targetFor(emitted, route.verb, route.path),
|
|
93
118
|
});
|
|
94
119
|
},
|
|
95
|
-
|
|
120
|
+
unvalidatableMediaType: (route, types) => {
|
|
96
121
|
reportDiagnostic(context.program, {
|
|
97
|
-
code: "
|
|
98
|
-
format: { operationId: route.operationId,
|
|
122
|
+
code: "unvalidatable-media-type",
|
|
123
|
+
format: { operationId: route.operationId, types: types.join(", ") },
|
|
99
124
|
target: targetFor(emitted, route.verb, route.path),
|
|
100
125
|
});
|
|
101
126
|
},
|
|
102
|
-
}, base.
|
|
127
|
+
}, base.basePaths, (verb, path) => {
|
|
103
128
|
const operation = operationFor(emitted, verb, path);
|
|
104
129
|
return operation === undefined ? [] : securityFor(context.program, operation);
|
|
105
130
|
}),
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The package's entry point: what a consumer may use, and the emitter written against it.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* **`typespec-http-zod` is re-exported deliberately.** This emitter runs the whole of it, so a
|
|
5
5
|
* consumer of the generated server is already a consumer of those validators and their types. Making
|
|
6
6
|
* them reach for a second package to name a schema this one caused to exist would be an accident of
|
|
7
7
|
* packaging showing through.
|
package/dist/src/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The package's entry point: what a consumer may use, and the emitter written against it.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* **`typespec-http-zod` is re-exported deliberately.** This emitter runs the whole of it, so a
|
|
5
5
|
* consumer of the generated server is already a consumer of those validators and their types. Making
|
|
6
6
|
* them reach for a second package to name a schema this one caused to exist would be an accident of
|
|
7
7
|
* packaging showing through.
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -7,30 +7,30 @@ import { type EmitterOptions as HttpZodOptions } from "typespec-http-zod";
|
|
|
7
7
|
/**
|
|
8
8
|
* Everything `typespec-http-zod` accepts, plus what a Hono server needs on top.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* **DERIVED, never restated, and the distinction is load-bearing.** This emitter runs the whole of
|
|
11
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
|
|
12
|
+
* as a second literal list, an option added there would be rejected here as unknown, or worse,
|
|
13
13
|
* accepted and silently dropped, which produces output that is wrong in a way no test of either
|
|
14
14
|
* package would see. `test/options.test.ts` asserts the forwarding as a CLASS.
|
|
15
15
|
*
|
|
16
16
|
* There is currently nothing to add: `runtime-module` belongs to the library, because the library is
|
|
17
17
|
* what emits the annotated response arms that need it. This type exists as the seam rather than
|
|
18
|
-
* because it carries anything today
|
|
18
|
+
* because it carries anything today. The moment a Hono-only option appears it goes here, and the
|
|
19
19
|
* derivation keeps the rest honest.
|
|
20
20
|
*/
|
|
21
21
|
export type EmitterOptions = HttpZodOptions;
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
23
|
+
* **A spread of the published schema, so a new key arrives for free.**
|
|
24
24
|
*
|
|
25
25
|
* `properties` is spread rather than re-listed for the same reason the type is aliased rather than
|
|
26
26
|
* re-declared. If this ever needs a Hono-only key, it is added to a spread of `httpZodOptions.properties`
|
|
27
|
-
|
|
27
|
+
*, never by copying the list.
|
|
28
28
|
*/
|
|
29
29
|
declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* **Spelled out rather than inferred, and every type in it imported through THIS package's own
|
|
32
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
|
|
33
|
+
* side-by-side checkout resolves two physically distinct copies of the identical version, measured,
|
|
34
34
|
* two `.pnpm` paths differing only in which repository they sit under. TypeScript then had a choice
|
|
35
35
|
* of which copy to name in the emitted declarations and chose the other package's, producing five
|
|
36
36
|
* `TS2883`s whose message is exactly the problem: *"this is likely not portable"*.
|
|
@@ -39,28 +39,25 @@ declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
|
|
|
39
39
|
* differently. Naming these through the direct import pins them to the specifier a consumer resolves,
|
|
40
40
|
* whatever their tree looks like.
|
|
41
41
|
*
|
|
42
|
-
*
|
|
42
|
+
* **A consumer never hits this**, because a peer dependency is installed once and both packages
|
|
43
43
|
* share it. That is precisely why it is worth guarding against here rather than trusting: the failure
|
|
44
44
|
* exists only in the arrangement that BUILDS the package, and would ship silently.
|
|
45
45
|
*/
|
|
46
46
|
type Diagnostics = {
|
|
47
|
-
"unroutable-verb": {
|
|
48
|
-
readonly default: CallableMessage<["operationId", "verb"]>;
|
|
49
|
-
};
|
|
50
47
|
"unsupported-path-template": {
|
|
51
48
|
readonly default: CallableMessage<["template", "name"]>;
|
|
52
49
|
};
|
|
53
|
-
"
|
|
54
|
-
readonly default: CallableMessage<["
|
|
50
|
+
"unvalidatable-media-type": {
|
|
51
|
+
readonly default: CallableMessage<["operationId", "types"]>;
|
|
55
52
|
};
|
|
56
53
|
};
|
|
57
54
|
/**
|
|
58
|
-
*
|
|
59
|
-
* preference.** This package and `typespec-http-zod` each resolve their own `@typespec/compiler
|
|
55
|
+
* **Annotated rather than inferred, and the reason is a packaging fact rather than a style
|
|
56
|
+
* preference.** This package and `typespec-http-zod` each resolve their own `@typespec/compiler`,
|
|
60
57
|
* that is what a peer dependency does, and a consumer installing both gets one copy while a
|
|
61
58
|
* side-by-side checkout gets two. Inferring the type here makes the emitted `.d.ts` name a compiler
|
|
62
59
|
* through a path that exists only in the tree it was built in: `TS2883`, five of them, and the
|
|
63
|
-
* message says it outright
|
|
60
|
+
* message says it outright, *"this is likely not portable"*.
|
|
64
61
|
*
|
|
65
62
|
* A published declaration file that names a `node_modules/.pnpm/...` path is broken for everyone who
|
|
66
63
|
* installed differently. Naming the type explicitly is what makes the declaration stand on its own,
|
package/dist/src/lib.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createTypeSpecLibrary, paramMessage, } from "@typespec/compiler";
|
|
2
2
|
import { EmitterOptionsSchema as httpZodOptions, } from "typespec-http-zod";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* **A spread of the published schema, so a new key arrives for free.**
|
|
5
5
|
*
|
|
6
6
|
* `properties` is spread rather than re-listed for the same reason the type is aliased rather than
|
|
7
7
|
* re-declared. If this ever needs a Hono-only key, it is added to a spread of `httpZodOptions.properties`
|
|
8
|
-
|
|
8
|
+
*, never by copying the list.
|
|
9
9
|
*/
|
|
10
10
|
const EmitterOptionsSchema = {
|
|
11
11
|
...httpZodOptions,
|
|
@@ -19,96 +19,56 @@ const EmitterOptionsSchema = {
|
|
|
19
19
|
* consumer who wants validators without a server inheriting a refusal about routing.
|
|
20
20
|
*/
|
|
21
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
22
|
/**
|
|
81
23
|
* A path template this emitter will not translate into a route.
|
|
82
24
|
*
|
|
83
|
-
*
|
|
25
|
+
* **Refused rather than approximated, because the failure mode is a route that WORKS and is
|
|
84
26
|
* wrong.** Hono reads `:name` up to the next `/`, so an RFC 6570 modifier survives into the
|
|
85
|
-
* parameter name
|
|
27
|
+
* parameter name, `{id*}` becomes `:id*`, where `*` is Hono's own wildcard. That mounts a route
|
|
86
28
|
* which matches, answers, and binds the wrong thing, which is strictly worse than one that 404s.
|
|
87
29
|
*
|
|
88
|
-
*
|
|
30
|
+
* **The refusal lands HERE and not in the library, which is the whole point of the split.** The
|
|
89
31
|
* validators for such an operation are correct and are still emitted: what a request body must
|
|
90
32
|
* look like does not depend on whether some particular router can express the path. Only the
|
|
91
33
|
* server is impossible.
|
|
92
34
|
*
|
|
93
|
-
* Plain names are translated, including the hyphens and dots that `\w` used to drop on the floor
|
|
35
|
+
* Plain names are translated, including the hyphens and dots that `\w` used to drop on the floor,
|
|
94
36
|
* a parameter carrying a hyphen was left alone entirely, so `@path("thing-id")` produced the
|
|
95
37
|
* literal route `/things/{thing-id}`: mounted, counted by every arm that counted routes, and
|
|
96
38
|
* reachable by nobody.
|
|
97
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* The document declares a request media type no `zValidator` target can parse.
|
|
42
|
+
*
|
|
43
|
+
* Reported rather than silently mis-parsed. This emitter used to hand every unrecognised media type
|
|
44
|
+
* to `zValidator("json", ...)`, which reads `c.req.json()`, so an `application/xml` body was
|
|
45
|
+
* rejected with a 400 and nothing said why. Naming it is what stops a consumer believing a route is
|
|
46
|
+
* validated when it is not.
|
|
47
|
+
*
|
|
48
|
+
* There is no Hono parser and no Zod representation for XML, and adding an XML dependency to a Zod
|
|
49
|
+
* emitter is not a trade this package should make. Where the same route also declares a media type
|
|
50
|
+
* that CAN be parsed, that one is still validated, chosen from the request's `Content-Type`.
|
|
51
|
+
*/
|
|
52
|
+
"unvalidatable-media-type": {
|
|
53
|
+
severity: "warning",
|
|
54
|
+
messages: {
|
|
55
|
+
default: paramMessage `'${"operationId"}' declares request media types this emitter cannot validate (${"types"}). Requests carrying them are refused rather than validated. Declare a media type with a parser -- JSON, multipart or urlencoded -- or handle the body yourself with '@body body: bytes'.`,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
98
58
|
"unsupported-path-template": {
|
|
99
59
|
severity: "warning",
|
|
100
60
|
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
|
|
61
|
+
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
62
|
},
|
|
103
63
|
},
|
|
104
64
|
};
|
|
105
65
|
/**
|
|
106
|
-
*
|
|
107
|
-
* preference.** This package and `typespec-http-zod` each resolve their own `@typespec/compiler
|
|
66
|
+
* **Annotated rather than inferred, and the reason is a packaging fact rather than a style
|
|
67
|
+
* preference.** This package and `typespec-http-zod` each resolve their own `@typespec/compiler`,
|
|
108
68
|
* that is what a peer dependency does, and a consumer installing both gets one copy while a
|
|
109
69
|
* side-by-side checkout gets two. Inferring the type here makes the emitted `.d.ts` name a compiler
|
|
110
70
|
* through a path that exists only in the tree it was built in: `TS2883`, five of them, and the
|
|
111
|
-
* message says it outright
|
|
71
|
+
* message says it outright, *"this is likely not portable"*.
|
|
112
72
|
*
|
|
113
73
|
* A published declaration file that names a `node_modules/.pnpm/...` path is broken for everyone who
|
|
114
74
|
* installed differently. Naming the type explicitly is what makes the declaration stand on its own,
|