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_python_sdk-0.1.0.dist-info/METADATA +135 -0
- ffid_python_sdk-0.1.0.dist-info/RECORD +22 -0
- ffid_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- ffid_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- ffid_sdk/__init__.py +176 -0
- ffid_sdk/client.py +394 -0
- ffid_sdk/constants.py +75 -0
- ffid_sdk/context.py +75 -0
- ffid_sdk/decorators.py +147 -0
- ffid_sdk/errors.py +153 -0
- ffid_sdk/legal/__init__.py +53 -0
- ffid_sdk/legal/client.py +224 -0
- ffid_sdk/legal/errors.py +55 -0
- ffid_sdk/legal/helpers.py +55 -0
- ffid_sdk/legal/types.py +145 -0
- ffid_sdk/middleware.py +240 -0
- ffid_sdk/types.py +257 -0
- ffid_sdk/webhook_constants.py +34 -0
- ffid_sdk/webhook_errors.py +63 -0
- ffid_sdk/webhook_handler.py +213 -0
- ffid_sdk/webhook_types.py +212 -0
- ffid_sdk/webhook_verify.py +136 -0
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
|