ffid-python-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ffid_sdk/client.py ADDED
@@ -0,0 +1,394 @@
1
+ """
2
+ FFID API Client
3
+
4
+ FFID プラットフォームとの全API通信を担当するクライアント。
5
+ TypeScript SDK の ``createFFIDClient`` に対応するサーバーサイド版。
6
+
7
+ ブラウザの Cookie 認証ではなく、Bearer Token 認証を使用。
8
+
9
+ Example:
10
+ ```python
11
+ from ffid_sdk import FFIDClient, FFIDConfig
12
+
13
+ client = FFIDClient(FFIDConfig(service_code="chatbot"))
14
+
15
+ # セッション検証
16
+ response = await client.get_session(token="eyJ...")
17
+ if response.is_success:
18
+ print(response.data.user.email)
19
+ ```
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import logging
26
+ from typing import Any, Dict, Optional
27
+
28
+ import httpx
29
+
30
+ from ffid_sdk.constants import (
31
+ AUTHORIZATION_HEADER,
32
+ BEARER_PREFIX,
33
+ DEFAULT_API_BASE_URL,
34
+ DEFAULT_MAX_RETRIES,
35
+ DEFAULT_RETRY_BACKOFF_FACTOR,
36
+ DEFAULT_TIMEOUT_SECONDS,
37
+ NO_CONTENT_STATUS,
38
+ REFRESH_ENDPOINT,
39
+ SDK_LOGGER_NAME,
40
+ SESSION_ENDPOINT,
41
+ SIGNOUT_ENDPOINT,
42
+ SUBSCRIPTIONS_CHECK_ENDPOINT,
43
+ )
44
+ from ffid_sdk.errors import FFIDErrorCode, FFIDNetworkError, FFIDSDKError
45
+ from ffid_sdk.types import (
46
+ FFIDApiResponse,
47
+ FFIDConfig,
48
+ FFIDError,
49
+ FFIDSessionResponse,
50
+ FFIDSubscription,
51
+ FFIDTokenResponse,
52
+ )
53
+
54
+ logger = logging.getLogger(SDK_LOGGER_NAME)
55
+
56
+ # デバッグログでマスクするセンシティブなキー(snake_case / camelCase)
57
+ _SENSITIVE_KEYS = frozenset({
58
+ "access_token", "refresh_token", "accessToken", "refreshToken",
59
+ "authorization",
60
+ })
61
+
62
+ # ログ用マスクプレースホルダー(センシティブ値を置換する文字列)
63
+ _MASK_PLACEHOLDER = "***"
64
+
65
+
66
+ def _sanitize_for_log(data: Dict[str, Any]) -> Dict[str, Any]:
67
+ """ログ出力用にセンシティブデータをマスクした辞書を返す"""
68
+ if not isinstance(data, dict):
69
+ return data
70
+ result: Dict[str, Any] = {}
71
+ for key, value in data.items():
72
+ if key in _SENSITIVE_KEYS:
73
+ result[key] = _MASK_PLACEHOLDER
74
+ elif isinstance(value, dict):
75
+ result[key] = _sanitize_for_log(value)
76
+ elif isinstance(value, list):
77
+ result[key] = [
78
+ _sanitize_for_log(item) if isinstance(item, dict) else item
79
+ for item in value
80
+ ]
81
+ else:
82
+ result[key] = value
83
+ return result
84
+
85
+
86
+ class FFIDClient:
87
+ """FFID API クライアント
88
+
89
+ FFID プラットフォームのAPIを呼び出すための非同期HTTPクライアント。
90
+ TypeScript SDK の ``createFFIDClient`` に対応。
91
+
92
+ Args:
93
+ config: SDK設定(``FFIDConfig`` インスタンス)
94
+
95
+ Raises:
96
+ FFIDValidationError: service_code が未設定の場合
97
+
98
+ Example:
99
+ ```python
100
+ client = FFIDClient(FFIDConfig(service_code="chatbot"))
101
+ response = await client.get_session(token)
102
+ ```
103
+ """
104
+
105
+ def __init__(self, config: FFIDConfig) -> None:
106
+ if not config.service_code or not config.service_code.strip():
107
+ from ffid_sdk.errors import FFIDValidationError
108
+
109
+ raise FFIDValidationError(
110
+ message="service_code が未設定です",
111
+ code=FFIDErrorCode.VALIDATION_ERROR,
112
+ )
113
+
114
+ self._config = config
115
+ self._base_url = config.api_base_url if config.api_base_url is not None else DEFAULT_API_BASE_URL
116
+ self._timeout = config.timeout_seconds if config.timeout_seconds is not None else DEFAULT_TIMEOUT_SECONDS
117
+ self._max_retries = config.max_retries if config.max_retries is not None else DEFAULT_MAX_RETRIES
118
+ self._backoff_factor = DEFAULT_RETRY_BACKOFF_FACTOR
119
+ self._service_code = config.service_code
120
+
121
+ if config.debug:
122
+ logging.getLogger(SDK_LOGGER_NAME).setLevel(logging.DEBUG)
123
+
124
+ logger.debug("FFIDClient initialized: base_url=%s, service_code=%s",
125
+ self._base_url, self._service_code)
126
+
127
+ @property
128
+ def base_url(self) -> str:
129
+ """API ベースURL"""
130
+ return self._base_url
131
+
132
+ @property
133
+ def service_code(self) -> str:
134
+ """サービスコード"""
135
+ return self._service_code
136
+
137
+ # ------------------------------------------------------------------
138
+ # Internal HTTP helpers
139
+ # ------------------------------------------------------------------
140
+
141
+ def _build_headers(self, token: str) -> Dict[str, str]:
142
+ """認証ヘッダーを構築"""
143
+ return {
144
+ AUTHORIZATION_HEADER: f"{BEARER_PREFIX}{token}",
145
+ "Content-Type": "application/json",
146
+ }
147
+
148
+ async def _request(
149
+ self,
150
+ method: str,
151
+ endpoint: str,
152
+ token: Optional[str] = None,
153
+ *,
154
+ json_body: Optional[Dict[str, Any]] = None,
155
+ params: Optional[Dict[str, str]] = None,
156
+ headers_override: Optional[Dict[str, str]] = None,
157
+ ) -> httpx.Response:
158
+ """HTTP リクエストを実行(リトライ + 指数バックオフ付き)
159
+
160
+ Args:
161
+ method: HTTPメソッド(GET, POST等)
162
+ endpoint: APIエンドポイントパス
163
+ token: アクセストークン(headers_override未指定時に使用)
164
+ json_body: リクエストボディ(JSON)
165
+ params: クエリパラメータ
166
+ headers_override: カスタムヘッダー(認証ヘッダー不要なリクエスト用)
167
+
168
+ Raises:
169
+ FFIDNetworkError: 全リトライ失敗時
170
+ """
171
+ url = f"{self._base_url}{endpoint}"
172
+ headers = headers_override if headers_override is not None else self._build_headers(token or "")
173
+ last_error: Optional[Exception] = None
174
+
175
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
176
+ for attempt in range(self._max_retries):
177
+ try:
178
+ logger.debug("Request [attempt %d/%d]: %s %s",
179
+ attempt + 1, self._max_retries, method, url)
180
+
181
+ response = await client.request(
182
+ method=method,
183
+ url=url,
184
+ headers=headers,
185
+ json=json_body,
186
+ params=params,
187
+ )
188
+ return response
189
+
190
+ except httpx.TimeoutException as exc:
191
+ last_error = exc
192
+ logger.warning("Request timeout [attempt %d/%d]: %s %s",
193
+ attempt + 1, self._max_retries, method, url)
194
+ except httpx.ConnectError as exc:
195
+ last_error = exc
196
+ logger.warning("Connection error [attempt %d/%d]: %s %s",
197
+ attempt + 1, self._max_retries, method, url)
198
+
199
+ # 指数バックオフ(最後の試行後は待機不要)
200
+ if attempt < self._max_retries - 1:
201
+ backoff_seconds = self._backoff_factor * (2 ** attempt)
202
+ logger.debug("Retrying in %.1f seconds...", backoff_seconds)
203
+ await asyncio.sleep(backoff_seconds)
204
+
205
+ raise FFIDNetworkError(
206
+ message="ネットワークエラーが発生しました。しばらく経ってから再度お試しください。",
207
+ details={"url": url, "error": str(last_error)},
208
+ )
209
+
210
+ async def _fetch_json(
211
+ self,
212
+ method: str,
213
+ endpoint: str,
214
+ token: Optional[str] = None,
215
+ *,
216
+ json_body: Optional[Dict[str, Any]] = None,
217
+ params: Optional[Dict[str, str]] = None,
218
+ headers_override: Optional[Dict[str, str]] = None,
219
+ ) -> Dict[str, Any]:
220
+ """JSON レスポンスを取得してパース
221
+
222
+ Returns:
223
+ パース済みJSONレスポンス
224
+
225
+ Raises:
226
+ FFIDNetworkError: ネットワークエラー
227
+ FFIDSDKError: パースエラー
228
+ """
229
+ response = await self._request(
230
+ method, endpoint, token,
231
+ json_body=json_body, params=params,
232
+ headers_override=headers_override,
233
+ )
234
+
235
+ if response.status_code == NO_CONTENT_STATUS:
236
+ return {}
237
+
238
+ try:
239
+ data: Dict[str, Any] = response.json()
240
+ except Exception as exc:
241
+ logger.error("Parse error: status=%d, error=%s", response.status_code, exc)
242
+ raise FFIDSDKError(
243
+ code=FFIDErrorCode.PARSE_ERROR,
244
+ message=f"サーバーから不正なレスポンスを受信しました (status: {response.status_code})",
245
+ ) from exc
246
+
247
+ logger.debug(
248
+ "Response: status=%d, data=%s",
249
+ response.status_code,
250
+ _sanitize_for_log(data),
251
+ )
252
+
253
+ if response.status_code >= 400:
254
+ error_data = data.get("error", {})
255
+ raise FFIDSDKError(
256
+ code=error_data.get("code", FFIDErrorCode.UNKNOWN_ERROR),
257
+ message=error_data.get("message", "不明なエラーが発生しました"),
258
+ details=error_data.get("details"),
259
+ )
260
+
261
+ return data
262
+
263
+ # ------------------------------------------------------------------
264
+ # Public API methods
265
+ # ------------------------------------------------------------------
266
+
267
+ async def get_session(self, token: str) -> FFIDApiResponse[FFIDSessionResponse]:
268
+ """セッション情報を取得
269
+
270
+ FFID API の ``GET /api/v1/auth/session`` を呼び出し、
271
+ トークンに紐づくユーザー・組織・契約情報を取得する。
272
+
273
+ Args:
274
+ token: アクセストークン(Bearer Token)
275
+
276
+ Returns:
277
+ セッション情報を含むレスポンス
278
+ """
279
+ try:
280
+ raw = await self._fetch_json("GET", SESSION_ENDPOINT, token)
281
+ session_data = raw.get("data", raw)
282
+ session = FFIDSessionResponse.model_validate(session_data)
283
+ return FFIDApiResponse[FFIDSessionResponse](data=session)
284
+ except FFIDSDKError as exc:
285
+ return FFIDApiResponse[FFIDSessionResponse](
286
+ error=FFIDError(code=exc.code, message=exc.message, details=exc.details)
287
+ )
288
+ except Exception as exc:
289
+ logger.error("Unexpected error in get_session: %s", exc)
290
+ return FFIDApiResponse[FFIDSessionResponse](
291
+ error=FFIDError(
292
+ code=FFIDErrorCode.UNKNOWN_ERROR,
293
+ message="セッション情報の取得に失敗しました",
294
+ )
295
+ )
296
+
297
+ async def refresh_token(self, refresh_token: str) -> FFIDApiResponse[FFIDTokenResponse]:
298
+ """トークンをリフレッシュ
299
+
300
+ リフレッシュトークンはリクエストボディのみに含める(セキュリティ対策)。
301
+ Authorization ヘッダーには送信しない。
302
+
303
+ Args:
304
+ refresh_token: リフレッシュトークン
305
+
306
+ Returns:
307
+ 新しいアクセストークンを含むレスポンス
308
+ """
309
+ try:
310
+ raw = await self._fetch_json(
311
+ "POST",
312
+ REFRESH_ENDPOINT,
313
+ None,
314
+ json_body={"refresh_token": refresh_token},
315
+ headers_override={"Content-Type": "application/json"},
316
+ )
317
+ data = raw.get("data", raw)
318
+ token_response = FFIDTokenResponse.model_validate(data)
319
+ return FFIDApiResponse[FFIDTokenResponse](data=token_response)
320
+ except FFIDSDKError as exc:
321
+ return FFIDApiResponse[FFIDTokenResponse](
322
+ error=FFIDError(code=exc.code, message=exc.message, details=exc.details)
323
+ )
324
+ except Exception as exc:
325
+ logger.error("Unexpected error in refresh_token: %s", exc)
326
+ return FFIDApiResponse[FFIDTokenResponse](
327
+ error=FFIDError(
328
+ code=FFIDErrorCode.UNKNOWN_ERROR,
329
+ message="トークンのリフレッシュに失敗しました",
330
+ )
331
+ )
332
+
333
+ async def sign_out(self, token: str) -> FFIDApiResponse[None]:
334
+ """サインアウト
335
+
336
+ Args:
337
+ token: アクセストークン
338
+
339
+ Returns:
340
+ 成功時は data=None のレスポンス
341
+ """
342
+ try:
343
+ await self._request("POST", SIGNOUT_ENDPOINT, token)
344
+ return FFIDApiResponse[None](data=None)
345
+ except FFIDSDKError as exc:
346
+ return FFIDApiResponse[None](
347
+ error=FFIDError(code=exc.code, message=exc.message, details=exc.details)
348
+ )
349
+ except Exception as exc:
350
+ logger.error("Unexpected error in sign_out: %s", exc)
351
+ return FFIDApiResponse[None](
352
+ error=FFIDError(
353
+ code=FFIDErrorCode.UNKNOWN_ERROR,
354
+ message="サインアウトに失敗しました",
355
+ )
356
+ )
357
+
358
+ async def check_subscription(
359
+ self,
360
+ token: str,
361
+ service_code: Optional[str] = None,
362
+ ) -> FFIDApiResponse[FFIDSubscription]:
363
+ """契約状況をチェック
364
+
365
+ Args:
366
+ token: アクセストークン
367
+ service_code: サービスコード(未指定時はクライアント設定値を使用)
368
+
369
+ Returns:
370
+ 契約情報を含むレスポンス
371
+ """
372
+ svc = service_code or self._service_code
373
+ try:
374
+ raw = await self._fetch_json(
375
+ "GET",
376
+ SUBSCRIPTIONS_CHECK_ENDPOINT,
377
+ token,
378
+ params={"service_code": svc},
379
+ )
380
+ data = raw.get("data", raw)
381
+ subscription = FFIDSubscription.model_validate(data)
382
+ return FFIDApiResponse[FFIDSubscription](data=subscription)
383
+ except FFIDSDKError as exc:
384
+ return FFIDApiResponse[FFIDSubscription](
385
+ error=FFIDError(code=exc.code, message=exc.message, details=exc.details)
386
+ )
387
+ except Exception as exc:
388
+ logger.error("Unexpected error in check_subscription: %s", exc)
389
+ return FFIDApiResponse[FFIDSubscription](
390
+ error=FFIDError(
391
+ code=FFIDErrorCode.UNKNOWN_ERROR,
392
+ message="契約情報の取得に失敗しました",
393
+ )
394
+ )
ffid_sdk/constants.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ FFID SDK Shared Constants
3
+
4
+ SDK全体で共有される定数。URL・エンドポイント・デフォルト設定値を一元管理。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # API Base URL
11
+ # ---------------------------------------------------------------------------
12
+
13
+ DEFAULT_API_BASE_URL = "https://id.feelflow.co.jp"
14
+ """FFID API のデフォルトベースURL(本番環境)"""
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # API Endpoint Paths
18
+ # ---------------------------------------------------------------------------
19
+
20
+ SESSION_ENDPOINT = "/api/v1/auth/session"
21
+ """セッション取得エンドポイント"""
22
+
23
+ REFRESH_ENDPOINT = "/api/v1/auth/refresh"
24
+ """トークンリフレッシュエンドポイント"""
25
+
26
+ SIGNOUT_ENDPOINT = "/api/v1/auth/signout"
27
+ """サインアウトエンドポイント"""
28
+
29
+ SUBSCRIPTIONS_CHECK_ENDPOINT = "/api/v1/subscriptions/check"
30
+ """契約チェックエンドポイント"""
31
+
32
+ LEGAL_EXT_PREFIX = "/api/v1/legal/ext"
33
+ """Legal 外部 API プレフィックス(Service API Key 認証)"""
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # HTTP
37
+ # ---------------------------------------------------------------------------
38
+
39
+ SERVICE_API_KEY_HEADER = "X-Service-Api-Key"
40
+ """Service API Key ヘッダー名(Legal 等のサーバー間通信)"""
41
+
42
+ NO_CONTENT_STATUS = 204
43
+ """HTTP 204 No Content ステータスコード"""
44
+
45
+ AUTHORIZATION_HEADER = "Authorization"
46
+ """認証ヘッダー名"""
47
+
48
+ BEARER_PREFIX = "Bearer "
49
+ """Bearer トークンプレフィックス"""
50
+
51
+ SESSION_COOKIE_NAME = "ffid_session"
52
+ """セッションCookie名"""
53
+
54
+ REFRESH_COOKIE_NAME = "ffid_refresh"
55
+ """リフレッシュトークン用Cookie名(auto_refresh 有効時のみ参照)"""
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Timeouts & Retries
59
+ # ---------------------------------------------------------------------------
60
+
61
+ DEFAULT_TIMEOUT_SECONDS = 10.0
62
+ """デフォルトHTTPタイムアウト(秒)"""
63
+
64
+ DEFAULT_MAX_RETRIES = 3
65
+ """デフォルトリトライ回数"""
66
+
67
+ DEFAULT_RETRY_BACKOFF_FACTOR = 0.5
68
+ """リトライ時のバックオフ係数"""
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Logging
72
+ # ---------------------------------------------------------------------------
73
+
74
+ SDK_LOGGER_NAME = "ffid_sdk"
75
+ """SDKロガー名"""
ffid_sdk/context.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ FFID Context Dependencies for FastAPI
3
+
4
+ FastAPI の ``Depends()`` で利用するコンテキスト取得関数。
5
+ ミドルウェアが設定した ``FFIDContext`` をエンドポイントに注入する。
6
+
7
+ Example:
8
+ ```python
9
+ from fastapi import Depends
10
+ from ffid_sdk import FFIDContext, get_ffid_context, require_ffid_auth
11
+
12
+ @app.get("/profile")
13
+ async def profile(ctx: FFIDContext = Depends(get_ffid_context)):
14
+ return {"email": ctx.user.email}
15
+
16
+ @app.get("/admin")
17
+ async def admin(ctx: FFIDContext = Depends(require_ffid_auth)):
18
+ # 認証必須(未認証時は FFIDAuthenticationError を送出)
19
+ return {"user": ctx.user.id}
20
+ ```
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from typing import Optional
27
+
28
+ from fastapi import Request
29
+
30
+ from ffid_sdk.constants import SDK_LOGGER_NAME
31
+ from ffid_sdk.errors import FFIDAuthenticationError, FFIDErrorCode
32
+ from ffid_sdk.types import FFIDContext
33
+
34
+ logger = logging.getLogger(SDK_LOGGER_NAME)
35
+
36
+
37
+ def get_ffid_context(request: Request) -> Optional[FFIDContext]:
38
+ """リクエストから FFID コンテキストを取得(認証任意)
39
+
40
+ ミドルウェアが設定した ``FFIDContext`` を返す。
41
+ 未認証(除外パス等)の場合は ``None`` を返す。
42
+
43
+ Args:
44
+ request: FastAPI リクエストオブジェクト
45
+
46
+ Returns:
47
+ 認証済みの場合は FFIDContext、未認証の場合は None
48
+ """
49
+ context: Optional[FFIDContext] = getattr(request.state, "ffid_context", None)
50
+ return context
51
+
52
+
53
+ def require_ffid_auth(request: Request) -> FFIDContext:
54
+ """リクエストから FFID コンテキストを取得(認証必須)
55
+
56
+ ミドルウェアが設定した ``FFIDContext`` を返す。
57
+ 未認証の場合は ``FFIDAuthenticationError`` を発生させる。
58
+
59
+ Args:
60
+ request: FastAPI リクエストオブジェクト
61
+
62
+ Returns:
63
+ 認証済みの FFIDContext
64
+
65
+ Raises:
66
+ FFIDAuthenticationError: 未認証の場合
67
+ """
68
+ context: Optional[FFIDContext] = getattr(request.state, "ffid_context", None)
69
+ if context is None:
70
+ logger.debug("require_ffid_auth: No context found on request.state")
71
+ raise FFIDAuthenticationError(
72
+ message="認証が必要です。ログインしてください。",
73
+ code=FFIDErrorCode.AUTHENTICATION_ERROR,
74
+ )
75
+ return context
ffid_sdk/decorators.py ADDED
@@ -0,0 +1,147 @@
1
+ """
2
+ FFID SDK Decorators
3
+
4
+ FastAPI エンドポイント向けの契約チェックデコレータ。
5
+ TypeScript SDK の ``withSubscription`` HOC に対応。
6
+
7
+ Example:
8
+ ```python
9
+ from ffid_sdk import require_subscription, FFIDContext, get_ffid_context
10
+
11
+ @app.get("/premium")
12
+ @require_subscription(plans=["pro", "enterprise"])
13
+ async def premium_feature(ctx: FFIDContext = Depends(get_ffid_context)):
14
+ return {"plan": ctx.active_subscription.plan_code}
15
+
16
+ @app.get("/basic")
17
+ @require_subscription() # 任意の有効な契約があればOK
18
+ async def basic_feature(ctx: FFIDContext = Depends(get_ffid_context)):
19
+ return {"message": "Welcome!"}
20
+ ```
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import functools
26
+ import logging
27
+ from collections.abc import Callable
28
+ from typing import TypeVar
29
+
30
+ try:
31
+ from typing import ParamSpec
32
+ except ImportError:
33
+ from typing_extensions import ParamSpec
34
+
35
+ from ffid_sdk.constants import SDK_LOGGER_NAME
36
+ from ffid_sdk.errors import (
37
+ FFIDAuthenticationError,
38
+ FFIDErrorCode,
39
+ FFIDSubscriptionError,
40
+ )
41
+ from ffid_sdk.types import FFIDContext, SubscriptionStatus
42
+
43
+ logger = logging.getLogger(SDK_LOGGER_NAME)
44
+
45
+ P = ParamSpec("P")
46
+ R = TypeVar("R")
47
+
48
+
49
+ def require_subscription(
50
+ plans: list[str] | None = None,
51
+ plan: list[str] | None = None,
52
+ service_code: str | None = None,
53
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
54
+ """契約チェックデコレータ
55
+
56
+ エンドポイント関数をラップし、有効な契約があるかチェックする。
57
+ TypeScript SDK の ``withSubscription`` HOC に対応。
58
+
59
+ Args:
60
+ plans: 許可するプランコードのリスト(省略時は任意のアクティブ契約でOK)
61
+ plan: ``plans`` のエイリアス(Issue #170 の使用例と一致。plans より優先されない)
62
+ service_code: サービスコード(省略時はコンテキストの全契約を対象)
63
+
64
+ Returns:
65
+ デコレータ関数
66
+
67
+ Raises:
68
+ FFIDAuthenticationError: 未認証の場合
69
+ FFIDSubscriptionError: 有効な契約がない場合
70
+
71
+ Example:
72
+ ```python
73
+ @require_subscription(plans=["pro", "enterprise"])
74
+ async def premium_only(ctx: FFIDContext = Depends(get_ffid_context)):
75
+ ...
76
+ ```
77
+ """
78
+ # Issue #170: plan は plans のエイリアス(使用例 @require_subscription(plan=[...]) と一致)
79
+ if plans is None and plan is not None:
80
+ plans = plan
81
+
82
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
83
+ @functools.wraps(func)
84
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
85
+ # コンテキストを取得(args および kwargs から FFIDContext を探す)
86
+ ctx: FFIDContext | None = next(
87
+ (a for a in args if isinstance(a, FFIDContext)), None
88
+ )
89
+ if ctx is None:
90
+ ctx = next(
91
+ (v for v in kwargs.values() if isinstance(v, FFIDContext)), None
92
+ )
93
+
94
+ if ctx is None:
95
+ raise FFIDAuthenticationError(
96
+ message="認証が必要です。ログインしてください。",
97
+ code=FFIDErrorCode.AUTHENTICATION_ERROR,
98
+ )
99
+
100
+ # 契約チェック
101
+ active_statuses = (SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIALING)
102
+ matching_subs = [
103
+ s for s in ctx.subscriptions
104
+ if s.status in active_statuses
105
+ ]
106
+
107
+ # サービスコードでフィルタ
108
+ if service_code:
109
+ matching_subs = [
110
+ s for s in matching_subs
111
+ if s.service_code == service_code
112
+ ]
113
+
114
+ # プランでフィルタ
115
+ if plans:
116
+ matching_subs = [
117
+ s for s in matching_subs
118
+ if s.plan_code in plans
119
+ ]
120
+
121
+ if not matching_subs:
122
+ plan_info = ""
123
+ if plans:
124
+ plan_info = f"(必要なプラン: {', '.join(plans)})"
125
+ logger.debug(
126
+ "Subscription check failed: user=%s, plans=%s, service=%s",
127
+ ctx.user.email, plans, service_code,
128
+ )
129
+ raise FFIDSubscriptionError(
130
+ message=f"この機能を利用するには有効な契約が必要です。{plan_info}",
131
+ code=FFIDErrorCode.SUBSCRIPTION_REQUIRED,
132
+ details={
133
+ "required_plans": plans or [],
134
+ "service_code": service_code,
135
+ },
136
+ )
137
+
138
+ logger.debug(
139
+ "Subscription check passed: user=%s, plan=%s",
140
+ ctx.user.email, matching_subs[0].plan_code,
141
+ )
142
+
143
+ return await func(*args, **kwargs)
144
+
145
+ return wrapper # type: ignore[return-value]
146
+
147
+ return decorator