ProvenPin Install

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
pinned versions7
koa@3.2.1@koa/router@15.7.0@koa/bodyparser@6.1.0zod@4.6.7supertest@7.3.1tsx@4.23.15@types/node@26.6.5
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
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.

package.json
{
  "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"
  }
}
src/app.ts
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;
}
test/app.test.ts
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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.