@nxgt/openapi-hono
v0.3.0
Published
Readme
@nxgt/openapi-hono
Typed Hono routes for an OpenAPI spec. This is the runtime that the
hono.ts file generated by
@nxgt/openapi-codegen binds to its spec:
- each route validates its request with the generated validators before the handler runs;
- a request the spec refuses gets a 400 that lists every issue;
- a reply the spec does not declare does not compile.
It replaces
@nxgt/openapi-codegen/hono, the subpath that held this runtime in codegen 0.1.0. Code generated by codegen 0.2.0 and later imports@nxgt/openapi-honoinstead.
Install
bun add @nxgt/openapi-hono hono zod
bun add -d @nxgt/openapi-codegen typescripthonois a required peer. This package imports it for types only; your app imports it to build the app.typescript6 is a required peer, as for every@nxgtpackage.zodis not a peer, but the app needs it: the generatedoperations.tsimports it.- The generator is only a dev dependency.
Setup
Generate with the hono option:
// openapi-codegen.config.ts
import { defineConfig } from '@nxgt/openapi-codegen';
export default defineConfig({
input: 'openapi/openapi.yaml',
output: 'src/generated',
hono: true,
});bunx nxgt-openapi generate then writes hono.ts beside the other
generated files. Import createRoutes and createApi from that file, not
from this package: there they are bound to your spec.
Usage
Registering routes
import { Hono } from 'hono';
import { createRoutes } from './generated/hono';
const app = new Hono();
const routes = createRoutes(app);
routes.put('/employees/{id}', auth, async (c) => {
const { id } = c.req.valid('param');
const employee = await employees.update(id, c.req.valid('json'));
if (!employee) return c.json({ message: 'errors.not-found' }, 404);
return c.json(employee, 200);
});
routes.operation('deleteEmployee', auth, async (c) => {
await employees.remove(c.req.valid('param').id);
return c.body(null, 204);
});- Paths are written as the spec writes them:
{id}, not:id. Each method offers only the paths that have an operation for it. c.req.valid()holdsparam,query,header, andjsonorformfor a body, already validated. A text body is validated too; read it withc.req.text().- A route runs as
[...middlewares, validator, handler], so a 401 fromauthcomes before a 400. routes.validatemarks where validation runs, for a middleware that needs validated input:routes.put(path, auth, routes.validate, owns, handler).
Validation errors
By default, a request the spec refuses gets a 400 response. Its body is
{ status, message: 'errors.validation-failed', timestamp, issues }, and
each issue is { target, path, code, message }. @nxgt/openapi-codegen
declares it, as ValidationErrorBody, on every operation that takes an
input, so a client that checks its replies reads it. To answer differently,
generate with validationErrors: false and declare your own body:
const routes = createRoutes(app, {
onValidationError: (failure, c) => {
throw new CustomException(400, 'errors.validation-failed', {
issues: failure.issues,
});
},
});The hook can:
- return a
Response, which is sent; - throw, which hands the failure to
app.onError; - return nothing, which sends the default.
routes.with({ onValidationError }) sets the hook for the routes registered
through it only.
Checking replies
const routes = createRoutes(app, {
validateResponses: process.env.NODE_ENV !== 'production',
});Every reply is checked against the spec: its status, its Content-Type,
and, for JSON and text, its body. A reply that fails goes through
onValidationError, as a 500 by default, its issues logged with
console.error rather than sent. The check reads every reply body twice,
so keep it for development and tests.
Streams
An operation whose reply the spec describes an item at a time, with OpenAPI
3.2's itemSchema, streams it from its handler with the hono.ts helpers:
import { createRoutes, streamEvents, streamLines } from './generated/hono';
createRoutes(app)
.get('/feed', (c) =>
streamEvents(c, 'watchFeed', async (stream) => {
await stream.write({ event: 'update', id: '7', data: item }); // Item, as JSON
await stream.write({ event: 'ping', data: 'still here' }); // text
while (!stream.aborted) await stream.sleep(15_000);
}),
)
.post('/export', (c) =>
streamLines(c, 'exportItems', async (stream) => {
for (const item of items) await stream.write(item);
}),
);- Each event is typed by its name, and its data is sent as JSON when the spec declares it JSON, as text otherwise. JSON lines go out as the media type the spec declares.
- With
validateResponses, each item is checked before it is sent. The status is out already, so a failing item goes toonValidationErrorfor its side effects, is not sent, and ends the stream. stream.abortedandstream.onAbort()tell when the client went away.
Modules
import { createApi } from './generated/hono';
export const api = createApi();
// employees.routes.ts
const employees = new Hono();
api
.routes(employees, { prefix: '/employees', tag: 'employees' })
.get('/employees', listEmployees)
.get('/employees/{id}', getEmployee);
// app.ts
app.route('/employees', employees);
api.assertComplete(); // throws, listing every operation without a routeprefixis where the sub-app is mounted. Only paths under it are offered.tagoffers only the operations that carry that tag.api.missing(tag?)lists the operations that have no route.api.assertComplete(tag?)throws when any operation has no route.
createRoutes(app, options) is createApi(options).routes(app, options):
a registry for that one app.
Registering a route throws at startup in these cases:
- the spec has no operation at that method and path, or no such
operationId; - the operation already has a route;
- an earlier route would always answer first;
- the operation is outside the
prefixor thetag; - the last argument is not the handler, or
routes.validateappears twice; - Hono cannot route it: a
HEADoperation (Hono answers it with theGETroute), or a path parameter that does not fill its segment (/files/{name}.json). Generating warns about both, andmissing()leaves them out.
API
The app imports from its generated hono.ts, which binds this package to
the spec. The package's own exports are what hono.ts is built from, and
the types of a failure for your own onValidationError.
Generated hono.ts
Generated with hono: true. S below is its HonoSpec.
createApi()
const createApi: (options?: ApiOptions) => Api<HonoSpec>;One registry for the whole spec: api.routes(app) in each module, then
api.assertComplete(). options are the defaults of every routes() it
makes (ApiOptions). See Modules.
createRoutes()
const createRoutes: <Prefix extends string = '', Tag extends keyof OperationsByTag & string = never>(
app: Hono<any, any, any>,
options?: RoutesOptions<Prefix, Tag>,
) => Routes<HonoSpec, ScopeOf<HonoSpec, Tag>, Prefix>;Routes on one app, with a registry of their own: createApi(options).routes(app, options).
Takes RoutesOptions and returns Routes.
See Registering routes.
streamEvents()
const streamEvents: <Id extends /* each operation that replies with server-sent events */>(
c: Context<any, any, any>,
id: Id,
write: (stream: EventWriter<Operations[Id]['stream']['item']>) => Promise<void>,
) => Replies[Id];Replies with the operation's server-sent events, each typed by the spec.
Generated only when the spec has such an operation. Behaves as the
package's streamEvents(). See Streams.
streamLines()
const streamLines: <Id extends /* each operation that replies with JSON Lines */>(
c: Context<any, any, any>,
id: Id,
write: (stream: LineWriter<Operations[Id]['stream']['item']>) => Promise<void>,
) => Replies[Id];Replies with the operation's JSON lines, each typed by the spec. Generated
only when the spec has such an operation. Behaves as the package's
streamLines().
Replies
interface Replies {
updateEmployee:
| TypedResponse<Employee, 200, 'json'>
| TypedResponse<Problem, 404, 'json'>;
deleteEmployee: TypedResponse<null, 204, 'body'>;
}What each operation may reply, keyed by operationId: a handler returning
anything else does not compile. A JSON body is typed as JSON carries it, a
reply without content as c.body(null, status) (and c.redirect() for a
3xx other than 304), a binary or streamed body as unknown. A status Hono
has no type for is typed any, and an operation that declares only
default or ranges replies Response.
HonoSpec
interface HonoSpec {
operations: Operations;
replies: Replies;
routes: OperationsByRoute;
paths: PathsByMethod;
tags: OperationsByTag;
tagPaths: PathsByTag;
}The spec, as this package reads it: the ApiSpec that every
generic below is given. The indexes come from the generated types.ts.
Functions
createApi()
function createApi<S extends ApiSpec>(
operations: OperationTable,
defaults?: ApiOptions,
): Api<S>;The engine: one registry over the operations table of operations.ts.
defaults apply to every routes() it makes, under the options given
there. Returns an Api. hono.ts calls it; an app calls the
generated createApi() instead.
validationErrorHandler()
const validationErrorHandler: (failure: ValidationFailure, c: Context) => Response;The default answer to a failure. For a request, a 400:
{ status: 400, message: 'errors.validation-failed', timestamp, issues }.
For a response, a 500 without the issues, which would show what the reply
held: { status: 500, message: 'errors.response-validation-failed', timestamp },
the issues going to console.error. Call it from your own hook to fall back
to it.
streamEvents()
function streamEvents<Event>(
c: Context,
id: string,
write: (stream: EventWriter<Event>) => Promise<void>,
): Response;Replies with server-sent events from the handler of operation id, as
text/event-stream with cache-control: no-cache, under the operation's
first 2xx status that declares them. The reply ends when write returns;
a throw inside it ends it too and goes to console.error. Throws when id
is not the route running c, or when the operation does not reply with
server-sent events. See Streams.
streamLines()
function streamLines<Item>(
c: Context,
id: string,
write: (stream: LineWriter<Item>) => Promise<void>,
): Response;Replies with JSON lines from the handler of operation id: one JSON text a
line, as the media type the spec declares (application/jsonl,
application/x-ndjson, or application/json-seq, which puts a record
separator before each). Ends and throws as streamEvents().
Objects
Api
interface Api<S extends ApiSpec> {
routes(app, options?): Routes;
missing(tag?): string[];
assertComplete(tag?): void;
}What createApi() returns: one registry, shared by every routes() it
makes. See Modules.
api.routes()
routes<Prefix extends string = '', Tag extends keyof S['tags'] & string = never>(
app: Hono<any, any, any>,
options?: RoutesOptions<Prefix, Tag>,
): Routes<S, ScopeOf<S, Tag>, Prefix>;Registers routes on app, which may be a module's sub-app. The options
override the defaults given to createApi().
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| prefix | string | none | Where app is mounted, as the spec writes it: /employees. Routes are registered relative to it, and only paths under it are offered. |
| tag | a tag of the spec | none | Offers only the operations with this tag. |
| onValidationError | ValidationErrorHook | validationErrorHandler | Answers a failure. |
| validateResponses | boolean | false | Checks every reply against the spec. |
api.missing()
missing(tag?: keyof S['tags'] & string): string[];The operationIds with no route yet, of one tag or of the whole spec. An
operation Hono cannot route is never missing.
api.assertComplete()
assertComplete(tag?: keyof S['tags'] & string): void;Throws an Error listing every operation of missing(tag), as
operationId (METHOD /path); returns when there is none. Call it once every
module is registered.
Routes
type Routes<S extends ApiSpec, Sc extends Scope = Whole<S>, Prefix extends string = ''>;What createRoutes() and api.routes() return: a method per HTTP method,
operation, validate and with. Every registration returns the same
Routes, so calls chain. See Registering routes.
routes.get(), routes.put(), routes.post(), routes.delete(), routes.options(), routes.head(), routes.patch(), routes.trace(), routes.query()
get<P extends /* a GET path of the scope, starting with the prefix */>(
path: P,
...chain: [...MiddlewareHandler[], RouteHandler<S, /* the operationId of GET P */>]
): Routes<S, Sc, Prefix>;Registers the operation at path, written as the spec writes it
(/employees/{id}), with middlewares then its handler. Its type,
Register, has one overload per chain length, up to five
middlewares, then one for longer chains, as Hono's own app.get has. With
a fixed length, the handler's c stays typed while its reply is being
written, so the editor can offer the body's fields. The handler's
c.req.valid() and replies are those of the operation. Throws at
registration in the cases listed under Modules; head() always
throws, since Hono answers HEAD with the GET route.
routes.operation()
operation<Id extends Sc['ids']>(id: Id, ...chain: Chain<S, Id>): Routes<S, Sc, Prefix>;Registers an operation by its operationId instead of its path. Throws as
the methods above. Its type, RegisterOperation, has the same overloads.
routes.validate
readonly validate: MiddlewareHandler;A marker for where the request is validated in a chain, when a middleware
needs validated input: routes.put(path, auth, routes.validate, owns, handler).
Without it, validation runs after every middleware. It throws if it is ever
run outside a routes chain.
routes.with()
with(options: ApiOptions): Routes<S, Sc, Prefix>;The same routes, on the same app and registry, with onValidationError or
validateResponses changed for the routes registered through it only.
Types
ApiOptions
| Field | Type | Description |
| --- | --- | --- |
| onValidationError? | ValidationErrorHook | Answers a request the spec refuses, or a reply it does not declare. Default validationErrorHandler. |
| validateResponses? | boolean | Checks every reply: its status, its content type and, for JSON and text, its body. For development and tests. |
Taken by createApi() and routes.with(). See
Validation errors and Checking replies.
RoutesOptions
interface RoutesOptions<Prefix extends string = '', Tag extends string = never> extends ApiOptions {
prefix?: Prefix;
tag?: Tag;
}Taken by createRoutes() and api.routes(); see the table under
api.routes().
ValidationFailure
| Field | Type | Description |
| --- | --- | --- |
| kind | 'request' \| 'response' | A request the spec refuses, or, with validateResponses, a reply it does not declare. |
| operationId | string | The operation. |
| method | string | Its method, lowercase. |
| path | string | As the spec writes it: /employees/{id}. |
| status? | number | The reply's status, for a response failure. |
| issues | ValidationIssue[] | Every issue, from every target at once. |
What onValidationError receives.
ValidationIssue
| Field | Type | Description |
| --- | --- | --- |
| target | ValidationTarget \| 'response' | Where the value was read. |
| path | (string \| number)[] | Inside the target: ['items', 0, 'name'], or [] for the whole of it. |
| code | string | Zod's issue code, or one of invalid_json, invalid_form, invalid_content_type, missing_body, repeated_parameter, undeclared_status. |
| message | string | What is wrong. |
One entry of ValidationFailure.issues, and of the default 400's issues.
ValidationTarget
type ValidationTarget = 'param' | 'query' | 'header' | 'json' | 'form' | 'body';Where a request value was read: a target of c.req.valid(), or body for
a text body, which a handler reads with c.req.text().
ValidationErrorHook
type ValidationErrorHook = (
failure: ValidationFailure,
c: Context,
) => Response | undefined | Promise<Response | undefined>;The type of onValidationError: return a Response to send it, throw to
hand the failure to app.onError, or return nothing for the default.
SchemaIssue
interface SchemaIssue {
readonly path: readonly PropertyKey[];
readonly code: string;
readonly message: string;
}What the engine reads of a Zod issue, in a Validator's
failed result.
EventWriter
| Field | Type | Description |
| --- | --- | --- |
| write(event) | (event: Event) => Promise<void> | Sends an event, { event?, data, id?, retry? } in hono.ts. Throws for an event name the spec does not declare, or an event or id holding a line break. |
| sleep(ms) | (ms: number) => Promise<void> | Resolves after ms milliseconds: a pause between two items. |
| aborted | boolean | Whether the client went away: stop writing then. |
| onAbort(listener) | (listener: () => void \| Promise<void>) => void | Runs listener when the client goes away. |
The stream that streamEvents() hands to write.
LineWriter
| Field | Type | Description |
| --- | --- | --- |
| write(item) | (item: Item) => Promise<void> | Sends an item, as a line of JSON. |
| sleep(ms), aborted, onAbort(listener) | | As on EventWriter. |
The stream that streamLines() hands to write.
RouteHandler
type RouteHandler<S extends ApiSpec, Id extends string> = (
c: DeclaredJson<Reply> & Context</* … */>, // c.req.valid() holds the operation's validated input
next: Next,
) => Reply | Promise<Reply>; // S['replies'][Id]The handler of operation Id: the last argument of a registration. Its c
is still a Context, for any helper that takes one.
DeclaredJson
interface DeclaredJson<R> {
json<Status extends /* a JSON status R declares */, Body extends Partial</* its body */>>(
body: Body,
status: Status,
headers?: Record<string, string | string[]>,
): Response & TypedResponse<JSONParsed<Body>, Status, 'json'>;
}The c.json() a handler gets on top of Hono's own. It types the body by the
status passed after it, so the editor offers the fields of the body declared
for that status, and then only those not written yet. Hono's c.json() has
nothing to offer, since it takes any T. The reply is typed as Hono types
it, so what the handler returns is checked against the spec as before. A
body that is not even part of the declared one falls through to Hono's
c.json(), and the handler's return is refused there.
Register
interface Register<S extends ApiSpec, Sc extends Scope, Prefix extends string, M extends Method>;The type of routes.get() and the other methods: see
routes.get().
RegisterOperation<S, Sc, Prefix> is routes.operation()'s.
Chain
type Chain<S extends ApiSpec, Id extends string> = [...MiddlewareHandler[], RouteHandler<S, Id>];Middlewares, then the handler: the arguments after the path or the
operationId.
Method
type Method = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace' | 'query';The HTTP methods of an operation, and the registration methods of
Routes.
ApiSpec
| Field | Type | Description |
| --- | --- | --- |
| operations | object | Operations, from types.ts, keyed by operationId. |
| replies | object | Replies, from hono.ts, keyed by operationId. |
| routes | object | OperationsByRoute: 'put /employees/{id}' to its operationId. |
| paths | { [M in Method]: string } | PathsByMethod. |
| tags | object | OperationsByTag. |
| tagPaths | object | PathsByTag. |
What a spec must provide to type routes; the generated
HonoSpec is one.
Scope
interface Scope {
ids: string;
paths: { [M in Method]: string };
}The operations a Routes offers: their operationIds, and their paths by
method.
Whole
The scope of every operation of a spec: Whole<HonoSpec>, the default of
Routes.
Tagged
The scope of the operations of one tag: Tagged<HonoSpec, 'employees'>.
ScopeOf
Whole<S> when no tag is given, else Tagged<S, Tag>; evaluated once per
routes(): ScopeOf<HonoSpec, 'employees'>.
OperationTable
type OperationTable = { readonly [operationId: string]: RuntimeOperation };The operations table that operations.ts exports, and createApi() takes.
RuntimeOperation
| Field | Type | Description |
| --- | --- | --- |
| method | Method | The operation's method. |
| path | string | As the spec writes it: /employees/{id}. |
| honoPath | string | As Hono routes it: /employees/:id. |
| tags | readonly string[] | Its tags. |
| parameters | readonly { name; in: 'path' \| 'query' \| 'header'; list: boolean; explode: boolean }[] | How each parameter is read. |
| param, query, header | Validator | The validator of each target. |
| body? | { required: boolean; content: { [mediaType]: RuntimeMedia } } | Its request body, by media type. |
| responses | { [status]: { [mediaType]: RuntimeMedia } } | Its replies, by status, then media type. |
One entry of the OperationTable.
RuntimeMedia
| Field | Type | Description |
| --- | --- | --- |
| kind | 'json' \| 'form' \| 'text' \| 'binary' \| 'sse' \| 'jsonl' | How the content is read or written. |
| schema? | Validator | Validates the whole content. |
| events? | { [event]: Validator \| null } | sse: each event's data, by name: a validator for JSON, null for text. |
| item? | Validator | jsonl: each item. |
One media type of a body or a reply in a RuntimeOperation.
Validator
interface Validator {
safeParse(value: unknown):
| { success: true; data: unknown }
| { success: false; error: { issues: readonly SchemaIssue[] } };
}What the engine asks of a validator: Zod's safeParse. Every validator of
the table is one.
Traps
- Give
c.json()a status. Without one, Hono types the reply with any contentful status, and it matches no declared reply. Without one, the editor also has no declared body to offer. - With
dates: 'date', completion stops at the firstDate. The declared body is the one JSON carries, with dates as strings. A body that holds aDatefalls through to Hono'sc.json(), which still checks it, but has no fields to offer. - Reply with plain objects. A Mongoose document does not type as its
schema; return
.lean()results. - Read the body through
c.req, neverc.req.raw. A middleware that drainsc.req.rawleaves the validator nothing to read. - Register the static path first.
/users/{id}registered before/users/mewould answer for it, so the second registration throws. securityis not enforced. Register your own auth middlewares.- Limit the body size yourself. Put Hono's
bodyLimitfirst.
Documentation
- Typed Hono routes: how a request is read, every issue code, reply checks, modules, the mistakes caught at startup, and the traps.
- The architecture: registration, validation, the route types, what they cost, and the tests. Read it before working on the runtime.
