LangENKO

API 엔드포인트 만들기

API 라우트 두 가지 패턴 — 빠른 filling chain, 타입 안전한 contract. 한 prompt 가 둘 다 처리.

since v0.22
On this page

API 엔드포인트 만들기

TL;DR — Mandu API 패턴 두 가지: 빠른 filling chain 과 타입 안전한 contract + handler. 둘 다 app/api/<route>/route.ts 에 위치. 프로토타입은 filling, 실제 운영은 contract.

Next.js / Express 에서 같은 일을 하려면 30줄 이 필요합니다 — zod 스키마, CSRF 체크, rate limit, try/catch, status code 분기, 타입 단언. 매번 손으로 동기화. 에이전트가 "auth 추가해" 라고 하면 어디 어디 손대야 할지 잘 모릅니다.

Mandu 의 라우트는 chain 형태라 에이전트가 본문 안 건드리고 .use(withCsrf()), .guard(...), 또 다른 .post(...) 등을 자연스럽게 확장할 수 있어요. 형상이 중요한 API 는 contract 한 파일이 타입과 OpenAPI 를 자동 파생합니다.

패턴 A · Filling chain (빠르게)

// app/api/users/route.ts
import { Mandu } from "@mandujs/core";
import { db } from "@/server/db";

export default Mandu.filling()
  .guard((ctx) => {
    if (!ctx.get("user")) return ctx.unauthorized("Login required");
  })
  .get(async (ctx) => {
    const users = await db.users.findMany();
    return ctx.ok({ data: users });
  })
  .post(async (ctx) => {
    const body = await ctx.body<{ name: string }>();
    const user = await db.users.create({ data: body });
    return ctx.created({ data: user });
  });

체인 요소:

  • .use(middleware) — 가장 먼저 실행. .use(withSession()), .use(withCsrf()) 등.
  • .guard(fn) — early return 으로 chain 중단 (return ctx.unauthorized(...), ctx.forbidden(...))
  • .get(fn) / .post(fn) / .put(fn) / .patch(fn) / .delete(fn) — HTTP 메서드 핸들러
  • 응답 헬퍼: ctx.ok(), ctx.created(), ctx.unauthorized(), ctx.redirect(url, 302)

패턴 B · Contract + handler (타입 안전)

// spec/contracts/user.contract.ts
import { Mandu } from "@mandujs/core";
import { z } from "zod";

export const userContract = Mandu.contract({
  description: "Users API",
  tags: ["users"],
  request: {
    GET:  { query: z.object({ id: z.string() }) },
    POST: { body:  z.object({ name: z.string() }) },
  },
  response: {
    200: z.object({ data: z.any() }),
    400: z.object({ error: z.string() }),
  },
});
// app/api/users/route.ts
import { Mandu } from "@mandujs/core";
import { userContract } from "@/spec/contracts/user.contract";
import { db } from "@/server/db";

export default Mandu.handler(userContract, {
  GET:  async (ctx) => ({ data: await db.users.findUnique({ where: { id: ctx.query.id } }) }),
  POST: async (ctx) => ({ data: await db.users.create({ data: ctx.body }) }),
});

Contract 한 번만 정의하면 Mandu 가 자동으로:

  • TypeScript 타입 추론 — ctx.query.idstring, ctx.body.namestring
  • request 검증 — 잘못된 입력은 자동 400 응답
  • mandu contract openapi 로 OpenAPI 스키마 export
  • 클라이언트 typed wrapper — contract 를 import 하면 client(contract) 가 완전히 타입 추론됨

🤖 에이전트 프롬프트 — 두 패턴 중 하나 자동 생성

🤖 Agent Prompt — Add an API endpoint
Add a Mandu API route at `app/api/<NAME>/route.ts` for the resource
`<NAME>` (e.g. users, posts, signup).

Pick the pattern based on what I need:

