API 設計
API Design

Next.js App Router(OpenNext / Cloudflare Workers 上)で提供する REST API の仕様。

1. エンドポイント一覧
Endpoint List

メソッドパス認証説明
GET /api/terms 不要 用語一覧取得・検索・カテゴリ絞込
POST /api/terms 🔒 管理者 用語新規登録
PUT /api/terms/[id] 🔒 管理者 用語更新
DELETE /api/terms/[id] 🔒 管理者 用語削除(R2 画像も削除)
POST /api/terms/upload-image 🔒 管理者 説明画像アップロード → { key }
GET /api/media/[...path] 不要 R2 保存画像のプロキシ配信
POST /api/auth/login 不要 管理者ログイン(HMAC セッション Cookie 発行)
POST /api/auth/logout 不要 管理者ログアウト(Cookie クリア)
GET /api/auth/session 不要 セッション確認 → { authenticated: boolean }
🔒 管理者必須: requireAdminSession ミドルウェアで保護。未認証の場合は 401 を返す。

2. 用語一覧取得・検索
Term List Retrieval & Search

GET /api/terms 認証不要 用語の一覧取得・キーワード検索・カテゴリ絞込・ページング
Query Parameters
パラメータデフォルト説明
qstring""検索キーワード
searchBy"name" | "description""name"検索対象フィールド
category"" | Category""カテゴリ絞込(空文字=すべて)
pagenumber1ページ番号(1 始まり)
limitnumber201 ページあたりの件数(最大 100)
Request Examples
GET /api/terms?q=ec2&searchBy=name&page=1&limit=20
GET /api/terms?q=仮想&searchBy=description&category=Compute
GET /api/terms?category=Security
GET /api/terms
Responses
200 OK
{
  "terms": [
    {
      "id": 1,
      "name": "Amazon EC2",
      "abbreviation": "EC2",
      "category": "Compute",
      "description": "仮想サーバーを提供するコンピューティングサービス",
      "descriptionImageKey": "terms/original/uuid.png",
      "descriptionImageSlimKey": "terms/slim/uuid.webp",
      "descriptionImageUrl": "/api/media/terms/slim/uuid.webp",
      "createdAt": "2024-01-15T10:00:00.000Z",
      "updatedAt": "2024-01-15T10:00:00.000Z"
    }
  ],
  "pagination": {
    "total": 60,
    "page": 1,
    "limit": 20,
    "totalPages": 3
  }
}
descriptionImageUrl: slim キーがある場合は /api/media/<slimKey>、ない場合は /api/media/<originalKey>、画像なしの場合は null
400 Bad Request
{ "error": "Invalid query parameter", "details": [...] }
500 Internal Server Error
{ "error": "Internal server error" }

3. 用語新規登録
Term Registration

POST /api/terms 🔒 管理者必須 新しい用語を登録する。同名の用語は 409 を返す。
Request Body application/json
{
  "name": "Amazon EC2",
  "abbreviation": "EC2",
  "category": "Compute",
  "description": "仮想サーバーを提供するサービス",
  "descriptionImageKey": "terms/original/uuid.png"
}
フィールド必須バリデーション
namestring必須1〜200 文字、空白のみ不可
abbreviationstring | null-最大 50 文字
categoryCategory | null-CATEGORIES 定数のいずれかの値
descriptionstring必須1〜2000 文字、空白のみ不可
descriptionImageKeystring | null-terms/original/ または terms/uuid.* 形式の R2 キー
Responses
201 Created
{
  "term": {
    "id": 61,
    "name": "Amazon EC2",
    "abbreviation": "EC2",
    "category": "Compute",
    "description": "仮想サーバーを提供するサービス",
    "descriptionImageKey": "terms/original/uuid.png",
    "descriptionImageSlimKey": null,
    "descriptionImageUrl": "/api/media/terms/original/uuid.png",
    "createdAt": "2024-04-18T10:00:00.000Z",
    "updatedAt": "2024-04-18T10:00:00.000Z"
  }
}
400 Bad Request
{ "error": "Validation failed", "details": [{ "field": "name", "message": "必須項目です" }] }
401 Unauthorized
{ "error": "Unauthorized" }
409 Conflict
{ "error": "Term with this name already exists" }

4. 用語更新
Term Update

PUT /api/terms/[id] 🔒 管理者必須 指定 ID の用語を更新する。descriptionImageKeynull で送ると画像削除。
URL Parameter
パラメータ説明
idnumber更新対象の用語 ID
Request Body application/json
{
  "name": "Amazon EC2",
  "abbreviation": "EC2",
  "category": "Compute",
  "description": "更新後の説明文",
  "descriptionImageKey": "terms/original/new-uuid.png"
}
descriptionImageKey を省略した場合は既存の画像キーを維持。画像を削除する場合は null を送信する。
Responses
200 OK
{ "term": { ...updatedTerm } }
400 Bad Request
{ "error": "Invalid id" }
401 Unauthorized
{ "error": "Unauthorized" }
404 Not Found
{ "error": "Term not found" }
409 Conflict
{ "error": "Term with this name already exists" }

5. 用語削除
Term Deletion

