@nxgt/openapi-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/docs/guide.md ADDED
@@ -0,0 +1,324 @@
1
+ # Typed Hono routes
2
+
3
+ With the `hono` option, [`@nxgt/openapi-codegen`](https://github.com/softistx/nxgt-http/blob/develop/packages/openapi-codegen/README.md)
4
+ also writes `hono.ts`: the routes of the spec, typed for a Hono app and
5
+ validated with the generated validators. `@nxgt/openapi-hono` is the runtime
6
+ it binds to the spec.
7
+
8
+ - A handler gets its parameters and body already validated.
9
+ - A request the spec refuses is answered with a 400 that lists every issue.
10
+ - A handler that returns a reply the spec does not declare does not compile.
11
+
12
+ ## Setup
13
+
14
+ `hono.ts` imports `@nxgt/openapi-hono` at runtime and `hono`'s types,
15
+ `operations.ts` imports `zod`, and your app builds its own `Hono`, so all
16
+ three are dependencies of your app.
17
+ The generator stays a dev dependency:
18
+
19
+ ```sh
20
+ bun add @nxgt/openapi-hono hono zod
21
+ bun add -d @nxgt/openapi-codegen
22
+ ```
23
+
24
+ ```ts
25
+ // openapi-codegen.config.ts
26
+ import { defineConfig } from '@nxgt/openapi-codegen';
27
+
28
+ export default defineConfig({
29
+ input: 'openapi/openapi.yaml',
30
+ output: 'src/generated',
31
+ hono: true,
32
+ });
33
+ ```
34
+
35
+ ## Register routes
36
+
37
+ ```ts
38
+ import { Hono } from 'hono';
39
+ import { createRoutes } from './generated/hono.js';
40
+
41
+ const app = new Hono();
42
+ const routes = createRoutes(app);
43
+
44
+ routes.put('/employees/{id}', auth, async (c) => {
45
+ const { id } = c.req.valid('param');
46
+ const employee = await employees.update(id, c.req.valid('json'));
47
+ if (!employee) return c.json({ message: 'errors.not-found' }, 404);
48
+ return c.json(employee, 200);
49
+ });
50
+
51
+ routes.operation('deleteEmployee', auth, async (c) => {
52
+ await employees.remove(c.req.valid('param').id);
53
+ return c.body(null, 204);
54
+ });
55
+ ```
56
+
57
+ - **Paths are written as the spec writes them**, `{id}` and not `:id`.
58
+ Each method offers only the paths that have an operation for it.
59
+ - **`routes.operation(id, …)`** registers an operation by its `operationId`.
60
+ - **Middlewares come first, then the handler.**
61
+ - **`c.req.valid()`** holds what was validated: `param`, `query`, `header`,
62
+ and `json` or `form` for a body. Numbers are numbers and defaults are
63
+ filled in. Header names are lowercased. A text body is validated too, but
64
+ Hono has no target for it: read it with `c.req.text()`.
65
+ - **The handler returns one of the declared replies:**
66
+
67
+ | Declared | Return |
68
+ | --- | --- |
69
+ | a JSON body | `c.json(body, status)` |
70
+ | a text body | `c.text(text, status)` |
71
+ | no content | `c.body(null, status)`; for a 3xx other than 304, also `c.redirect(url, status)` |
72
+ | a binary body | `c.body(data, status, headers)` |
73
+
74
+ ## What happens to a request
75
+
76
+ A route runs as `[...middlewares, validator, handler]`, so `auth` answers
77
+ 401 before the body is even read.
78
+
79
+ 1. **The validator reads every target**, then answers:
80
+ - **Path, query and headers** are read as text. A query list is every
81
+ value of a repeated key (`?ids=1&ids=2`), or one value split on commas
82
+ with `explode: false`. Any other query key sent twice is refused, since
83
+ the handler would see one value and `c.req.queries()` another. A header
84
+ list is split on commas.
85
+ - **The body** is read by its `Content-Type`: the media type itself, then
86
+ `type/*`, then `*/*`. A required body sent empty is missing.
87
+ - JSON is parsed and validated.
88
+ - A form (`multipart/form-data`, `application/x-www-form-urlencoded`)
89
+ whose schema is a [flat object](https://github.com/softistx/nxgt-http/blob/develop/packages/openapi-codegen/docs/guide/schema-mapping.md#form-bodies) has
90
+ each field read from text, and a field sent once is taken as a list
91
+ of one where the schema expects a list.
92
+ - Text is validated as a string.
93
+ - Binary content passes through unread, for the handler to stream. It
94
+ is missing only when its `Content-Length` is 0.
95
+ 2. **Hono caches the body**, so a middleware that read it and the handler
96
+ can both read it again.
97
+ 3. **Every issue of every target** goes into one failure: one 400, not the
98
+ first mistake only.
99
+
100
+ When a middleware needs validated input, `routes.validate` marks where
101
+ validation runs:
102
+
103
+ ```ts
104
+ // 401 from auth, then 400 from validation, then 403 from ownership
105
+ routes.put('/employees/{id}', auth, routes.validate, ownsEmployee, handler);
106
+ ```
107
+
108
+ ## Validation errors
109
+
110
+ By default, a refused request gets:
111
+
112
+ ```json
113
+ {
114
+ "status": 400,
115
+ "message": "errors.validation-failed",
116
+ "timestamp": "2024-05-01T10:00:00.000Z",
117
+ "issues": [
118
+ {
119
+ "target": "json",
120
+ "path": ["email"],
121
+ "code": "invalid_format",
122
+ "message": "Invalid email address"
123
+ }
124
+ ]
125
+ }
126
+ ```
127
+
128
+ - **`target`** is `param`, `query`, `header`, `json`, `form`, or `body` for
129
+ a text body.
130
+ - **`code`** is Zod's issue code, or one of these:
131
+
132
+ | Code | When |
133
+ | --- | --- |
134
+ | `invalid_json` | the body is not JSON |
135
+ | `invalid_form` | the body is not the form its `Content-Type` says: a bad multipart boundary… |
136
+ | `invalid_content_type` | no declared media type matches, or a body came without a `Content-Type` |
137
+ | `missing_body` | a required body is missing, or empty |
138
+ | `repeated_parameter` | a query key that takes one value was sent more than once |
139
+
140
+ To answer differently, pass `onValidationError`:
141
+
142
+ ```ts
143
+ const routes = createRoutes(app, {
144
+ onValidationError: (failure, c) => {
145
+ throw new CustomException(400, 'errors.validation-failed', {
146
+ issues: failure.issues,
147
+ });
148
+ },
149
+ });
150
+ ```
151
+
152
+ The hook can do three things:
153
+ - **return a `Response`** to send it;
154
+ - **throw**, to hand the failure to `app.onError`, which is where an app
155
+ translates its messages;
156
+ - **return nothing**, to send the default.
157
+
158
+ `routes.with({ onValidationError })` sets it for the routes registered
159
+ through it, and leaves the others alone.
160
+
161
+ ## Checking replies
162
+
163
+ ```ts
164
+ const routes = createRoutes(app, {
165
+ validateResponses: process.env.NODE_ENV !== 'production',
166
+ });
167
+ ```
168
+
169
+ With `validateResponses`, every reply is checked against the spec:
170
+ - its status must be declared, or the issue is `undeclared_status`;
171
+ - its `Content-Type` must be one of the status's media types, when the
172
+ status declares any. What `c.json()` sends, `application/json`, stands
173
+ for a declared `+json` type such as `application/problem+json`; what
174
+ `c.text()` sends, `text/plain`, for any declared text type;
175
+ - a JSON or text body is validated, except a stream: `text/event-stream`
176
+ and the JSON line formats are never read, since they may never end.
177
+ `streamEvents()` and `streamLines()` check each item instead, as it is
178
+ written ([Streams](#streams)).
179
+
180
+ A reply that fails goes through `onValidationError` as a `response`
181
+ failure. By default it is a 500 with no `issues`, since they would show the
182
+ client what the reply held; they go to `console.error` instead. Checking
183
+ reads every reply body twice: keep it for development and tests.
184
+
185
+ Only exact statuses are declared. `default` and `4XX` responses are dropped
186
+ with an `ignored` warning, so an operation that declares only those has its
187
+ replies typed `Response`: any reply compiles. And with `validateResponses`,
188
+ every reply it sends fails as `undeclared_status`. Give each operation its
189
+ exact statuses.
190
+
191
+ ## Streams
192
+
193
+ A reply of server-sent events or JSON Lines that the spec describes an item
194
+ at a time, with OpenAPI 3.2's `itemSchema`, is written from the handler with
195
+ the helpers `hono.ts` exports when the spec has such an operation:
196
+
197
+ ```ts
198
+ import { createRoutes, streamEvents, streamLines } from './generated/hono.js';
199
+
200
+ createRoutes(app, { validateResponses: true })
201
+ .get('/feed', (c) =>
202
+ streamEvents(c, 'watchFeed', async (stream) => {
203
+ stream.onAbort(() => unsubscribe());
204
+ await stream.write({ event: 'update', id: '7', data: item });
205
+ await stream.write({ event: 'ping', data: 'still here' });
206
+ }),
207
+ )
208
+ .post('/export', (c) =>
209
+ streamLines(c, 'exportItems', async (stream) => {
210
+ for await (const item of cursor) {
211
+ if (stream.aborted) break;
212
+ await stream.write(item);
213
+ }
214
+ }),
215
+ );
216
+ ```
217
+
218
+ `streamEvents(c, id, write)`:
219
+ - takes only an operation whose 2xx reply is `text/event-stream`, and types
220
+ each event by its name: `{ event, data, id?, retry? }`;
221
+ - sends the data as JSON when the spec declares it JSON
222
+ (`contentMediaType: application/json`), and as text otherwise, a `data:`
223
+ line per line;
224
+ - throws for an event name the spec does not declare. With no `itemSchema`,
225
+ any event goes, its data as text, `event` optional.
226
+
227
+ `streamLines(c, id, write)` sends each item as a line of JSON, as the media
228
+ type the spec declares: `application/jsonl`, `application/x-ndjson`, or
229
+ `application/json-seq`, which puts a record separator before each one.
230
+
231
+ Both reply with the status the stream is declared under, and end when
232
+ `write` returns. The writer's `sleep(ms)` pauses between items. `aborted`
233
+ and `onAbort()` tell when the client went away.
234
+
235
+ With `validateResponses`, each item is checked before it is sent. The
236
+ status went out with the first one, so a failing item cannot turn into a
237
+ 500. Instead it is not sent, `onValidationError` receives the `response`
238
+ failure for its side effects (a log, a metric), and the stream ends with
239
+ an error logged by `console.error`. A throw inside `write` ends the stream
240
+ the same way.
241
+
242
+ A handler may only stream its own operation's reply: `streamEvents(c, id)`
243
+ throws when `id` is not the running route's.
244
+
245
+ ## Modules
246
+
247
+ `createApi()` holds one registry for the whole spec. Each module registers
248
+ its routes on its own sub-app:
249
+
250
+ ```ts
251
+ import { createApi } from './generated/hono.js';
252
+
253
+ export const api = createApi();
254
+
255
+ // employees.routes.ts
256
+ const employees = new Hono();
257
+ api
258
+ .routes(employees, { prefix: '/employees', tag: 'employees' })
259
+ .get('/employees', listEmployees)
260
+ .get('/employees/{id}', getEmployee);
261
+
262
+ // app.ts
263
+ app.route('/employees', employees);
264
+ api.assertComplete(); // throws, listing every operation without a route
265
+ ```
266
+
267
+ - **`prefix`** is where the sub-app is mounted, as the spec writes it.
268
+ Routes are registered relative to it. Only paths under it are offered,
269
+ and one outside it fails at startup.
270
+ - **`tag`** offers only the operations with that tag, by path and by
271
+ `operationId`.
272
+ - **`api.missing(tag?)`** lists the operations without a route, of one tag
273
+ or of all. **`api.assertComplete(tag?)`** throws when there is one.
274
+
275
+ `createRoutes(app, options)` is `createApi(options).routes(app, options)`:
276
+ a registry for that app alone.
277
+
278
+ ## Mistakes caught at startup
279
+
280
+ Registering a route throws when:
281
+ - **the operation already has a route**;
282
+ - **an earlier route would always answer first.** Hono tries routes in the
283
+ order they were registered, so `/users/{id}` registered before
284
+ `/users/me` answers for it. Register the static path first;
285
+ - **the operation is outside the `prefix` or the `tag`**;
286
+ - **the last argument is not the handler**, or `routes.validate` appears
287
+ twice;
288
+ - **Hono cannot serve the operation.** Hono answers `HEAD` with the `GET`
289
+ route of the same path, so a `head` handler would never run: register the
290
+ `GET`. And a path parameter must fill its whole segment, so
291
+ `/files/{name}.json` and `/v1/{name}:cancel` cannot be routed: serve them
292
+ with `app.on()`. Generating with `hono` warns about both (`ignored`), and
293
+ `missing()` leaves them out.
294
+
295
+ ## Traps
296
+
297
+ - **With `dates: 'date'`, a handler gets `Date`s and can reply with them.**
298
+ `c.req.valid()` holds decoded dates. `c.json()` sends a `Date` as its
299
+ ISO string, and `Replies` types replies as JSON carries them (`Wire<T>`),
300
+ so both a `Date` and its string compile.
301
+ - **Give `c.json()` a status.** Without one, Hono types the reply with any
302
+ contentful status, and it matches no declared reply.
303
+ - **Reply with plain objects.** A Mongoose document is not the JSON it
304
+ serializes to, and its type does not match the schema's. Return `.lean()`
305
+ results, or `toJSON()`.
306
+ - **A body declared as both JSON and a form** types `c.req.valid('json')`
307
+ and `c.req.valid('form')` as both present. Only the one the request sent
308
+ is filled in.
309
+ - **Keep form schemas flat.** Only a form whose schema is a
310
+ [flat object](https://github.com/softistx/nxgt-http/blob/develop/packages/openapi-codegen/docs/guide/schema-mapping.md#form-bodies) is read from text. Any other
311
+ form is validated by its own `z<Name>`, so `copies=2` arrives as the
312
+ string `'2'` and fails an integer.
313
+ - **`security` is not enforced.** The spec's security requirements are not
314
+ turned into middlewares; register your own.
315
+ - **Limit the body size yourself.** The validator reads a JSON, form or text
316
+ body whole, and the spec's `maxLength` applies only once it is read. Put
317
+ Hono's `bodyLimit` middleware first.
318
+ - **Read the body through `c.req`, never `c.req.raw`.** Hono caches what
319
+ `c.req.json()`, `c.req.text()` and `c.req.arrayBuffer()` read, so the
320
+ validator can read it again; a middleware that drains `c.req.raw` leaves
321
+ it nothing, and the request fails with an error that says so.
322
+ - **A `pattern` runs on what the client sends.** A pattern with nested
323
+ repetition, such as `^(a+)+$`, can take seconds on a crafted value. Give
324
+ such strings a `maxLength`, which Zod checks first.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@nxgt/openapi-hono",
3
+ "version": "0.1.0",
4
+ "license": "UNLICENSED",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "docs",
11
+ "README.md",
12
+ "package.json"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/softistx/nxgt-http.git",
25
+ "directory": "packages/openapi-hono"
26
+ },
27
+ "publishConfig": {
28
+ "registry": "https://registry.npmjs.org",
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "build": "bun run ../../build.ts",
33
+ "fixtures": "bun run test/generate.ts",
34
+ "test": "bun run fixtures && bun test src",
35
+ "typecheck": "bun run fixtures && tsc --noEmit"
36
+ },
37
+ "devDependencies": {
38
+ "@nxgt/openapi-codegen": "^0.1.0",
39
+ "@types/bun": "^1.4.0",
40
+ "hono": "^4.13.4",
41
+ "zod": "^4.5.4"
42
+ },
43
+ "peerDependencies": {
44
+ "hono": "^4.13.4",
45
+ "typescript": "^6.0.3"
46
+ }
47
+ }