🎄 科学と神々株式会社 アドベントカレンダー 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")
🌟 まとめ
今日学んだこと:
-
RESTの6原則
- クライアント・サーバー分離
- ステートレス
- キャッシュ可能
- 統一インターフェース
- 階層化システム
- コードオンデマンド
-
API設計
- リソース指向のURL設計
- HTTPメソッドの適切な使い分け
- ステータスコードの選択
-
Nim実装
- Jesterフレームワークでのルーティング
- エラーハンドリング
- レスポンス署名
-
セキュリティ
- 適切なステータスコード
- セキュリティヘッダー
- レート制限
💡 次回予告
Day 12: データベーススキーマ設計
- SQLite vs PostgreSQL
- 正規化の原則
- インデックス戦略
- Nimでのデータベース操作
お楽しみに!
前回: Day 10: クライアント・サーバーアーキテクチャ
次回: Day 12: データベーススキーマ設計
Happy Learning! 🎉