DELETE /api/terms/[id] 🔒 管理者必須 用語を削除し、紐付く R2 画像(original / slim)も同時に削除する。
処理フロー
  1. D1 から用語を削除する
  2. descriptionImageKey または descriptionImageSlimKey が存在する場合、対応する R2 オブジェクトも削除する
Responses
200 OK
{ "message": "Term deleted successfully" }
400 Bad Request
{ "error": "Invalid id" }
401 Unauthorized
{ "error": "Unauthorized" }
404 Not Found
{ "error": "Term not found" }

6. 説明画像アップロード
Description Image Upload

POST /api/terms/upload-image 🔒 管理者必須 画像を R2 に保存し、最適化キューにメッセージを送信する。
Request Body multipart/form-data
フィールド必須バリデーション
fileFile必須JPEG / PNG / GIF / WebP、5MB 以下
処理フロー
  1. MIME タイプ・ファイルサイズのバリデーション
  2. R2 に terms/original/<uuid>.<ext> として保存
  3. IMAGE_OPTIMIZE_QUEUE にメッセージを送信(軽量版生成トリガー)
Responses
200 OK
{ "key": "terms/original/uuid.png" }
400 Bad Request
{ "error": "file が必要です" }
{ "error": "対応形式は JPEG / PNG / GIF / WebP のみです" }
{ "error": "ファイルサイズは 5MB 以下にしてください" }
401 Unauthorized
{ "error": "Unauthorized" }

7. R2 画像プロキシ配信
R2 Image Proxy Delivery

GET /api/media/[...path] 認証不要 R2 に保存された画像を Content-Type 付きでプロキシ配信する。
Request Examples
GET /api/media/terms/original/uuid.png
GET /api/media/terms/slim/uuid.webp
Responses
200 OK

画像バイナリ(Content-Type: image/png 等)

403 Forbidden
{ "error": "Forbidden" }

パストラバーサル検出(.. を含むパス)

404 Not Found
{ "error": "Not found" }

8. 管理者ログイン
Admin Login

POST /api/auth/login 認証不要 HMAC-SHA256 署名付きセッション Cookie を発行する。有効期限 7 日。
Request Body application/json
{ "email": "admin@example.com", "password": "secret" }
フィールドバリデーション
emailstring必須・空文字不可
passwordstring必須・空文字不可
処理フロー
  1. email / password が空の場合は 400 を返す
  2. ADMIN_EMAIL / ADMIN_PASSWORD 環境変数と照合(タイミング安全比較)
  3. 不一致の場合は 401 を返す
  4. 一致した場合は HMAC-SHA256 署名付きトークンを生成し admin_session Cookie にセット
Responses
200 OK
{ "ok": true }
Set-Cookie: admin_session=<token>; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800
本番環境では末尾に ; Secure が付与される。
400 Bad Request
{ "error": "Invalid request" }
401 Unauthorized
{ "error": "Unauthorized" }

9. 管理者ログアウト
Admin Logout

POST /api/auth/logout 認証不要 セッション Cookie を Max-Age=0 でクリアする。
Responses
200 OK
{ "ok": true }
Set-Cookie: admin_session=; Max-Age=0; ...(Cookie をクリア)

10. セッション確認
Session Verification

GET /api/auth/session 認証不要 Cookie を検証してログイン状態を返す。フロントエンドの初期化時に使用。
Responses
200 OK
{ "authenticated": true }
{ "authenticated": false }

11. バリデーションスキーマ(Zod)
Validation Schema (Zod)

// src/features/terms/schemas/term.ts
import { z } from "zod"
import { CATEGORIES } from "@/features/terms/constants/categories"
import { isValidDescriptionImageKey } from "@/lib/descriptionImage"

const descriptionImageKeyField = z
  .union([
    z.string().max(512).refine((s) => isValidDescriptionImageKey(s), "画像キーの形式が不正です"),
    z.null(),
  ])
  .optional()

export const termSchema = z.object({
  name:        z.string().min(1, "用語名は必須です").max(200).trim(),
  abbreviation: z.string().max(50).optional().nullable(),
  category:    z.enum(CATEGORIES).optional().nullable(),
  description: z.string().min(1, "説明は必須です").max(2000).trim(),
  descriptionImageKey: descriptionImageKeyField,
})
// src/features/terms/schemas/searchParams.ts
export const searchParamsSchema = z.object({
  q:        z.string().default(""),
  searchBy: z.enum(["name", "description"]).default("name"),
  category: z.union([z.literal(""), z.enum(CATEGORIES)]).default(""),
  page:     z.coerce.number().int().min(1).default(1),
  limit:    z.coerce.number().int().min(1).max(100).default(20),
})

12. エラーハンドリング方針
Error Handling Policy

HTTP ステータス意味使用場面
200成功GET, PUT, DELETE 成功時
201作成成功POST 成功時
400リクエスト不正バリデーションエラー・不正な ID
401未認証管理者セッションなしで保護エンドポイントにアクセス
403アクセス禁止パストラバーサル等
404リソース未存在存在しない ID / R2 キーへのアクセス
409重複同名用語の登録
500サーバーエラー予期しないエラー