npm.io
0.3.1 • Published 2d ago

@toad-contracts/hono

Licence
MIT
Version
0.3.1
Deps
2
Size
42 kB
Vulns
0
Weekly
0

@toad-contracts/hono

Hono adapter for @toad-contracts. Define an HTTP contract once with defineApiContract and mount it as a fully typed, self-validating Hono route. The method, path and request schemas are derived from the contract, and the handler's c.req.valid(...) data and return type are inferred from it.

Unlike a generic Hono validator, this package rolls its own validator typed directly from the contract surface, so there is no separate type provider to wire up. Validation runs against the contract's Standard Schemas, so any Standard Schema library (valibot, zod, ...) works.

Table of Contents

Requirements

hono is a peer dependency (>=4). This package is ESM-only.

Builders

buildHonoRoute

buildHonoRoute(app, contract, handler) registers a contract on a Hono app and returns the app for chaining. The HTTP method and path come from the contract; one validator is wired per declared request schema (path params, query, headers, body). The handler is typed from the contract:

  • c.req.valid('param' | 'query' | 'header' | 'json') carry the parsed, transformed request data (the 'json' target exists only for contracts with a request body).
  • the return value is constrained to the contract's responsesByStatusCode, so c.json(body, status) is checked against the declared body and status.

Path-param schemas must be wrapped with withObjectKeys from @toad-contracts/valibot (or your schema adapter's equivalent). Core needs the path-param field names to build the route path, and the Standard Schema interface does not expose object keys; the adapter supplies that capability. Query, header and body schemas need no wrapping.

import { buildHonoRoute } from "@toad-contracts/hono";
import { defineApiContract, ContractNoBody } from "@toad-contracts/core";
import { withObjectKeys } from "@toad-contracts/valibot";
import { object, string } from "valibot";
import { Hono } from "hono";

const app = new Hono();

// GET
buildHonoRoute(
  app,
  defineApiContract({
    method: "get",
    requestPathParamsSchema: withObjectKeys(object({ userId: string() })),
    requestQuerySchema: QUERY_SCHEMA,
    pathResolver: ({ userId }) => `/users/${userId}`,
    responsesByStatusCode: { 200: RESPONSE_BODY_SCHEMA },
  }),
  (c) => {
    const { userId } = c.req.valid("param");
    const query = c.req.valid("query");
    return c.json({ name: "Frodo" }, 200);
  },
);

// POST
buildHonoRoute(
  app,
  defineApiContract({
    method: "post",
    requestBodySchema: REQUEST_BODY_SCHEMA,
    pathResolver: () => "/users",
    responsesByStatusCode: { 201: RESPONSE_BODY_SCHEMA },
  }),
  (c) => {
    const body = c.req.valid("json");
    return c.json({ name: "Sam" }, 201);
  },
);

// DELETE returning no body
buildHonoRoute(
  app,
  defineApiContract({
    method: "delete",
    requestPathParamsSchema: withObjectKeys(object({ userId: string() })),
    pathResolver: ({ userId }) => `/users/${userId}`,
    responsesByStatusCode: { 204: ContractNoBody },
  }),
  (c) => c.body(null, 204),
);

The route path is derived from the contract via core's mapApiContractToPath, which reads the path-param keys through the schema's adapter-supplied object-key surface and replaces each with a :placeholder, so (p) => /users/${p.userId} becomes the Hono path /users/:userId.

buildHonoRouteHandler

Define a handler separately from the route, typed from the contract, then pass it to buildHonoRoute:

import { buildHonoRoute, buildHonoRouteHandler } from "@toad-contracts/hono";

const handler = buildHonoRouteHandler(contract, (c) => {
  // c.req.valid(...) and the return type are typed from the contract
  return c.json({ name: "Sam" }, 201);
});

buildHonoRoute(app, contract, handler);
Accessing the contract

The contract is exposed on the context, so handlers and middleware can read it:

buildHonoRoute(app, contract, (c) => {
  const apiContract = c.get("apiContract");
  return c.json({ name: "Frodo" }, 200);
});
Accessing the app's own context variables

buildHonoRoute infers the app's Env from the app instance, so a handler on a typed app keeps its own context variables typed alongside apiContract. Pass a Hono<AppEnv> and c.get('container'), c.get('user'), and similar resolve from AppEnv['Variables'] with no extra annotation:

type AppEnv = { Variables: { container: ServerContainer; user?: SessionPayload } };

const app = new Hono<AppEnv>();

buildHonoRoute(app, contract, (c) => {
  const container = c.get("container"); // typed as ServerContainer
  const user = c.get("user"); // typed as SessionPayload | undefined
  const apiContract = c.get("apiContract"); // still typed from the contract
  return c.json({ name: "Frodo" }, 200);
});

For a handler defined away from its app (no instance to infer the env from), bind the env once with honoContractRoutes<AppEnv>(). It returns buildHonoRoute and buildHonoRouteHandler bound to the env, plus requestByContract re-exported unchanged for convenience:

import { honoContractRoutes } from "@toad-contracts/hono";

const { buildHonoRoute, buildHonoRouteHandler } = honoContractRoutes<AppEnv>();

const handler = buildHonoRouteHandler(contract, (c) => {
  const container = c.get("container"); // typed as ServerContainer
  return c.json({ name: "Sam" }, 201);
});

buildHonoRoute(app, contract, handler);

Hono's Context is invariant in its env, so a standalone handler typed with the bare buildHonoRouteHandler (no env) is not assignable to a route on an app with its own env: that is a type error by design, catching env mismatches. Use the factory's buildHonoRouteHandler for such handlers. Inline handlers passed straight to the free buildHonoRoute need none of this.

Validation errors

When a request fails contract validation, the validator throws a SchemaValidationError (from @toad-contracts/core) by default. Map it in the app's onError:

import { SchemaValidationError } from "@toad-contracts/core";

app.onError((error, c) => {
  if (error instanceof SchemaValidationError) {
    return c.json({ issues: error.issues }, 400);
  }
  throw error;
});

Or handle it per route with onValidationError:

buildHonoRoute(app, contract, handler, {
  onValidationError: (error, c) => c.json({ issues: error.issues }, 400),
});

A request whose body is empty or not valid JSON (when the contract declares a request body) is treated the same way: it surfaces as a SchemaValidationError rather than escaping as an unhandled 500, so the same onError / onValidationError handling applies.

Query parameters and arrays

Repeated query keys are validated as arrays (?id=1&id=2["1", "2"]), and a single occurrence is validated as a scalar (?q=find"find"), mirroring Hono's own validator so both scalar and array query schemas work.

Two array cases cannot be represented in a query string, by HTTP and Hono convention rather than a limitation of this adapter (a browser, fetch, or requestByContract all hit the same boundary):

  • a single-element array round-trips as a scalar (?tags=x is read back as "x", not ["x"]);
  • an empty array cannot be sent at all (the key is simply absent).

If a field must accept one value, model it to accept the scalar (or absent) form, for example optional(union([array(string()), string()])). In tests that exercise an array query param via requestByContract, send two or more values.

Adding middleware from contract metadata

buildHonoRoute accepts an optional contractMetadataToRouteMapper that maps the contract metadata to extra middleware appended after the validators:

buildHonoRoute(app, contract, handler, {
  contractMetadataToRouteMapper: (metadata) => ({
    middleware: [authMiddlewareFor(metadata)],
  }),
});

Test helper

requestByContract

requestByContract(app, contract, params) dispatches a request against a Hono app from a contract using Hono's native app.request() (no server needed). Request inputs are validated and transformed through the contract's request schemas before sending. Params are typed from the contract: each field is required only when the contract declares the matching request schema.

import { requestByContract } from "@toad-contracts/hono";

const response = await requestByContract(app, createUserContract, {
  pathParams: { userId: "1" },
  body: { id: "2" },
  headers: async () => ({ authorization: "token" }), // plain object or (a)sync function
});

expect(response.status).toBe(201);

pathPrefix is always optional; when provided it is prepended to the path resolved from the contract (e.g. to hit a route mounted under a Hono base path).

Keywords