0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

License System Day 11: RESTful API設計

Last updated at Posted at 2025-12-10

🎄 科学と神々株式会社 アドベントカレンダー 2025

License System Day 11: RESTful API設計


📖 今日のテーマ

昨日はシステム全体のアーキテクチャを学びました。今日は、サーバーとクライアントをつなぐRESTful APIの設計について詳しく学びます。

良いAPI設計は、システムの使いやすさと拡張性を大きく左右します。


🎯 REST とは何か?

定義

REST = Representational State Transfer

Webの設計原則に基づいた、リソース指向のアーキテクチャスタイル

簡単に言うと:

  • リソース(データ)をURLで表現
  • HTTPメソッドで操作を表現
  • ステートレス(状態を持たない)

📐 REST の 6原則

1. クライアント・サーバー分離

クライアント        サーバー
    │                │
    │   HTTP通信     │
    │◄──────────────►│
    │                │
  UI/UX            データ・ビジネスロジック

利点:

  • 独立して開発・更新できる
  • スケーリングが容易

2. ステートレス

❌ ステートフル(セッション管理):
  リクエスト1 → サーバーがセッションIDを記憶
  リクエスト2 → セッションIDから状態を復元

✅ ステートレス(JWT):
  リクエスト1 → JWTに全情報が含まれる
  リクエスト2 → JWTから直接情報を取得

利点:

  • サーバー側でセッション管理不要
  • 水平スケーリングが容易
  • 障害からの復旧が簡単

3. キャッシュ可能

GET /api/v1/license/validate
Cache-Control: max-age=300

→ 5分間キャッシュ可能

利点:

  • サーバー負荷軽減
  • レスポンス高速化

4. 統一インターフェース

同じパターンでアクセス:
  GET    /api/v1/users/:id     # 取得
  POST   /api/v1/users          # 作成
  PUT    /api/v1/users/:id     # 更新
  DELETE /api/v1/users/:id     # 削除

5. 階層化システム

クライアント
    ↓
ロードバランサー
    ↓
APIサーバー
    ↓
データベース

利点:

  • 各層を独立して変更可能
  • セキュリティレイヤーの追加が容易

6. コードオンデマンド(オプション)

// サーバーからJavaScriptコードを配信
GET /api/v1/client-code
 WASM モジュールを返す

🔗 ライセンスシステムのAPI設計

エンドポイント一覧

認証系:
  POST   /api/v1/license/activate      # ライセンス有効化
  GET    /api/v1/license/validate      # ライセンス検証
  POST   /api/v1/license/deactivate    # ライセンス無効化
  GET    /api/v1/license/status        # ライセンス状態確認

サービス系:
  POST   /api/v1/service/echo          # エコーサービス
  GET    /api/v1/service/usage         # 使用状況確認

管理系:
  GET    /api/v1/admin/stats           # 統計情報
  GET    /api/v1/admin/users           # ユーザー一覧
  POST   /api/v1/admin/users/:id/suspend # ユーザー停止

ヘルスチェック:
  GET    /health                       # サーバー状態確認

📝 API設計の詳細

1. POST /api/v1/license/activate

用途: ライセンスを有効化する

リクエスト:

POST /api/v1/license/activate HTTP/1.1
Host: localhost:3000
Content-Type: application/json

{
  "email": "user@example.com",
  "password": "password123",
  "client_id": "browser-extension-001",
  "device_fingerprint": "sha256-hash..."
}

Nim実装:

post "/api/v1/license/activate":
  try:
    let body = parseJson(request.body)
    let email = body["email"].getStr()
    let password = body["password"].getStr()

    # 認証処理
    let userOpt = db.getUserByEmail(email)
    if userOpt.isNone:
      resp Http401, %*{"error": "Invalid credentials"}

    # ライセンス発行
    let activationKey = cryptoService.generateJWT(userId, email, plan)

    # レスポンス作成と署名
    let responseData = %*{
      "status": "activated",
      "activation_key": activationKey,
      "plan_type": $planType,
      "expires_at": $expiryDate
    }

    let signature = cryptoService.signData(responseData)
    responseData["signature"] = %signature

    resp Http200, responseData
  except:
    resp Http500, %*{"error": "Internal error"}

レスポンス(成功):

{
  "status": "activated",
  "license_id": "550e8400-e29b-41d4-a716-446655440000",
  "activation_key": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...",
  "plan_type": "premium_monthly",
  "expires_at": "2026-01-20T00:00:00Z",
  "message": "License activated successfully",
  "signature": "MEUCIQDz7vkKYYjPxQw..."
}

レスポンス(エラー):

{
  "error": "Invalid credentials",
  "code": "AUTH_FAILED",
  "details": {
    "email": "user@example.com",
    "timestamp": 1699564800
  }
}