- Pattern A (filling chain) for quick / prototype endpoints:
    export default Mandu.filling()
      .use(withSession())            // optional middleware
      .guard((ctx) => { ... })       // optional auth short-circuit
      .get(async (ctx) => ctx.ok({ ... }))
      .post(async (ctx) => {
        const body = await ctx.body<{ ... }>();
        return ctx.created({ ... });
      });

- Pattern B (contract + handler) for type-safe + OpenAPI:
    // spec/contracts/<NAME>.contract.ts
    export const <name>Contract = Mandu.contract({
      request:  { GET: { query: z.object({...}) },
                  POST: { body: z.object({...}) } },
      response: { 200: z.object({...}),
                  400: z.object({ error: z.string() }) },
    });

    // app/api/<NAME>/route.ts
    export default Mandu.handler(<name>Contract, {
      GET:  async (ctx) => ({ ... }),
      POST: async (ctx) => ({ ... }),
    });

Required invariants:
- File path: app/api/<NAME>/route.ts (default export only)
- Use ctx helpers (ctx.body<T>, ctx.query, ctx.ok, ctx.created,
  ctx.unauthorized, ctx.redirect) — never construct a raw Response.
- Middleware chains with .use(...); never inline auth/CSRF in the body.
- Contract path: spec/contracts/<NAME>.contract.ts (named export
  ending in `Contract`).

After writing the files, run `bun run guard` and `bun run check`
and report any violations.

이 prompt 한 번이면 Claude Code · Cursor · OpenAI Codex · GitHub Copilot · Gemini CLI 등 AI 에이전트가 패턴을 고르고, 파일을 짜고, Guard 까지 돌립니다. <NAME> 만 바꿔서 재사용하세요.

함정 — 놓치면 안 되는 invariants

  • Default export 만 인식: app/api/X/route.ts 의 default export 가 라우트. named export 는 무시됩니다.
  • Response 직접 만들지 말 것: 항상 ctx.ok(), ctx.created(), ctx.unauthorized(), ctx.redirect() 사용. Mandu Guard 가 app/api/** 안에서 raw new Response(...) 를 거부합니다.
  • 미들웨어는 chain 으로: .use(withSession()), .use(withCsrf()). body 안에 if (!ctx.session) ... 직접 넣지 말 것.
  • Pattern B contract 위치 고정: spec/contracts/<name>.contract.ts. 다른 폴더에 두면 OpenAPI 추출이 안 됩니다.
  • 두 패턴 섞지 말 것: filling chain 또는 Mandu.handler(contract, {...}) 둘 중 하나. 한 파일에 둘 다 두면 라우터가 헷갈립니다.

다음 읽을거리

For Agents

AI hint

For quick endpoints, use `Mandu.filling().get(...).post(...)` chain on a default export at `app/api/<route>/route.ts`. For type-safe contracts, define `Mandu.contract({ request: { GET, POST }, response: { 200, 400 } })` and pair it with `Mandu.handler(contract, { GET, POST })`. Use `ctx.body<T>()`, `ctx.query`, `ctx.ok()`, `ctx.created()`, `ctx.unauthorized()` helpers — never read the raw Request directly.

Invariants
  • Handler files live at `app/api/<route>/route.ts` and export a `default` (file-system routing)
  • Filling chain shape — `Mandu.filling().use(...).guard(...).get(...).post(...)`; never instantiate `new Mandu()` or write a raw `Request` handler
  • Contract shape — `Mandu.contract({ request: { GET, POST, ... }, response: { 200, 400, ... } })` with zod schemas keyed by HTTP method / status code
  • Use `ctx.body<T>()`, `ctx.query`, and the response helpers (`ctx.ok`, `ctx.created`, `ctx.unauthorized`, `ctx.redirect`) — Mandu Guard rejects raw `Response` construction in API handlers
  • Middleware runs before handlers — chain with `.use(withSession())`, not inside the body
Guard scope
api-route