Server-Side HTTP with Hono
Learning Objectives
- You can define Hono routes and construct deliberate HTTP responses.
- You can distinguish request input, middleware, and resource behavior.
- You can test application requests without starting a network listener.
The browser sends HTTP requests. Now we implement the other side. A server receives a request, decides which behavior applies, and constructs a response. Hono organizes that work; HTTP semantics still belong to the application design.
A small request contract
Application API endpoints start with /api, such as /api/rooms. Browser page routes and operational health checks such as /health have separate paths. The same API paths are used in direct tests and through the running server.
The practice application retains api/src/app.ts and api/src/app-run.ts from the walking skeleton. Create api/src/routes/ in the practice application (practice/). Then, create api/src/routes/room-routes.ts:
import { Hono } from "hono";
export const app = new Hono();
const rooms = [ { id: 1, name: "Quiet room", capacity: 4 }, { id: 2, name: "Workshop room", capacity: 12 },];
app.get("/api/rooms", (c) => c.json(rooms));
app.get("/api/rooms/:id", (c) => { const id = c.req.param("id"); const room = rooms.find((candidate) => String(candidate.id) === id); if (!room) { return c.json({ error: { code: "NOT_FOUND", message: "Room not found" } }, 404); }
return c.json(room);});The above creates a route module that contains and exports a Hono application with the routes for one concern; its handlers perform the controller responsibility. Each app.get registers a handler for a
path. Importing the module makes the application available without starting a
server listener.
The path parameter :id arrives as a string. Here we compare it with each room’s ID
written as a string. A matching room produces 200; a lookup without a match
produces 404.
Chapter 3.3 adds a schema that distinguishes malformed identifiers from valid identifiers for missing rooms.
c.json constructs a JSON response. The number passed after the body sets its status. The function returns an HTTP representation. See Hono routing and request access.
Middleware wraps a request path
Add this middleware before the app.get registrations in room-routes.ts:
app.use("*", async (c, next) => { console.log(`--> ${c.req.method} ${c.req.path}`); await next(); console.log(`<-- ${c.req.method} ${c.req.path} ${c.res.status}`);});The middleware registers before the routes. The '*' pattern makes it apply to every request path in this application. It reads the HTTP method from c.req.method and the path from c.req.path and logs them before calling await next().
For a GET /api/rooms/2 request, the middleware produces these log messages:
--> GET /api/rooms/2<-- GET /api/rooms/2 200The first line is written before the route handler runs. At await next(), processing continues to the matching handler, which finds the workshop room and constructs a JSON response. Once that handling finishes, execution resumes after await next(). The second log statement can then read the response status from c.res.status. This is how middleware wraps the handler: it performs work both before and after it.
For a GET /api/rooms/999 request, the same middleware produces:
--> GET /api/rooms/999<-- GET /api/rooms/999 404The handler returns a 404 because the room does not exist, and the middleware logs that outcome too. The logging behavior is shared across the routes without adding log statements to each handler. Figure 1 follows the successful request through the middleware and handler; notice where execution resumes after await next().
These messages appear in the server’s logs, not in the browser’s console or the JSON response. When you use app.request() in the tests below, they appear in the test runner’s output instead; the middleware runs even without a network listener.
Middleware can also stop the chain by returning a response instead of calling next(). See Hono’s middleware guide for more on execution order.
Hono has logger middleware, which one would normally use. The example here is intended for educational purposes.
Trace middleware completion
0 / 5 points
Consider this Hono application:
// ...app.use("*", async (c, next) => { console.log("A in"); await next(); console.log("A out");});
app.use("*", async (c, next) => { console.log("B in"); await next(); console.log("B out");});
app.get("/api/greeting", (c) => { console.log("handler"); return c.json({ greeting: "Hello" });});Assume GET /api/greeting is the only request made to the application. In what order are the five messages logged?
Respond to an unmatched path
A missing room and an unrecognized route both produce 404 responses. Add a fallback so an unrecognized path also receives a JSON error:
app.notFound((c) => c.json({ error: { code: "NOT_FOUND", message: "Route not found" }, }, 404));Serve an equipment catalogue
0 / 20 points
Files in the editor
Edit api/src/routes/equipment-routes.ts in the editor below.
This exercise provides one editable file.
Task
Complete api/src/routes/equipment-routes.ts, exporting the Hono instance as app.
Use the fixed items array supplied in the starter. Each item has code,
name, and available. Keep these records unchanged and do not open a
listening port or a database connection.
The starter already supplies middleware that rejects invalid filter strings and
malformed codes. Keep that middleware; implementing its parsing is not part of
this task. Hono’s c.req.query("available") returns the query string (e.g. ?available=true => "true") or
undefined; c.req.param("code") returns the path string. Use these values to
select records. The behavior below includes the supplied guards so the entire
endpoint contract is visible here.
Requirements
GET /api/equipmentreturns 200 with a bare JSON array of all supplied items in their original order.?available=trueselects available items;falseselects unavailable items. An omitted filter selects both. Other filter strings return 400 witherror.code: "INVALID_FILTER".GET /api/equipment/:codeaccepts exactly two uppercase letters followed by two digits. Invalid syntax returns 400INVALID_CODE; a valid absent code returns 404NOT_FOUND; a present code returns the bare item object (no envelope) with 200.- An unknown path returns 404
NOT_FOUND.
All responses above are JSON. Errors use { error: { code, message } } with a
nonempty message. Filtering must not mutate or shrink the source collection.
Filtered results must preserve the original order. Later list and detail
requests must still have access to the complete catalogue.
Check and submit
Choose Submit solution below the editor to run the grading checks and read
their feedback. The editor submits api/src/routes/equipment-routes.ts.
This editor has no Run control; you can submit for grading without setting
up a local application.
The grader supplies Hono and uses direct
app.request() calls, awarding 5 points each for list/filter behavior, detail
lookup, error responses, and preserving the catalogue across requests.
Test HTTP behavior without opening a port
Create api/tests/room-routes_test.ts:
import { assertEquals } from "@std/assert";import { app } from "@src/routes/room-routes.ts";
Deno.test("room detail uses the path ID", async () => { const response = await app.request("/api/rooms/2"); assertEquals(response.status, 200); assertEquals((await response.json()).name, "Workshop room"); assertEquals(response.headers.get("content-type")?.includes("application/json"), true);});
Deno.test("a missing room returns 404", async () => { const response = await app.request("/api/rooms/999"); assertEquals(response.status, 404); assertEquals((await response.json()).error.code, "NOT_FOUND");});
Deno.test("an unmatched path returns a JSON error", async () => { const response = await app.request("/missing"); assertEquals(response.status, 404); assertEquals((await response.json()).error.code, "NOT_FOUND");});The tests use the exported app directly. Its room catalogue is read-only, and the logging middleware keeps no mutable application state, so there is no instance state to reconstruct for each test.
These are application-request tests. No TCP listener or browser is involved, and this particular app has no database. Hono documents direct application testing.
From the root of the practice application (practice/), run the API suite with compose.yaml
and compose.test.yaml, using a distinct Compose test project name such as
wsd-practice-test. The
supplied Compose service starts a database dependency for the entire API suite
even though the tests do not use it.
Run these request tests alone from practice/ without starting database dependencies:
docker compose run --build --rm --no-deps api deno test --cached-only tests/room-routes_test.tsTo see the same requests in the running API, add this import to api/src/app.ts and the registration after its existing middleware. Keep the health and todo routes:
import { app as roomsApp } from "@src/routes/room-routes.ts";
app.route("/", roomsApp);app.route mounts the example’s registered routes. The example already includes /api, so mounting at / keeps /api/rooms; mounting at /api would duplicate that prefix. The main app owns the fallback for unmatched paths in the assembled application.
Start the practice services from practice/:
docker compose up --buildIn a second terminal in that same directory, send a request and inspect the logs:
curl -i http://localhost:8000/api/rooms/2docker compose logs --tail=20 apiExpect status 200, the workshop room’s JSON, and the two middleware log lines. Ctrl+C followed by docker compose down stops development without deleting its database volume.
Choose the missing test
0 / 5 points
A test imports the catalogue app, requests /api/equipment/AB12, and checks its status and JSON. It passes. The browser still cannot read the API from its configured origin. Which next check addresses evidence missing from this test?
Keep values with the request
Middleware can also attach a value for handlers processing the same request. Create api/src/routes/guide-routes.ts:
import { Hono } from "hono";
type GuideEnv = { Variables: { language: "en" | "fi"; };};
export const app = new Hono<GuideEnv>();
app.use("*", async (c, next) => { const language = c.req.header("X-Guide-Language") === "fi" ? "fi" : "en"; c.set("language", language); await next();});
app.get("/api/greeting", (c) => { const greeting = c.get("language") === "fi" ? "Hei" : "Hello"; return c.json({ greeting });});Hono<GuideEnv> supplies a TypeScript type argument. Hono’s Variables property describes values stored with c.set and read with c.get. It lets the checker reject an unknown key or an invalid language. The type does not create a value: the middleware must still call c.set at runtime before the handler reads it.
c.set and c.get connect middleware and a handler within one request. A global
variable would give unrelated requests a shared mutable value.
A header is client-provided input. It can express a preference, but a caller can choose its value. Establishing identity requires a trusted source.
Create api/tests/guide-routes_test.ts:
import { assertEquals } from "@std/assert";import { app } from "@src/routes/guide-routes.ts";
Deno.test("language belongs to the current request", async () => { const finnish = await app.request("/api/greeting", { headers: { "X-Guide-Language": "fi" }, }); assertEquals(await finnish.json(), { greeting: "Hei" });
const defaultLanguage = await app.request("/api/greeting"); assertEquals(await defaultLanguage.json(), { greeting: "Hello" });});The second request has no language header; it must not inherit the first request’s preference. Run the file from practice/:
docker compose run --build --rm --no-deps api deno test --cached-only tests/guide-routes_test.tsThis separate app is tested directly; it need not be mounted in the running API.
Check Your Understanding
- Why is creating an app different from starting a server listener?
- What type of value does a path parameter supply to the handler?
- What does middleware’s
await next()coordinate? - What does
app.request()avoid testing? - Why must one request’s language preference not become a global variable?