rest-api-design について
プロジェクトの規約に沿った REST エンドポイントの設計と実装を支援するスキルです。Next.js App Router を対象に、Zod による検証や OpenAPI 文書、エラー処理、レート制限、認証を扱います。
主な機能
- 01Next.js REST エンドポイントの作成
- 02Zod によるリクエスト・レスポンス定義
- 03エラーと HTTP ステータスコードの処理
- 04レート制限と認証の適用
- 05OpenAPI 文書の作成
活用シーン
- 01Next.js アプリへのエンドポイント追加
- 02リクエストパラメーターと本文の検証
- 03API エラーレスポンスの標準化
- 04OpenAPI 文書の更新
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ curiositech/ some_claude_skills --skill rest-api-designインストール後、Claude Code で/rest-api-designを入力して読み込まれたか確認します。
同梱ファイル.claude-plugin/plugin.json
詳細
REST API 設計
このスキルは、プロジェクトのパターンに従い、Zod によるバリデーションと OpenAPI ドキュメントを用いて REST API エンドポイントを設計、実装するのに役立ちます。
使用するタイミング
✅ 次の場合にこのスキルを使用してください:
- Next.js App Router で新しい REST API エンドポイントを作成する
- Zod でリクエスト/レスポンスのスキーマを設計する
- 適切なエラーハンドリングとステータスコードを実装する
- レート制限と認証を追加する
- OpenAPI ドキュメントを生成する
❌ 次の場合は使用しないでください:
- GraphQL API → まったく異なるパラダイムです
- Cloudflare Workers →
cloudflare-worker-devスキルを使用してください - Supabase Edge Functions → Supabase のドキュメントを参照してください
- WebSocket/リアルタイム API → パターンが異なります
API ルートの構造
src/app/api/
├── auth/ # Authentication endpoints
├── check-in/ # Daily check-in CRUD
├── chat/ # AI coaching chat
├── journal/ # Journal entries
├── admin/ # Admin-only endpoints
└── health/ # Health check
標準的なルートのテンプレート
// src/app/api/[feature]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getSession } from '@/lib/auth';
import { createRateLimiter } from '@/lib/rate-limit';
import { logPHIAccess } from '@/lib/hipaa/audit';
import { db } from '@/db';
// 1. Define schemas
const RequestSchema = z.object({
field: z.string().min(1).max(1000),
optional: z.string().optional(),
enumField: z.enum(['option1', 'option2']),
number: z.number().int().positive(),
});
const ResponseSchema = z.object({
id: z.string(),
createdAt: z.string().datetime(),
});
// 2. Configure rate limiter
const rateLimiter = createRateLimiter({
windowMs: 60000, // 1 minute
maxRequests: 30, // 30 requests per window
keyPrefix: 'api:feature',
});
// 3. Implement handlers
export async function GET(request: NextRequest) {
// Auth check
const session = await getSession();
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Rate limit
const rateLimitResult = await rateLimiter.check(session.userId);
if (!rateLimitResult.allowed) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429, headers: rateLimitResult.headers }
);
}
// Query data
const data = await db.query.features.findMany({
where: eq(features.userId, session.userId),
});
// Audit log (if PHI)
await logPHIAccess(session.userId, 'feature', null, 'LIST');
return NextResponse.json(data);
}
export async function POST(request: NextRequest) {
// Auth check
const session = await getSession();
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Rate limit
const rateLimitResult = await rateLimiter.check(session.userId);
if (!rateLimitResult.allowed) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429, headers: rateLimitResult.headers }
);
}
// Parse and validate body
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: 'Invalid JSON' },
{ status: 400 }
);
}
const parsed = RequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: parsed.error.issues.map(i => ({
path: i.path.join('.'),
message: i.message,
})),
},
{ status: 400 }
);
}
// Create resource
const [created] = await db.insert(features).values({
id: generateId(),
userId: session.userId,
...parsed.data,
createdAt: new Date(),
}).returning();
// Audit log
await logPHIAccess(session.userId, 'feature', created.id, 'CREATE');
return NextResponse.json(created, { status: 201 });
}
Zod スキーマのパターン
基本的な型
import { z } from 'zod';
const Schema = z.object({
// Strings
name: z.string().min(1).max(100),
email: z.string().email(),
url: z.string().url(),
uuid: z.string().uuid(),
// Numbers
count: z.number().int().positive(),
rating: z.number().min(1).max(5),
price: z.number().nonnegative(),
// Booleans
isActive: z.boolean(),
// Dates
date: z.string().datetime(),
dateOnly: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
// Enums
status: z.enum(['pending', 'approved', 'denied']),
// Arrays
tags: z.array(z.string()).min(1).max(10),
// Optional fields
notes: z.string().optional(),
metadata: z.record(z.string()).optional(),
// Nullable
deletedAt: z.string().datetime().nullable(),
});
高度なパターン
// Discriminated unions
const EventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('keypress'), key: z.string() }),
]);
// Refinements
const PasswordSchema = z.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[a-z]/, 'Must contain lowercase')
.regex(/[0-9]/, 'Must contain number')
.regex(/[^A-Za-z0-9]/, 'Must contain special character');
// Transform
const DateSchema = z.string()
.datetime()
.transform(str => new Date(str));
// Preprocess (coerce types)
const NumberFromString = z.preprocess(
val => typeof val === 'string' ? parseInt(val, 10) : val,
z.number()
);
クエリパラメーターのバリデーション
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const QuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['asc', 'desc']).default('desc'),
status: z.enum(['all', 'active', 'archived']).optional(),
});
const query = QuerySchema.safeParse({
page: searchParams.get('page'),
limit: searchParams.get('limit'),
sort: searchParams.get('sort'),
status: searchParams.get('status'),
});
if (!query.success) {
return NextResponse.json(
{ error: 'Invalid query parameters', details: query.error.issues },
{ status: 400 }
);
}
const { page, limit, sort, status } = query.data;
// Use validated params...
}
エラーレスポンスの形式
// Standard error response
interface APIError {
error: string; // Human-readable message
code?: string; // Machine-readable code
details?: ErrorDetail[]; // Validation details
}
interface ErrorDetail {
path: string;
message: string;
}
// Error responses
return NextResponse.json(
{ error: 'Not found', code: 'NOT_FOUND' },
{ status: 404 }
);
return NextResponse.json(
{
error: 'Validation failed',
code: 'VALIDATION_ERROR',
details: [
{ path: 'email', message: 'Invalid email format' },
],
},
{ status: 400 }
);
HTTP ステータスコード
| コード | 使用例 |
|---|---|
| 200 | GET、PUT、PATCH の成功 |
| 201 | POST の成功(作成済み) |
| 204 | DELETE の成功(コンテンツなし) |
| 400 | 無効なリクエスト/バリデーションエラー |
| 401 | 未認証 |
| 403 | 認可されていない(認証済みだが許可されていない) |
| 404 | リソースが見つからない |
| 409 | 競合(重複など) |
| 429 | レート制限超過 |
| 500 | サーバーエラー |
OpenAPI ドキュメント
エンドポイントを追加する際は、docs/openapi.yaml を更新してください。
paths:
/api/feature:
get:
summary: List features
tags: [Features]
security:
- cookieAuth: []
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Feature'
'401':
$ref: '#/components/responses/Unauthorized'
post:
summary: Create feature
tags: [Features]
security:
- cookieAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateFeatureRequest'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/Feature'
'400':
$ref: '#/components/responses/ValidationError'
'401':
$ref: '#/components/responses/Unauthorized'
components:
schemas:
Feature:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
createdAt:
type: string
format: date-time
required: [id, name, createdAt]
CreateFeatureRequest:
type: object
properties:
name:
type: string
minLength: 1
maxLength: 100
required: [name]
responses:
Unauthorized:
description: Not authenticated
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Unauthorized
ValidationError:
description: Validation failed
content:
application/json:
schema:
type: object
properties:
error:
type: string
details:
type: array
items:
type: object
properties:
path:
type: string
message:
type: string
ルートハンドラーのパターン
動的ルート
// src/app/api/feature/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// Validate ID format
if (!isValidUUID(id)) {
return NextResponse.json(
{ error: 'Invalid ID format' },
{ status: 400 }
);
}
const item = await db.query.features.findFirst({
where: eq(features.id, id),
});
if (!item) {
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 }
);
}
return NextResponse.json(item);
}
ページネーション
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
async function getPaginated(page: number, limit: number) {
const offset = (page - 1) * limit;
const [data, [{ count }]] = await Promise.all([
db.query.features.findMany({
limit,
offset,
orderBy: desc(features.createdAt),
}),
db.select({ count: count() }).from(features),
]);
return {
data,
pagination: {
page,
limit,
total: count,
totalPages: Math.ceil(count / limit),
},
};
}
参考資料
コメント
Markdown 対応