ステータスコード:

  • 200 OK: 成功
  • 400 Bad Request: リクエストが不正
  • 401 Unauthorized: 認証失敗
  • 500 Internal Server Error: サーバーエラー

2. GET /api/v1/license/validate

用途: ライセンスの有効性を確認

リクエスト:

GET /api/v1/license/validate HTTP/1.1
Host: localhost:3000
X-Activation-Key: eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...

Nim実装:

get "/api/v1/license/validate":
  try:
    let activationKey = request.headers.getOrDefault("x-activation-key")

    if activationKey == "":
      resp Http401, %*{"error": "Missing activation key"}

    # JWT検証
    let claims = cryptoService.verifyJWT(activationKey)

    # データベースから詳細情報取得
    let licenseOpt = db.getLicenseByKey(activationKey)
    if licenseOpt.isNone:
      resp Http404, %*{"error": "License not found"}

    let license = licenseOpt.get()
    let planType = parseEnum[PlanType](license[5])

    # プラン情報の取得
    let limits = getPlanLimits(planType)
    let features = getPlanFeatures(planType)

    # レスポンス作成
    let responseData = %*{
      "status": "valid",
      "premium": planType != ptFree,
      "plan_type": $planType,
      "user_id": license[4],
      "limits": limits.toJson(),
      "features": features.toJson(),
      "timestamp": now().toTime().toUnix()
    }

    # 署名
    let signature = cryptoService.signData(responseData)
    responseData["signature"] = %signature

    # 検証回数を更新
    db.updateValidationCount(license[0])

    resp Http200, responseData

  except ValueError:
    resp Http401, %*{"error": "Invalid or expired token"}

レスポンス:

{
  "status": "valid",
  "premium": true,
  "plan_type": "premium_monthly",
  "user_id": "550e8400-e29b-41d4-a716-446655440000",
  "subscription": {
    "plan": "premium_monthly",
    "expires_at": "2026-01-20T00:00:00Z",
    "validation_count": 42
  },
  "limits": {
    "max_requests_per_hour": 1000,
    "max_requests_per_day": 10000,
    "max_message_length": 10000
  },
  "features": {
    "echo": true,
    "advancedEcho": true,
    "bulkOperation": true,
    "priority": false
  },
  "timestamp": 1699564800,
  "signature": "MEUCIQDz7vkKYYjPxQw..."
}

3. POST /api/v1/service/echo

用途: エコーサービスを利用

リクエスト:

POST /api/v1/service/echo HTTP/1.1
Host: localhost:3000
Content-Type: application/json
X-Activation-Key: eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9... (optional)

{
  "message": "Hello, World!"
}

Nim実装:

post "/api/v1/service/echo":
  try:
    let activationKey = request.headers.getOrDefault("x-activation-key", "")
    var userId = "anonymous"
    var planType = ptFree

    # 認証済みの場合、ユーザー情報取得
    if activationKey != "":
      let claims = cryptoService.verifyJWT(activationKey)
      userId = claims["user_id"].getStr()
      planType = parseEnum[PlanType](claims["plan"].getStr())

    # レート制限チェック
    let limits = getPlanLimits(planType)
    if not db.checkRateLimit(userId, "echo", limits.maxRequestsPerHour):
      resp Http429, %*{
        "error": "Rate limit exceeded",
        "code": "RATE_LIMIT_EXCEEDED",
        "limit": limits.maxRequestsPerHour,
        "window": "1 hour",
        "retry_after": 3600
      }

    # リクエストパース
    let body = parseJson(request.body)
    let message = body["message"].getStr()

    # メッセージ長チェック
    if message.len > limits.maxMessageLength:
      resp Http400, %*{
        "error": "Message too long",
        "code": "MESSAGE_TOO_LONG",
        "max_length": limits.maxMessageLength,
        "actual_length": message.len
      }

    # プラン別処理
    var echoMessage = message
    if planType != ptFree:
      echoMessage = "[PREMIUM] " & message.toUpperAscii()

    # レスポンス作成
    let responseData = %*{
      "original": message,
      "echo": echoMessage,
      "length": message.len,
      "plan": $planType,
      "timestamp": $now(),
      "premium_features_used": planType != ptFree
    }

    # 署名
    let signature = cryptoService.signData(responseData)
    responseData["signature"] = %signature

    resp Http200, responseData

  except JsonParsingError:
    resp Http400, %*{"error": "Invalid JSON"}

レスポンス(Premium):

{
  "original": "Hello, World!",
  "echo": "[PREMIUM] HELLO, WORLD!",
  "length": 13,
  "plan": "premium_monthly",
  "timestamp": "2025-01-20T12:00:00Z",
  "premium_features_used": true,
  "signature": "MEUCIQDz7vkKYYjPxQw..."
}

