mcpField
R
rest-api-design

rest-api-design

github.com/curiositech開発263199 回インストール今日更新
リポジトリ

rest-api-design について

プロジェクトの規約に沿った REST エンドポイントの設計と実装を支援するスキルです。Next.js App Router を対象に、Zod による検証や OpenAPI 文書、エラー処理、レート制限、認証を扱います。

主な機能

  1. 01Next.js REST エンドポイントの作成
  2. 02Zod によるリクエスト・レスポンス定義
  3. 03エラーと HTTP ステータスコードの処理
  4. 04レート制限と認証の適用
  5. 05OpenAPI 文書の作成

活用シーン

  1. 01Next.js アプリへのエンドポイント追加
  2. 02リクエストパラメーターと本文の検証
  3. 03API エラーレスポンスの標準化
  4. 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 ステータスコード

コード使用例
200GET、PUT、PATCH の成功
201POST の成功(作成済み)
204DELETE の成功(コンテンツなし)
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),
    },
  };
}

参考資料

コメント