Tips & Tricks

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

  1. It understands OpenAPI specs as context — paste a YAML file and it generates spec-compliant code instantly
  2. It deeply knows the TypeScript type system — produces type-safe code from the start, dramatically reducing late-stage type errors
  3. 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

MetricBefore Claude CodeAfter Claude Code
Schema definition time30–45 min per endpoint5–10 min (including prompt)
Type error rateFrequent in later project stagesNear zero from the start
Missed validation casesFlagged in code review every timeEdge cases covered automatically
Spec vs. code driftOutdated within 2–3 weeksAlways 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:

  1. Generate the OpenAPI spec first — it becomes the foundation for all downstream steps
  2. Generate Zod schemas from the spec — hand-writing them causes spec/code drift
  3. Explicitly request happy-path, error-path, and edge-case tests — without instruction, only happy paths get written
  4. 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:

#claude-code #rest-api #openapi #typescript #backend

Level up your Claude Code workflow

50 battle-tested prompt templates you can copy-paste into Claude Code right now.

Free

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.

Masa

About the Author

Masa

Engineer obsessed with Claude Code. Runs claudecode-lab.com, a 10-language tech media with 2,000+ pages.