@afora/hono 0.0.0-stage → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,132 @@
1
- # Temporary Holding Version
1
+ # @afora/hono
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [Hono](https://hono.dev) sink for [`@afora/core`](../../README.md) outputs. It turns the operations of an output into a Hono app: validated input, `200` with the response (sensitive values masked), RFC 9457 `application/problem+json` errors, and an OpenAPI document of the same routes. Outputs, stages and `statusOf` are described in [docs/outputs.md](../../docs/outputs.md).
4
+
5
+ ```bash
6
+ bun add @afora/core @afora/hono hono typebox
7
+ ```
8
+
9
+ `@afora/core`, `hono` (`^4.13.14`) and TypeBox (`^1`) are peer dependencies. The package uses only web APIs, so it runs on Bun, Node >= 22 and Workers.
10
+
11
+ ## Example
12
+
13
+ ```ts
14
+ import { outputOf } from "@afora/core";
15
+ import { hono } from "@afora/hono";
16
+
17
+ const Api = outputOf({
18
+ name: "Blog",
19
+ commands: [Users, Posts], // commandOf(...) declarations
20
+ statuses: { InvalidCredentials: 401, Unauthorized: 401 },
21
+ pipe: [
22
+ hono({
23
+ basePath: "/api",
24
+ route: { "Posts.list": { method: "get" } },
25
+ docs: {
26
+ path: "/openapi.json",
27
+ info: { title: "Blog", version: "1.0.0" },
28
+ },
29
+ context: { headers: { authorization: "authorization" } },
30
+ }),
31
+ ],
32
+ });
33
+
34
+ export default Api.build(); // a Hono app: Bun.serve, Workers and `app.request(...)` all take it
35
+ ```
36
+
37
+ ```
38
+ POST /api/users/register { "name": "Ana", "email": "ana@example.com", "plainPassword": "S3cret-pass!" }
39
+ -> 200 { "user": { "name": "Ana", "email": "ana@example.com", "password": "********", ... } }
40
+
41
+ POST /api/users/login { "email": "ana@example.com", "plainPassword": "wrong" }
42
+ -> 401 application/problem+json
43
+ { "type": "urn:afora:exception:InvalidCredentials", "title": "InvalidCredentials",
44
+ "status": 401, "detail": "Wrong email or password.", "layer": "use-case" }
45
+
46
+ POST /api/users/register { "email": "nope" }
47
+ -> 400 application/problem+json { ..., "issues": [{ "path": ["email"], "code": "...", "message": "..." }] }
48
+ ```
49
+
50
+ ## Options
51
+
52
+ All options are JSON, so the stage can live in an `afora.json` (`{ "sink": "hono", "options": { ... } }`).
53
+
54
+ | Option | Meaning |
55
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
56
+ | `basePath` | Prefix of every route, e.g. `"/api"`. |
57
+ | `route` | `{ [operationId]: { method?, path? } }`, as in the `openapi` sink. |
58
+ | `docs` | `{ path, info: { title, version, description? }, servers? }`: serves the `openapi` document of the same operations, routes and `basePath` at `path` (not under `basePath`). A `path` that is also a GET route throws `InvalidOutputStage`. |
59
+ | `debug` | `true` adds the `trace` to problems and the real `detail` to 5xx ones. Off by default. |
60
+ | `context` | `{ headers: { [contextKey]: headerName } }`. |
61
+
62
+ Invalid options throw `InvalidOutputStage` when the stage is made, and unknown operation ids in `route` or two operations on one method and path when the app is built.
63
+
64
+ ## Behavior
65
+
66
+ - **Routes.** `POST {basePath}/{kebab(command)}/{kebab(operation)}` (`/users/change-password`); `route` changes the method and path. It is the routing of the `openapi` sink (`operationRoutes` in the core), so the served routes and the document agree.
67
+ - **Input.** `post`, `put` and `patch` read a JSON body (an empty body is `{}`; invalid JSON is a 400 `InvalidRequestBody` problem). `get` and `delete` read the query string, converting a value to a number or boolean when the input field takes one rather than a string; anything else is kept for the blueprint to reject. The operation validates the input: a 400 problem with `issues` (`path`, `code`, `message`, no values).
68
+ - **Context.** For an operation whose command or use case declares a `context`, each key listed in `context.headers` is read from its header; a missing header leaves the key out (so an optional key is `undefined`, a required one a 400 problem). Header values are converted like query values.
69
+ - **Success.** `200` with `outputValueOf(response)`: records go through `toJSON`, so sensitive values (a `Password`) are `"********"`.
70
+ - **Errors.** Status from `statusOf(error, operation, output.statuses)`: the output `statuses` first, then 400 for invalid input or context, 404 for a missing entity, 409 for a unique violation, 422 for an error the use case declares, else 500. The body is `problemOf` with `application/problem+json`; a 5xx says `"Internal error."` unless `debug` is on.
71
+ - **Unknown routes** are a `404` problem (`urn:afora:exception:NotFound`) instead of Hono's plain text, and anything thrown outside an operation (your own middleware) is a problem too, through `app.onError`.
72
+
73
+ ## Authentication and custom context
74
+
75
+ Options are JSON, so there is no callback for the context. Cross-cutting rules such as authentication are a `transform` stage that wraps `invoke`: it sees the context the sink built from headers and can reject the call, which then goes through `statuses` like any other error.
76
+
77
+ ```ts
78
+ import {
79
+ ExceptionLayers,
80
+ exceptionOf,
81
+ operationOf,
82
+ outputOf,
83
+ transformOf,
84
+ } from "@afora/core";
85
+ import { hono } from "@afora/hono";
86
+
87
+ const Unauthorized = exceptionOf({
88
+ layer: ExceptionLayers.UseCase,
89
+ name: "Unauthorized",
90
+ message: "Sign in first.",
91
+ });
92
+
93
+ // the operation's use case (or command) declares `context: blueprintOf((ctx) => ({ authorization: ctx.optional.string }))`
94
+ export const requireAuth = transformOf({
95
+ name: "requireAuth",
96
+ apply: (operation) =>
97
+ operation.tags.includes("public")
98
+ ? operation
99
+ : operationOf(operation, {
100
+ invoke: async (input, call) => {
101
+ const { authorization } = (call?.context ?? {}) as {
102
+ authorization?: string;
103
+ };
104
+ if (!authorization)
105
+ throw new Unauthorized({ source: operation.id });
106
+ // verify the token and put the user into the call, if the context declares it
107
+ return operation.invoke(input, call);
108
+ },
109
+ }),
110
+ });
111
+
112
+ outputOf({
113
+ name: "Blog",
114
+ commands: [Users, Posts],
115
+ statuses: { Unauthorized: 401 },
116
+ pipe: [
117
+ requireAuth(),
118
+ hono({ context: { headers: { authorization: "authorization" } } }),
119
+ ],
120
+ });
121
+ ```
122
+
123
+ The context only reaches `invoke` for operations that declare one, so the key (`authorization` here) must be in the `context` blueprint of the command or use case.
124
+
125
+ ## Catalog
126
+
127
+ `HonoStages` is `stageCatalogOf(hono)`, and `stages` are the entries codegen reads to resolve `{ "sink": "hono" }` in an `afora.json`:
128
+
129
+ ```ts
130
+ import { stages } from "@afora/hono";
131
+ // [{ name: "hono", module: "@afora/hono", export: "hono" }]
132
+ ```
@@ -0,0 +1,49 @@
1
+ import { type OpenAPISinkOptions } from "@afora/core";
2
+ import { Hono } from "hono";
3
+ /** The options of the `hono` sink. JSON, so the stage can live in an `afora.json`. */
4
+ export interface HonoOptions {
5
+ /** Prefix of every route, e.g. `"/api"`. Empty by default. */
6
+ readonly basePath?: OpenAPISinkOptions["basePath"];
7
+ /** Method and path by operation id, as in the `openapi` sink. */
8
+ readonly route?: OpenAPISinkOptions["route"];
9
+ /** Serves the OpenAPI document of the same operations at `path`. */
10
+ readonly docs?: {
11
+ readonly path: string;
12
+ readonly info: OpenAPISinkOptions["info"];
13
+ readonly servers?: OpenAPISinkOptions["servers"];
14
+ };
15
+ /** Adds the `trace` to problems and the real `detail` to 5xx ones. Off by default. */
16
+ readonly debug?: boolean;
17
+ /** The context of an operation, from request headers: `{ headers: { token: "authorization" } }`. */
18
+ readonly context?: {
19
+ readonly headers?: {
20
+ readonly [contextKey: string]: string;
21
+ };
22
+ };
23
+ }
24
+ /**
25
+ * A Hono app that serves the operations: `POST {basePath}/{command}/{operation}`
26
+ * per operation (kebab-case; `route` changes the method and path as in the
27
+ * `openapi` sink, so the two documents agree).
28
+ *
29
+ * - Body methods read a JSON body, `get` and `delete` the query string, whose
30
+ * scalars are coerced to numbers and booleans where the input takes them.
31
+ * The input is validated by the operation (a 400 problem with `issues`).
32
+ * - The context comes from `context.headers` (`{ contextKey: headerName }`);
33
+ * a missing header leaves the key out. Anything richer, such as
34
+ * authentication, is a `transform` stage that wraps `invoke`.
35
+ * - Success is a `200` with the response as JSON (`outputValueOf`: sensitive
36
+ * values masked). Errors are `application/problem+json`, with the status of
37
+ * `statusOf` (the output `statuses` first). An unknown route is a 404
38
+ * problem, and anything thrown outside an operation a 500 one.
39
+ * - `docs.path` serves the `openapi` document of the same operations.
40
+ */
41
+ export declare const hono: import("@afora/core").SinkFactory<Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">, HonoOptions>;
42
+ /** The runtime catalog of this package: the stage name `hono` resolves to the sink. */
43
+ export declare const HonoStages: import("@afora/core").IStageCatalog<Readonly<Record<string, import("@afora/core").StageFactory>>>;
44
+ /** The catalog entries codegen reads (`--stages @afora/hono`): `{ name, module, export }`. */
45
+ export declare const stages: readonly [{
46
+ readonly name: "hono";
47
+ readonly module: "@afora/hono";
48
+ readonly export: "hono";
49
+ }];
package/dist/index.js ADDED
@@ -0,0 +1,199 @@
1
+ // src/index.ts
2
+ import {
3
+ InvalidOutputStage,
4
+ openapi,
5
+ operationRoutes,
6
+ outputValueOf,
7
+ PROBLEM_MEDIA_TYPE,
8
+ problemOf,
9
+ routeOptionsIssue,
10
+ sinkOf,
11
+ stageCatalogOf,
12
+ statusOf,
13
+ toOpenAPI
14
+ } from "@afora/core";
15
+ import { Hono } from "hono";
16
+ function isObject(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function onlyKeys(value, allowed, where) {
20
+ for (const key of Object.keys(value)) {
21
+ if (!allowed.includes(key))
22
+ return `${where} has no option "${key}".`;
23
+ }
24
+ return;
25
+ }
26
+ function validate(options) {
27
+ const given = options;
28
+ const bad = onlyKeys(given, ["basePath", "route", "docs", "debug", "context"], "hono");
29
+ if (bad)
30
+ return bad;
31
+ const routing = routeOptionsIssue(options, "hono");
32
+ if (routing)
33
+ return routing;
34
+ if (given.debug !== undefined && typeof given.debug !== "boolean") {
35
+ return "Expected hono.debug to be a boolean.";
36
+ }
37
+ const { docs, context } = given;
38
+ if (docs !== undefined) {
39
+ if (!isObject(docs))
40
+ return "Expected hono.docs to be an object.";
41
+ const docsBad = onlyKeys(docs, ["path", "info", "servers"], "hono.docs");
42
+ if (docsBad)
43
+ return docsBad;
44
+ if (typeof docs.path !== "string" || !docs.path.startsWith("/") || /[{}?#\s:*]/.test(docs.path)) {
45
+ return 'Expected hono.docs.path to be a path such as "/openapi.json".';
46
+ }
47
+ try {
48
+ openapi({
49
+ info: docs.info,
50
+ ...docs.servers ? { servers: docs.servers } : {}
51
+ });
52
+ } catch (error) {
53
+ const reason = error.details?.reason;
54
+ return (reason ?? "Expected hono.docs to be valid.").replaceAll("openapi.", "hono.docs.");
55
+ }
56
+ }
57
+ if (context !== undefined) {
58
+ if (!isObject(context))
59
+ return "Expected hono.context to be an object.";
60
+ const contextBad = onlyKeys(context, ["headers"], "hono.context");
61
+ if (contextBad)
62
+ return contextBad;
63
+ const { headers } = context;
64
+ if (headers !== undefined) {
65
+ if (!isObject(headers)) {
66
+ return "Expected hono.context.headers to be an object.";
67
+ }
68
+ for (const [key, name] of Object.entries(headers)) {
69
+ if (typeof name !== "string" || !/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name)) {
70
+ return `Expected hono.context.headers["${key}"] to be a header name.`;
71
+ }
72
+ }
73
+ }
74
+ }
75
+ return;
76
+ }
77
+ function scalarTypesOf(blueprint) {
78
+ const schema = toOpenAPI(blueprint);
79
+ const types = {};
80
+ for (const [key, property] of Object.entries(schema.properties ?? {})) {
81
+ types[key] = [property.type ?? []].flat();
82
+ }
83
+ return types;
84
+ }
85
+ function coerce(text, types) {
86
+ if (!types || types.includes("string"))
87
+ return text;
88
+ const trimmed = text.trim();
89
+ if (types.includes("boolean") && (trimmed === "true" || trimmed === "false")) {
90
+ return trimmed === "true";
91
+ }
92
+ if (types.includes("integer") || types.includes("number")) {
93
+ const number = trimmed === "" ? Number.NaN : Number(trimmed);
94
+ if (Number.isFinite(number))
95
+ return number;
96
+ }
97
+ return text;
98
+ }
99
+ function coerceAll(values, types) {
100
+ const out = {};
101
+ for (const [key, value] of Object.entries(values)) {
102
+ if (value !== undefined)
103
+ out[key] = coerce(value, types[key]);
104
+ }
105
+ return out;
106
+ }
107
+ function problemResponse(body, status) {
108
+ return new Response(JSON.stringify(body), {
109
+ status,
110
+ headers: { "content-type": PROBLEM_MEDIA_TYPE }
111
+ });
112
+ }
113
+ function requestProblem(title, detail) {
114
+ return problemResponse({ type: `urn:afora:exception:${title}`, title, status: 400, detail }, 400);
115
+ }
116
+ var hono = sinkOf({
117
+ name: "hono",
118
+ validate,
119
+ build: (operations, options, { output }) => {
120
+ const routes = operationRoutes(operations, options, "hono");
121
+ const debug = options.debug === true;
122
+ const headers = options.context?.headers ?? {};
123
+ const app = new Hono;
124
+ const fail = (error, operation) => {
125
+ const status = statusOf(error, operation, output.statuses);
126
+ return problemResponse(problemOf(error, { status, debug }), status);
127
+ };
128
+ app.notFound(() => problemResponse({
129
+ type: "urn:afora:exception:NotFound",
130
+ title: "NotFound",
131
+ status: 404,
132
+ detail: "No operation is served at this method and path."
133
+ }, 404));
134
+ app.onError((error) => fail(error));
135
+ for (const operation of operations) {
136
+ const { method, path } = routes.get(operation.id);
137
+ const hasBody = method !== "get" && method !== "delete";
138
+ const inputTypes = hasBody ? {} : scalarTypesOf(operation.input);
139
+ const contextKeys = operation.context ? Object.keys(operation.context.properties).filter((key) => Object.hasOwn(headers, key)) : [];
140
+ const contextTypes = operation.context ? scalarTypesOf(operation.context) : {};
141
+ app.on(method.toUpperCase(), path, async (c) => {
142
+ let input;
143
+ if (hasBody) {
144
+ const text = await c.req.text();
145
+ try {
146
+ input = text.trim() === "" ? {} : JSON.parse(text);
147
+ } catch {
148
+ return requestProblem("InvalidRequestBody", "The request body is not valid JSON.");
149
+ }
150
+ } else {
151
+ input = coerceAll(c.req.query(), inputTypes);
152
+ }
153
+ const call = {};
154
+ if (operation.context) {
155
+ const raw = {};
156
+ for (const key of contextKeys) {
157
+ raw[key] = c.req.header(headers[key]) ?? undefined;
158
+ }
159
+ call.context = coerceAll(raw, contextTypes);
160
+ }
161
+ try {
162
+ const response = await operation.invoke(input, call);
163
+ return c.json(outputValueOf(response) ?? null, 200);
164
+ } catch (error) {
165
+ return fail(error, operation);
166
+ }
167
+ });
168
+ }
169
+ if (options.docs) {
170
+ const { path, info, servers } = options.docs;
171
+ const taken = [...routes.values()].some((route) => route.method === "get" && route.path === path);
172
+ if (taken) {
173
+ throw new InvalidOutputStage({
174
+ source: "hono",
175
+ details: {
176
+ reason: `hono.docs.path "${path}" collides with a GET operation route.`
177
+ }
178
+ });
179
+ }
180
+ const document = openapi({
181
+ info,
182
+ ...servers ? { servers } : {},
183
+ ...options.basePath ? { basePath: options.basePath } : {},
184
+ ...options.route ? { route: options.route } : {}
185
+ }).build(operations, { output });
186
+ app.get(path, (c) => c.json(document));
187
+ }
188
+ return app;
189
+ }
190
+ });
191
+ var HonoStages = stageCatalogOf(hono);
192
+ var stages = [
193
+ { name: "hono", module: "@afora/hono", export: "hono" }
194
+ ];
195
+ export {
196
+ HonoStages,
197
+ hono,
198
+ stages
199
+ };
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
- "name": "@afora/hono",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@afora/hono",
3
+ "version": "0.0.3",
4
+ "description": "A Hono sink for afora outputs: one route per operation, problem+json errors, sensitive data masked and an optional OpenAPI document.",
5
+ "keywords": [
6
+ "afora",
7
+ "hono",
8
+ "openapi",
9
+ "problem-json",
10
+ "http",
11
+ "api"
12
+ ],
13
+ "author": "Alan Reis <alanreisanjo@gmail.com> (https://github.com/Hoyasumii)",
14
+ "license": "MIT",
15
+ "homepage": "https://github.com/afora-framework/core/tree/main/packages/hono#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/afora-framework/core.git",
19
+ "directory": "packages/hono"
20
+ },
21
+ "type": "module",
22
+ "main": "./dist/index.js",
23
+ "module": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "files": [
34
+ "dist"
35
+ ],
36
+ "engines": {
37
+ "bun": ">=1.4.4",
38
+ "node": ">=22"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "provenance": true
43
+ },
44
+ "scripts": {
45
+ "typecheck": "bun check",
46
+ "build": "rm -rf dist && bun build ./src/index.ts --outdir ./dist --target node --format esm --packages external --tsconfig-override tsconfig.build.json && tsc -p tsconfig.build.json && bun ../../scripts/add-dts-extensions.ts dist",
47
+ "test": "bun test"
48
+ },
49
+ "devDependencies": {
50
+ "@seriousme/openapi-schema-validator": "2.11.0",
51
+ "@types/bun": "^1.4.4",
52
+ "hono": "^4.13.14",
53
+ "typebox": "^1.4.3"
54
+ },
55
+ "peerDependencies": {
56
+ "@afora/core": ">=0.0.1",
57
+ "hono": "^4.13.14",
58
+ "typebox": "^1"
59
+ }
60
+ }