Koa 3 REST API with @koa/router, Zod validation and supertest: working versions (tested 2026-10-15)
A Koa 3 JSON API using @koa/router and @koa/bodyparser with Zod 4 body and param validation mapped to 400 responses by a central error middleware, tested offline with supertest.
passed5 passed · 0 failed2026-10-15 15:00 UTC- Status
- passed
- Last passed
- 2026-10-15 15:00 UTC
- Tests
- 5 / 5
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38755782594
The code
Exactly the files that passed. Your agent can pull them with get_recipe("koa-router-zod-supertest") (install), or see them on GitHub.
{
"name": "koa-router-zod-supertest",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"koa": "3.2.1",
"@koa/router": "15.7.0",
"@koa/bodyparser": "6.1.0",
"zod": "4.6.7"
},
"devDependencies": {
"supertest": "7.3.1",
"tsx": "4.23.15",
"@types/node": "26.6.5"
}
}
import Koa from "koa";
import Router from "@koa/router";
import { bodyParser } from "@koa/bodyparser";
import { z } from "zod";
export const NewTodo = z.object({
title: z.string().trim().min(1, "title is required").max(200),
done: z.boolean().default(false),
});
export type Todo = z.infer<typeof NewTodo> & { id: number };
const IdParam = z.coerce.number().int().positive();
/** Builds a Koa app with an in-memory todo store. A fresh app per test keeps state isolated. */
export function createApp() {
const todos = new Map<number, Todo>();
let nextId = 1;
const app = new Koa();
const router = new Router({ prefix: "/todos" });
// Central error handler: Zod errors become 400 with field issues, everything else 500.
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
if (err instanceof z.ZodError) {
ctx.status = 400;
ctx.body = { error: "validation_failed", issues: err.issues.map((i) => ({ path: i.path.join("."), message: i.message })) };
} else {
ctx.status = (err as { status?: number }).status ?? 500;
ctx.body = { error: (err as Error).message };
}
}
});
router.get("/", (ctx) => {
const done = ctx.query.done;
let list = [...todos.values()];
if (done === "true" || done === "false") list = list.filter((t) => t.done === (done === "true"));
ctx.body = list;
});
router.post("/", (ctx) => {
const input = NewTodo.parse(ctx.request.body);
const todo = { id: nextId++, ...input };
todos.set(todo.id, todo);
ctx.status = 201;
ctx.set("Location", `/todos/${todo.id}`);
ctx.body = todo;
});
router.patch("/:id", (ctx) => {
const id = IdParam.parse(ctx.params.id);
const todo = todos.get(id);
if (!todo) ctx.throw(404, "todo not found");
const patch = NewTodo.partial().parse(ctx.request.body);
const updated = { ...todo!, ...patch };
todos.set(id, updated);
ctx.body = updated;
});
router.delete("/:id", (ctx) => {
const id = IdParam.parse(ctx.params.id);
if (!todos.delete(id)) ctx.throw(404, "todo not found");
ctx.status = 204;
});
app.use(bodyParser());
app.use(router.routes());
app.use(router.allowedMethods());
return app;
}
import { test } from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { createApp } from "../src/app.ts";
const server = () => createApp().callback();
test("creates a todo with defaults and a Location header", async () => {
const res = await request(server()).post("/todos").send({ title: " write recipe " }).expect(201);
assert.deepEqual(res.body, { id: 1, title: "write recipe", done: false });
assert.equal(res.headers.location, "/todos/1");
});
test("rejects an invalid body with 400 and field issues", async () => {
const res = await request(server()).post("/todos").send({ title: "", done: "yes" }).expect(400);
assert.equal(res.body.error, "validation_failed");
const paths = res.body.issues.map((i: { path: string }) => i.path).sort();
assert.deepEqual(paths, ["done", "title"]);
});
test("patches, filters and deletes", async () => {
const app = server();
await request(app).post("/todos").send({ title: "a" }).expect(201);
await request(app).post("/todos").send({ title: "b" }).expect(201);
const patched = await request(app).patch("/todos/2").send({ done: true }).expect(200);
assert.equal(patched.body.done, true);
const done = await request(app).get("/todos?done=true").expect(200);
assert.deepEqual(done.body.map((t: { title: string }) => t.title), ["b"]);
await request(app).delete("/todos/1").expect(204);
const all = await request(app).get("/todos").expect(200);
assert.equal(all.body.length, 1);
});
test("returns 404 for unknown ids and 400 for bad ids", async () => {
const app = server();
await request(app).patch("/todos/99").send({ done: true }).expect(404);
await request(app).delete("/todos/abc").expect(400);
});
test("answers 405 for unsupported methods via allowedMethods", async () => {
await request(server()).put("/todos").send({}).expect(405);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Anthropic TypeScript SDK tool use loop, tested with a mocked fetch
Run a Claude tool_use loop with the official Anthropic SDK and Zod-validated tool inputs, tested offline with an injected fetch.
better-sqlite3 transactions with Zod-validated inputs
A small ledger on better-sqlite3 using db.transaction for atomic transfers, nested transactions as savepoints for all-or-nothing batches, and Zod 4 to validate inputs and rows, tested on in-memory SQLite.
Cheerio HTML scraping with Zod-validated records and saved fixtures
Scrape a product listing and a spec table with Cheerio, normalise prices and relative URLs, and validate every record with Zod 4 so broken cards are reported, tested offline against saved HTML fixtures.
Commander CLI with Zod option validation, tested in-process
A Commander subcommand CLI with variadic arguments, repeatable options, choices and Zod-backed argument parsers, made testable with exitOverride and configureOutput so node:test runs it in-process without spawning.
All koa recipesAll @koa/router recipesAll @koa/bodyparser recipesAll Zod recipesAll supertest recipes