API 엔드포인트 만들기
API 라우트 두 가지 패턴 — 빠른 filling chain, 타입 안전한 contract. 한 prompt 가 둘 다 처리.
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.id는string,ctx.body.name도string - request 검증 — 잘못된 입력은 자동 400 응답
mandu contract openapi로 OpenAPI 스키마 export- 클라이언트 typed wrapper — contract 를 import 하면
client(contract)가 완전히 타입 추론됨
🤖 에이전트 프롬프트 — 두 패턴 중 하나 자동 생성
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/**안에서 rawnew Response(...)를 거부합니다.- 미들웨어는 chain 으로:
.use(withSession()),.use(withCsrf()). body 안에if (!ctx.session) ...직접 넣지 말 것. - Pattern B contract 위치 고정:
spec/contracts/<name>.contract.ts. 다른 폴더에 두면 OpenAPI 추출이 안 됩니다. - 두 패턴 섞지 말 것: filling chain 또는
Mandu.handler(contract, {...})둘 중 하나. 한 파일에 둘 다 두면 라우터가 헷갈립니다.
다음 읽을거리
- start/quickstart.ko — 프로젝트가 없다면 여기부터
- architect/guard.ko — Guard 가 라우트 파일에서 무엇을 막는가
- recipes/auth — 미들웨어 패턴 (
withSession,withCsrf) - recipes/testing — filling chain 과 contract 둘 다 테스트하는 방법
For Agents
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.
- 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