@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/README.md +692 -0
- package/dist/engine.d.ts +75 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/errors.d.ts +50 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +545 -0
- package/dist/index.js.map +13 -0
- package/dist/routable.d.ts +10 -0
- package/dist/routable.d.ts.map +1 -0
- package/dist/streams.d.ts +40 -0
- package/dist/streams.d.ts.map +1 -0
- package/dist/types.d.ts +103 -0
- package/dist/types.d.ts.map +1 -0
- package/docs/architecture.md +166 -0
- package/docs/guide.md +324 -0
- package/package.json +47 -0
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
|
+
}
|