Blazing-Fast REST API Design, Implementation & Testing with Claude Code | From OpenAPI Spec to Production
Learn how to develop REST APIs end-to-end with Claude Code — from OpenAPI spec generation to production-ready TypeScript code. Covers Hono/Express/Fastify, zod validation, and vitest test generation with working code examples.
Why Claude Code Excels at REST API Design
REST API development is full of repetitive patterns: endpoint definitions, validation, error handling, and tests — all written by hand. While grinding through this boilerplate, it’s hard to stay focused on the actual business logic that matters.
I’m Masa, a DX engineer working on internal system API modernization projects. Before adopting Claude Code, my standard per-endpoint cost was “30 min design → 2 hrs implementation → 1 hr testing.” After fully integrating Claude Code, that dropped to 10 min design → 30 min implementation → 15 min testing.
3 Reasons Claude Code Excels at API Development
- It understands OpenAPI specs as context — paste a YAML file and it generates spec-compliant code instantly
- It deeply knows the TypeScript type system — produces type-safe code from the start, dramatically reducing late-stage type errors
- It knows a vast range of test patterns — generates happy-path, error-path, and edge-case tests in one shot
1. Auto-Generate an OpenAPI Spec with Claude Code
How to Write the Prompt
Here is the actual prompt I use to generate an OpenAPI spec for a “Task Management API” in a business system:
Generate an OpenAPI 3.1 specification (YAML) for the following requirements.
Resource: Task (task management)
Fields:
- id: UUID
- title: string (1–200 chars)
- description: string (optional)
- status: "todo" | "in_progress" | "done"
- priority: "low" | "medium" | "high" (default: medium)
- assignee_id: UUID (optional)
- due_date: ISO8601 format (optional)
- created_at: ISO8601 format
- updated_at: ISO8601 format
Endpoints:
- GET /tasks (list with pagination)
- GET /tasks/:id
- POST /tasks
- PUT /tasks/:id (full update)
- PATCH /tasks/:id (partial update)
- DELETE /tasks/:id
Response format: { data, meta, errors }
Error codes: 400, 401, 403, 404, 422, 500
Auth: Bearer Token (Authorization header)
Generated OpenAPI YAML (excerpt)
openapi: 3.1.0
info:
title: Task Management API
version: 1.0.0
description: OpenAPI specification for Task Management API
servers:
- url: https://api.example.com/v1
description: Production
- url: http://localhost:3000/v1
description: Development
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Task:
type: object
required: [id, title, status, priority, created_at, updated_at]
properties:
id:
type: string
format: uuid
title:
type: string
minLength: 1
maxLength: 200
description:
type: string
nullable: true
status:
type: string
enum: [todo, in_progress, done]
priority:
type: string
enum: [low, medium, high]
default: medium
assignee_id:
type: string
format: uuid
nullable: true
due_date:
type: string
format: date-time
nullable: true
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
TaskCreate:
type: object
required: [title]
properties:
title:
type: string
minLength: 1
maxLength: 200
description:
type: string
status:
type: string
enum: [todo, in_progress, done]
default: todo
priority:
type: string
enum: [low, medium, high]
default: medium
assignee_id:
type: string
format: uuid
due_date:
type: string
format: date-time
ApiResponse:
type: object
properties:
data:
type: object
meta:
type: object
properties:
total:
type: integer
page:
type: integer
per_page:
type: integer
errors:
type: array
items:
type: object
properties:
code:
type: string
message:
type: string
field:
type: string
paths:
/tasks:
get:
summary: List tasks
security:
- bearerAuth: []
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: per_page
in: query
schema:
type: integer
default: 20
maximum: 100
- name: status
in: query
schema:
type: string
enum: [todo, in_progress, done]
responses:
'200':
description: Success
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Task'
This spec becomes the single source of truth for all subsequent steps: implementation, testing, and documentation.
2. Type-Safe Handler Implementation with Hono
Once the OpenAPI YAML is ready, tell Claude Code: “Implement this using Hono.” My current first choice for new projects is Hono — it supports Edge Runtimes, is type-safe, and has a tiny bundle size.
Project Setup
// src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json";
import { taskRouter } from "./routes/tasks";
const app = new Hono();
// Middleware
app.use("*", cors());
app.use("*", logger());
app.use("*", prettyJSON());
// Router registration
app.route("/v1/tasks", taskRouter);
// Global error handler
app.onError((err, c) => {
console.error(err);
return c.json(
{
data: null,
meta: null,
errors: [{ code: "INTERNAL_ERROR", message: "An internal server error occurred" }],
},
500
);
});
export default app;
Task Router Implementation
// src/routes/tasks.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { TaskCreateSchema, TaskUpdateSchema, TaskPatchSchema, TaskQuerySchema } from "../schemas/task";
import { TaskService } from "../services/taskService";
export const taskRouter = new Hono();
const taskService = new TaskService();
// List tasks
taskRouter.get("/", zValidator("query", TaskQuerySchema), async (c) => {
const query = c.req.valid("query");
const result = await taskService.findAll(query);
return c.json({
data: result.tasks,
meta: {
total: result.total,
page: query.page,
per_page: query.per_page,
},
errors: null,
});
});
// Get task by ID
taskRouter.get("/:id", async (c) => {
const id = c.req.param("id");
const task = await taskService.findById(id);
if (!task) {
return c.json(
{
data: null,
meta: null,
errors: [{ code: "NOT_FOUND", message: "Task not found", field: "id" }],
},
404
);
}
return c.json({ data: task, meta: null, errors: null });
});
// Create task
taskRouter.post("/", zValidator("json", TaskCreateSchema), async (c) => {
const body = c.req.valid("json");
const task = await taskService.create(body);
return c.json({ data: task, meta: null, errors: null }, 201);
});
// Full update
taskRouter.put("/:id", zValidator("json", TaskUpdateSchema), async (c) => {
const id = c.req.param("id");
const body = c.req.valid("json");
const task = await taskService.update(id, body);
if (!task) {
return c.json(
{
data: null,
meta: null,
errors: [{ code: "NOT_FOUND", message: "Task not found", field: "id" }],
},
404
);
}
return c.json({ data: task, meta: null, errors: null });
});
// Partial update
taskRouter.patch("/:id", zValidator("json", TaskPatchSchema), async (c) => {
const id = c.req.param("id");
const body = c.req.valid("json");
const task = await taskService.patch(id, body);
if (!task) {
return c.json(
{
data: null,
meta: null,
errors: [{ code: "NOT_FOUND", message: "Task not found", field: "id" }],
},
404
);
}
return c.json({ data: task, meta: null, errors: null });
});
// Delete task
taskRouter.delete("/:id", async (c) => {
const id = c.req.param("id");
const deleted = await taskService.delete(id);
if (!deleted) {
return c.json(
{
data: null,
meta: null,
errors: [{ code: "NOT_FOUND", message: "Task not found", field: "id" }],
},
404
);
}
return c.json({ data: null, meta: null, errors: null }, 204);
});
3. Auto-Generate Zod Validation Schemas
Tell Claude Code: “Generate Zod schemas from the schemas section of this OpenAPI spec.” Type-safe validation is ready to go.
// src/schemas/task.ts
import { z } from "zod";
// Status and priority enums
const TaskStatus = z.enum(["todo", "in_progress", "done"]);
const TaskPriority = z.enum(["low", "medium", "high"]);
// Full task schema (returned from DB)
export const TaskSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1, "Title must be at least 1 character").max(200, "Title must be 200 characters or less"),
description: z.string().nullable(),
status: TaskStatus,
priority: TaskPriority,
assignee_id: z.string().uuid().nullable(),
due_date: z.string().datetime().nullable(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
});
// Create request
export const TaskCreateSchema = z.object({
title: z.string().min(1, "Title must be at least 1 character").max(200, "Title must be 200 characters or less"),
description: z.string().optional(),
status: TaskStatus.default("todo"),
priority: TaskPriority.default("medium"),
assignee_id: z.string().uuid().optional(),
due_date: z.string().datetime().optional(),
});
// Full update request (all fields required)
export const TaskUpdateSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().nullable(),
status: TaskStatus,
priority: TaskPriority,
assignee_id: z.string().uuid().nullable(),
due_date: z.string().datetime().nullable(),
});
// Partial update request (all fields optional)
export const TaskPatchSchema = TaskUpdateSchema.partial();
// Query parameters
export const TaskQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
per_page: z.coerce.number().int().min(1).max(100).default(20),
status: TaskStatus.optional(),
priority: TaskPriority.optional(),
assignee_id: z.string().uuid().optional(),
});
// TypeScript type exports
export type Task = z.infer<typeof TaskSchema>;
export type TaskCreate = z.infer<typeof TaskCreateSchema>;
export type TaskUpdate = z.infer<typeof TaskUpdateSchema>;
export type TaskPatch = z.infer<typeof TaskPatchSchema>;
export type TaskQuery = z.infer<typeof TaskQuerySchema>;
Before/After Comparison
| Metric | Before Claude Code | After Claude Code |
|---|---|---|
| Schema definition time | 30–45 min per endpoint | 5–10 min (including prompt) |
| Type error rate | Frequent in later project stages | Near zero from the start |
| Missed validation cases | Flagged in code review every time | Edge cases covered automatically |
| Spec vs. code drift | Outdated within 2–3 weeks | Always in sync (generated from spec) |
4. Auto-Generate Tests with Vitest
Prompt Example
Write tests for the tasks router above using vitest + supertest.
Must include:
- Happy path: list, get by ID, create, update, delete
- Error path: non-existent ID, validation errors (per field)
- Auth errors: missing token, invalid token
- Edge cases: pagination boundaries, max field length
Generated Test Code (excerpt)
// src/routes/tasks.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import app from "../index";
import { TaskService } from "../services/taskService";
// Mock TaskService
vi.mock("../services/taskService");
const mockTask = {
id: "550e8400-e29b-41d4-a716-446655440000",
title: "Test task",
description: "A task for testing",
status: "todo" as const,
priority: "medium" as const,
assignee_id: null,
due_date: null,
created_at: "2026-04-25T00:00:00.000Z",
updated_at: "2026-04-25T00:00:00.000Z",
};
describe("GET /v1/tasks", () => {
it("happy path: returns task list", async () => {
vi.mocked(TaskService.prototype.findAll).mockResolvedValue({
tasks: [mockTask],
total: 1,
});
const res = await app.request("/v1/tasks", {
headers: { Authorization: "Bearer valid-token" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toHaveLength(1);
expect(body.meta.total).toBe(1);
expect(body.errors).toBeNull();
});
it("error path: per_page exceeds maximum", async () => {
const res = await app.request("/v1/tasks?per_page=101", {
headers: { Authorization: "Bearer valid-token" },
});
expect(res.status).toBe(400);
});
});
describe("POST /v1/tasks", () => {
it("happy path: creates a task", async () => {
vi.mocked(TaskService.prototype.create).mockResolvedValue(mockTask);
const res = await app.request("/v1/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer valid-token",
},
body: JSON.stringify({ title: "New task", priority: "high" }),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data.id).toBeDefined();
});
it("error path: empty title", async () => {
const res = await app.request("/v1/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer valid-token",
},
body: JSON.stringify({ title: "" }),
});
expect(res.status).toBe(400);
});
it("error path: title exceeds 200 chars", async () => {
const res = await app.request("/v1/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer valid-token",
},
body: JSON.stringify({ title: "a".repeat(201) }),
});
expect(res.status).toBe(400);
});
});
describe("DELETE /v1/tasks/:id", () => {
it("happy path: deletes a task", async () => {
vi.mocked(TaskService.prototype.delete).mockResolvedValue(true);
const res = await app.request(`/v1/tasks/${mockTask.id}`, {
method: "DELETE",
headers: { Authorization: "Bearer valid-token" },
});
expect(res.status).toBe(204);
});
it("error path: non-existent ID", async () => {
vi.mocked(TaskService.prototype.delete).mockResolvedValue(false);
const res = await app.request("/v1/tasks/non-existent-id", {
method: "DELETE",
headers: { Authorization: "Bearer valid-token" },
});
expect(res.status).toBe(404);
const body = await res.json();
expect(body.errors[0].code).toBe("NOT_FOUND");
});
});
5. Error Handling Patterns
A reusable error handling utility I had Claude Code generate for production use:
// src/lib/errors.ts
export class AppError extends Error {
constructor(
public readonly code: string,
public readonly message: string,
public readonly statusCode: number,
public readonly field?: string
) {
super(message);
this.name = "AppError";
}
}
// Pre-defined error factories for common cases
export const Errors = {
notFound: (resource: string) =>
new AppError("NOT_FOUND", `${resource} not found`, 404),
unauthorized: () =>
new AppError("UNAUTHORIZED", "Authentication required", 401),
forbidden: () =>
new AppError("FORBIDDEN", "You do not have permission to access this resource", 403),
conflict: (message: string) =>
new AppError("CONFLICT", message, 409),
unprocessable: (message: string, field?: string) =>
new AppError("UNPROCESSABLE", message, 422, field),
internal: () =>
new AppError("INTERNAL_ERROR", "An internal server error occurred", 500),
};
// Error response formatter
export function createErrorResponse(err: unknown) {
if (err instanceof AppError) {
return {
data: null,
meta: null,
errors: [
{
code: err.code,
message: err.message,
...(err.field ? { field: err.field } : {}),
},
],
};
}
// Convert ZodError to per-field errors
if (err instanceof Error && err.name === "ZodError") {
const zodErr = err as any;
return {
data: null,
meta: null,
errors: zodErr.errors.map((e: any) => ({
code: "VALIDATION_ERROR",
message: e.message,
field: e.path.join("."),
})),
};
}
return {
data: null,
meta: null,
errors: [{ code: "INTERNAL_ERROR", message: "An unexpected error occurred" }],
};
}
6. Auto-Generate Documentation with Swagger UI
// src/swagger.ts
import { swaggerUI } from "@hono/swagger-ui";
import { OpenAPIHono } from "@hono/zod-openapi";
import fs from "node:fs";
import path from "node:path";
import YAML from "yaml";
// Load the OpenAPI YAML generated by Claude Code
const openApiSpec = YAML.parse(
fs.readFileSync(path.resolve("./openapi.yaml"), "utf8")
);
export function setupSwagger(app: OpenAPIHono) {
// Swagger UI at /docs
app.get("/docs", swaggerUI({ url: "/openapi.json" }));
// OpenAPI JSON endpoint
app.get("/openapi.json", (c) => c.json(openApiSpec));
console.log("Swagger UI: http://localhost:3000/docs");
}
In production, either disable /docs when NODE_ENV === "production" or protect it with Basic Auth.
7. Masa’s Battle-Tested Workflow Patterns
Pattern 1: “Spec-Driven Prompting”
Save the OpenAPI YAML to .claude/commands/api-impl.md and reference it every time.
# api-impl command
Implement the following according to openapi.yaml:
$ARGUMENTS
Constraints:
- Use Hono + TypeScript
- Validation with zod
- Always return { data, meta, errors } format
- Use Errors factory from src/lib/errors.ts
- vitest tests covering happy path, error path, and edge cases
Usage:
claude /api-impl "Add POST /tasks endpoint"
Pattern 2: Enforcing PUT vs PATCH
A common bug in my team was using PATCH when PUT was intended. I include this prompt in every session:
Is this endpoint a full update (PUT) or a partial update (PATCH)?
For PUT: reset omitted fields to null or their defaults.
For PATCH: leave undefined fields unchanged.
Pattern 3: N+1 Query Detection
Check the following service code for N+1 query problems.
If found, fix using DataLoader or a JOIN query.
3 Common Pitfalls
Pitfall 1: Missing UUID Validation
Claude Code sometimes treats the :id path parameter as a plain string. Without UUID format validation, malformed queries can reach the database.
// BAD: raw string sent directly to DB
const task = await db.findById(c.req.param("id"));
// GOOD: validate UUID format first
const idSchema = z.string().uuid("Not a valid UUID");
const parsed = idSchema.safeParse(c.req.param("id"));
if (!parsed.success) {
return c.json({ data: null, meta: null, errors: [{ code: "INVALID_ID", message: "Invalid ID format" }] }, 400);
}
const task = await db.findById(parsed.data);
Pitfall 2: Missing Content-Type Header Validation
For PUT/POST requests without Content-Type: application/json, Hono treats the body as an empty object. The zod validation passes and empty data gets saved.
// src/middleware/contentType.ts
import { createMiddleware } from "hono/factory";
export const requireJson = createMiddleware(async (c, next) => {
if (["POST", "PUT", "PATCH"].includes(c.req.method)) {
const contentType = c.req.header("Content-Type");
if (!contentType?.includes("application/json")) {
return c.json(
{ data: null, meta: null, errors: [{ code: "UNSUPPORTED_MEDIA_TYPE", message: "Content-Type: application/json is required" }] },
415
);
}
}
await next();
});
Pitfall 3: Timezone Handling for Date Fields
z.string().datetime() only accepts UTC format (trailing Z or +00:00). If a frontend sends 2026-04-25T09:00:00+09:00, it gets rejected.
// BAD: rejects timezone-offset datetime strings
due_date: z.string().datetime()
// GOOD: allow timezone offset
due_date: z.string().datetime({ offset: true }).optional()
Summary
Integrating Claude Code into REST API development dramatically reduces time-to-production. Key takeaways:
- Generate the OpenAPI spec first — it becomes the foundation for all downstream steps
- Generate Zod schemas from the spec — hand-writing them causes spec/code drift
- Explicitly request happy-path, error-path, and edge-case tests — without instruction, only happy paths get written
- Bake pitfalls into your prompts upfront — UUID validation, Content-Type checks, timezone handling
Since adopting this workflow, per-endpoint development time dropped by ~70% in my projects.
That said, never ship Claude Code output without review. Security concerns (SQL injection, input sanitization) and business logic (pricing, inventory management) always need human eyes. Claude Code is a fast first-draft machine — final quality responsibility rests with the developer.
In practice, a team member with two years of backend experience went from an OpenAPI spec to a working implementation in half a day using this workflow. Give it a try.
Related articles:
Level up your Claude Code workflow
50 battle-tested prompt templates you can copy-paste into Claude Code right now.
Free PDF: Claude Code Cheatsheet in 5 Minutes
Just enter your email and we'll send you the single-page A4 cheatsheet right away.
We handle your data with care and never send spam.
About the Author
Masa
Engineer obsessed with Claude Code. Runs claudecode-lab.com, a 10-language tech media with 2,000+ pages.
Related Posts
Complete Beginner's Guide to Claude Code 2026 | 7 Steps from Zero to Production-Ready
A complete beginner's guide for first-time Claude Code users. From installation to integrating it into your real development workflow — covering every pitfall Masa ran into when starting out.
Building a REST API with Claude Code | A Practical Beginner's Guide
Learn REST API fundamentals with Claude Code. A hands-on guide covering endpoint design, validation, and error handling — all with copy-paste ready code.
Claude Code vs Gemini CLI 2026 Deep Comparison | How Does Google's AI Stack Up?
A hands-on comparison of Claude Code and Gemini CLI by DX engineer Masa. Covers pricing, autonomy, context window, and ecosystem differences. Includes a decision flowchart to help you choose.