レスポンス(Free):

{
  "original": "Hello, World!",
  "echo": "Hello, World!",
  "length": 13,
  "plan": "free",
  "timestamp": "2025-01-20T12:00:00Z",
  "premium_features_used": false,
  "signature": "MEUCIQDz7vkKYYjPxQw..."
}

🎨 HTTPメソッドの使い分け

GET - 読み取り専用

特徴:
  - べき等性: 何回実行しても同じ結果
  - キャッシュ可能
  - ブックマーク可能

用途:
  GET /api/v1/license/validate
  GET /api/v1/service/usage
  GET /api/v1/admin/stats

POST - 作成・実行

特徴:
  - べき等性なし: 実行ごとに新しいリソース作成
  - キャッシュ不可

用途:
  POST /api/v1/license/activate     # 新しいライセンス作成
  POST /api/v1/service/echo         # サービス実行

PUT - 更新(置換)

特徴:
  - べき等性あり: 何回実行しても同じ状態
  - リソース全体を置換

用途:
  PUT /api/v1/users/:id
  {
    "email": "new@example.com",
    "plan": "premium"
  }

PATCH - 部分更新

特徴:
  - 一部のフィールドのみ更新

用途:
  PATCH /api/v1/users/:id
  {
    "plan": "premium"  # planのみ更新
  }

DELETE - 削除

特徴:
  - べき等性あり

用途:
  DELETE /api/v1/license/:id

📊 ステータスコードの選び方

2xx - 成功

200 OK              # 成功(レスポンスボディあり)
201 Created         # リソース作成成功
202 Accepted        # リクエスト受理(非同期処理中)
204 No Content      # 成功(レスポンスボディなし)

使用例:

# 200 OK
get "/api/v1/license/validate":
  resp Http200, responseData

# 201 Created
post "/api/v1/users":
  # ユーザー作成
  resp Http201, %*{
    "user_id": userId,
    "created_at": $now()
  }

# 204 No Content
delete "/api/v1/license/:id":
  # ライセンス削除
  resp Http204

4xx - クライアントエラー

400 Bad Request         # リクエストが不正
401 Unauthorized        # 認証が必要
403 Forbidden           # 権限がない
404 Not Found           # リソースが存在しない
409 Conflict            # リソースの競合
429 Too Many Requests   # レート制限超過

使用例:

# 400 Bad Request
if message.len == 0:
  resp Http400, %*{"error": "Message is required"}

# 401 Unauthorized
if activationKey == "":
  resp Http401, %*{"error": "Authentication required"}

# 403 Forbidden
if not hasPermission:
  resp Http403, %*{"error": "Insufficient permissions"}

# 429 Too Many Requests
if not db.checkRateLimit(userId, endpoint, limit):
  resp Http429, %*{
    "error": "Rate limit exceeded",
    "retry_after": 3600
  }

5xx - サーバーエラー

500 Internal Server Error   # サーバー内部エラー
502 Bad Gateway            # ゲートウェイエラー
503 Service Unavailable    # サービス利用不可

🔒 セキュリティヘッダー

Nim実装での設定

routes:
  # すべてのレスポンスに共通ヘッダーを追加
  before:
    # CORS設定
    resp.setHeader("Access-Control-Allow-Origin", "*")
    resp.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
    resp.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Activation-Key")

    # セキュリティヘッダー
    resp.setHeader("X-Content-Type-Options", "nosniff")
    resp.setHeader("X-Frame-Options", "DENY")
    resp.setHeader("X-XSS-Protection", "1; mode=block")
    resp.setHeader("Strict-Transport-Security", "max-age=31536000")

    # キャッシュ制御
    resp.setHeader("Cache-Control", "no-store, no-cache, must-revalidate")

🌟 まとめ

今日学んだこと:

  1. RESTの6原則

    • クライアント・サーバー分離
    • ステートレス
    • キャッシュ可能
    • 統一インターフェース
    • 階層化システム
    • コードオンデマンド
  2. API設計

    • リソース指向のURL設計
    • HTTPメソッドの適切な使い分け
    • ステータスコードの選択
  3. Nim実装

    • Jesterフレームワークでのルーティング
    • エラーハンドリング
    • レスポンス署名
  4. セキュリティ

    • 適切なステータスコード
    • セキュリティヘッダー
    • レート制限

💡 次回予告

Day 12: データベーススキーマ設計

  • SQLite vs PostgreSQL
  • 正規化の原則
  • インデックス戦略
  • Nimでのデータベース操作

お楽しみに!


前回: Day 10: クライアント・サーバーアーキテクチャ
次回: Day 12: データベーススキーマ設計

Happy Learning! 🎉

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?