reqkey 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.
reqkey/client.py ADDED
@@ -0,0 +1,469 @@
1
+ """Synchronous and asynchronous clients for the ReqKey HTTP API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from .exceptions import (
13
+ ReqKeyAPIError,
14
+ ReqKeyAuthenticationError,
15
+ ReqKeyConfigurationError,
16
+ ReqKeyTimeoutError,
17
+ ReqKeyTransportError,
18
+ )
19
+ from .models import VerificationReason, VerificationResult
20
+
21
+ DEFAULT_BASE_URL = "https://api.reqkey.com"
22
+ DEFAULT_TIMEOUT_SECONDS = 2.0
23
+ USER_AGENT = "reqkey-python/0.1.0"
24
+ MAX_BODY_CHARACTERS = 1000
25
+ _DECISION_STATUS_CODES = {200, 402, 403, 429}
26
+
27
+
28
+ def _require_project_key(project_key: str | None, root_key: str | None) -> str:
29
+ if project_key is not None and root_key is not None:
30
+ raise ReqKeyConfigurationError("Pass project_key or root_key, not both.")
31
+ value = (project_key or root_key or "").strip()
32
+ if not value:
33
+ raise ReqKeyConfigurationError(
34
+ "A ReqKey project key is required. Pass project_key/root_key or set "
35
+ "REQKEY_PROJECT_KEY/REQKEY_ROOT_KEY."
36
+ )
37
+ return value
38
+
39
+
40
+ def _validate_verify_input(key: str, credits: int) -> None:
41
+ if not key.strip():
42
+ raise ReqKeyConfigurationError("The consumer API key cannot be empty.")
43
+ if isinstance(credits, bool) or not isinstance(credits, int) or credits < 0:
44
+ raise ReqKeyConfigurationError("credits must be a non-negative integer.")
45
+
46
+
47
+ def _json_object(response: httpx.Response) -> dict[str, Any]:
48
+ try:
49
+ payload = response.json()
50
+ except ValueError as exc:
51
+ raise ReqKeyAPIError(
52
+ "ReqKey returned a non-JSON response.",
53
+ status_code=response.status_code,
54
+ ) from exc
55
+ if not isinstance(payload, dict):
56
+ raise ReqKeyAPIError(
57
+ "ReqKey returned an unexpected response body.",
58
+ status_code=response.status_code,
59
+ )
60
+ return payload
61
+
62
+
63
+ def _error_message(payload: Mapping[str, Any], fallback: str) -> str:
64
+ for key in ("error", "message"):
65
+ value = payload.get(key)
66
+ if isinstance(value, str) and value:
67
+ return value
68
+ return fallback
69
+
70
+
71
+ def _number(value: Any) -> int | None:
72
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
73
+
74
+
75
+ def _float_number(value: Any) -> float | None:
76
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
77
+ return float(value)
78
+ return None
79
+
80
+
81
+ def _reason_for(response: httpx.Response, payload: Mapping[str, Any]) -> VerificationReason:
82
+ if response.status_code == 402:
83
+ return VerificationReason.INSUFFICIENT_CREDITS
84
+ if response.status_code == 403:
85
+ return VerificationReason.FORBIDDEN
86
+ if response.status_code == 429 or payload.get("rateLimited") is True:
87
+ return VerificationReason.RATE_LIMITED
88
+ if payload.get("valid") is True:
89
+ return VerificationReason.VALID
90
+ if response.status_code == 200:
91
+ return VerificationReason.INVALID_KEY
92
+ return VerificationReason.DENIED
93
+
94
+
95
+ def _verification_result(response: httpx.Response) -> VerificationResult:
96
+ payload = _json_object(response)
97
+
98
+ if response.status_code == 401:
99
+ raise ReqKeyAuthenticationError(
100
+ _error_message(payload, "ReqKey rejected the project credential."),
101
+ status_code=response.status_code,
102
+ body=payload,
103
+ )
104
+ if response.status_code not in _DECISION_STATUS_CODES:
105
+ raise ReqKeyAPIError(
106
+ _error_message(payload, f"ReqKey returned HTTP {response.status_code}."),
107
+ status_code=response.status_code,
108
+ body=payload,
109
+ )
110
+
111
+ retry_after = _float_number(payload.get("retryAfter"))
112
+ if retry_after is None:
113
+ retry_after = _float_number(response.headers.get("Retry-After"))
114
+
115
+ raw_allowed_apis = payload.get("allowedApis")
116
+ allowed_apis = (
117
+ tuple(str(item) for item in raw_allowed_apis)
118
+ if isinstance(raw_allowed_apis, list)
119
+ else ()
120
+ )
121
+ raw_rate_limit = payload.get("rateLimit")
122
+ rate_limit = dict(raw_rate_limit) if isinstance(raw_rate_limit, dict) else None
123
+
124
+ return VerificationResult(
125
+ valid=payload.get("valid") is True,
126
+ reason=_reason_for(response, payload),
127
+ status_code=response.status_code,
128
+ request_id=payload.get("requestId") if isinstance(payload.get("requestId"), str) else None,
129
+ message=payload.get("message") if isinstance(payload.get("message"), str) else None,
130
+ api_id=payload.get("apiId") if isinstance(payload.get("apiId"), str) else None,
131
+ api_name=payload.get("apiName") if isinstance(payload.get("apiName"), str) else None,
132
+ resource=payload.get("resource") if isinstance(payload.get("resource"), str) else None,
133
+ credits_remaining=_number(payload.get("creditsRemaining")),
134
+ credits_limit=_number(payload.get("creditsLimit")),
135
+ allowed_apis=allowed_apis,
136
+ retry_after=retry_after,
137
+ rate_limit=rate_limit,
138
+ raw=payload,
139
+ )
140
+
141
+
142
+ def _verify_payload(
143
+ *,
144
+ key: str,
145
+ api_id: str | None,
146
+ credits: int,
147
+ resource: str | None,
148
+ ) -> dict[str, Any]:
149
+ _validate_verify_input(key, credits)
150
+ payload: dict[str, Any] = {"key": key, "credits": credits}
151
+ if api_id is not None:
152
+ payload["apiId"] = api_id
153
+ if resource is not None:
154
+ payload["resource"] = resource
155
+ return payload
156
+
157
+
158
+ def _ingest_payload(
159
+ *,
160
+ request_id: str | None = None,
161
+ api_id: str | None = None,
162
+ method: str | None = None,
163
+ endpoint: str | None = None,
164
+ path: str | None = None,
165
+ status_code: int | None = None,
166
+ latency_ms: int | None = None,
167
+ client_ip: str | None = None,
168
+ user_agent: str | None = None,
169
+ user_id: str | None = None,
170
+ query_params: Mapping[str, Any] | None = None,
171
+ request_headers: Mapping[str, str] | None = None,
172
+ response_headers: Mapping[str, str] | None = None,
173
+ request_body: str | None = None,
174
+ response_body: str | None = None,
175
+ timestamp: datetime | str | None = None,
176
+ ) -> dict[str, Any]:
177
+ if request_id is not None and not request_id.strip():
178
+ raise ReqKeyConfigurationError("request_id cannot be empty when provided.")
179
+ if api_id is not None and not api_id.strip():
180
+ raise ReqKeyConfigurationError("api_id cannot be empty when provided.")
181
+ if request_id is None and api_id is None:
182
+ raise ReqKeyConfigurationError("Ingestion requires request_id, api_id, or both.")
183
+ payload: dict[str, Any] = {}
184
+ if request_id is not None:
185
+ payload["requestId"] = request_id
186
+ if api_id is not None:
187
+ payload["apiId"] = api_id
188
+ optional_values: dict[str, Any] = {
189
+ "method": method,
190
+ "endpoint": endpoint,
191
+ "path": path,
192
+ "statusCode": status_code,
193
+ "latencyMs": latency_ms,
194
+ "clientIp": client_ip,
195
+ "userAgent": user_agent,
196
+ "userId": user_id,
197
+ "queryParams": dict(query_params) if query_params is not None else None,
198
+ "requestHeaders": dict(request_headers) if request_headers is not None else None,
199
+ "responseHeaders": dict(response_headers) if response_headers is not None else None,
200
+ "requestBody": (
201
+ request_body[:MAX_BODY_CHARACTERS] if request_body is not None else None
202
+ ),
203
+ "responseBody": (
204
+ response_body[:MAX_BODY_CHARACTERS] if response_body is not None else None
205
+ ),
206
+ "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else timestamp,
207
+ }
208
+ payload.update({key: value for key, value in optional_values.items() if value is not None})
209
+ return payload
210
+
211
+
212
+ class _ClientConfig:
213
+ def __init__(
214
+ self,
215
+ project_key: str | None,
216
+ root_key: str | None,
217
+ base_url: str,
218
+ timeout: float,
219
+ ) -> None:
220
+ self.project_key = _require_project_key(project_key, root_key)
221
+ self.base_url = base_url.rstrip("/")
222
+ if not self.base_url:
223
+ raise ReqKeyConfigurationError("base_url cannot be empty.")
224
+ if timeout <= 0:
225
+ raise ReqKeyConfigurationError("timeout must be greater than zero.")
226
+ self.timeout = timeout
227
+
228
+ @property
229
+ def headers(self) -> dict[str, str]:
230
+ return {
231
+ "Authorization": f"Bearer {self.project_key}",
232
+ "Content-Type": "application/json",
233
+ "User-Agent": USER_AGENT,
234
+ }
235
+
236
+
237
+ class ReqKey:
238
+ """Synchronous ReqKey client."""
239
+
240
+ def __init__(
241
+ self,
242
+ *,
243
+ project_key: str | None = None,
244
+ root_key: str | None = None,
245
+ base_url: str = DEFAULT_BASE_URL,
246
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
247
+ http_client: httpx.Client | None = None,
248
+ ) -> None:
249
+ self._config = _ClientConfig(project_key, root_key, base_url, timeout)
250
+ self._owns_client = http_client is None
251
+ self._client = http_client or httpx.Client(timeout=timeout, headers=self._config.headers)
252
+
253
+ @classmethod
254
+ def from_env(cls, **kwargs: Any) -> ReqKey:
255
+ """Create a client using `REQKEY_PROJECT_KEY` or `REQKEY_ROOT_KEY`."""
256
+
257
+ return cls(
258
+ project_key=os.getenv("REQKEY_PROJECT_KEY") or os.getenv("REQKEY_ROOT_KEY"),
259
+ **kwargs,
260
+ )
261
+
262
+ def verify(
263
+ self,
264
+ key: str,
265
+ *,
266
+ api_id: str | None = None,
267
+ credits: int = 1,
268
+ resource: str | None = None,
269
+ ) -> VerificationResult:
270
+ """Validate a consumer key and atomically deduct credits."""
271
+
272
+ try:
273
+ response = self._client.post(
274
+ f"{self._config.base_url}/key/validate",
275
+ json=_verify_payload(key=key, api_id=api_id, credits=credits, resource=resource),
276
+ headers=self._config.headers,
277
+ )
278
+ except httpx.TimeoutException as exc:
279
+ raise ReqKeyTimeoutError("The ReqKey validation request timed out.") from exc
280
+ except httpx.RequestError as exc:
281
+ raise ReqKeyTransportError(f"Could not reach ReqKey: {exc}") from exc
282
+ return _verification_result(response)
283
+
284
+ def ingest(
285
+ self,
286
+ request_id: str | None = None,
287
+ *,
288
+ api_id: str | None = None,
289
+ method: str | None = None,
290
+ endpoint: str | None = None,
291
+ path: str | None = None,
292
+ status_code: int | None = None,
293
+ latency_ms: int | None = None,
294
+ client_ip: str | None = None,
295
+ user_agent: str | None = None,
296
+ user_id: str | None = None,
297
+ query_params: Mapping[str, Any] | None = None,
298
+ request_headers: Mapping[str, str] | None = None,
299
+ response_headers: Mapping[str, str] | None = None,
300
+ request_body: str | None = None,
301
+ response_body: str | None = None,
302
+ timestamp: datetime | str | None = None,
303
+ ) -> None:
304
+ """Send request/response metadata to ReqKey analytics."""
305
+
306
+ payload = _ingest_payload(
307
+ request_id=request_id,
308
+ api_id=api_id,
309
+ method=method,
310
+ endpoint=endpoint,
311
+ path=path,
312
+ status_code=status_code,
313
+ latency_ms=latency_ms,
314
+ client_ip=client_ip,
315
+ user_agent=user_agent,
316
+ user_id=user_id,
317
+ query_params=query_params,
318
+ request_headers=request_headers,
319
+ response_headers=response_headers,
320
+ request_body=request_body,
321
+ response_body=response_body,
322
+ timestamp=timestamp,
323
+ )
324
+ try:
325
+ response = self._client.post(
326
+ f"{self._config.base_url}/ingest",
327
+ json=payload,
328
+ headers=self._config.headers,
329
+ )
330
+ except httpx.TimeoutException as exc:
331
+ raise ReqKeyTimeoutError("The ReqKey ingestion request timed out.") from exc
332
+ except httpx.RequestError as exc:
333
+ raise ReqKeyTransportError(f"Could not reach ReqKey: {exc}") from exc
334
+ if response.status_code not in {200, 202}:
335
+ body = _json_object(response)
336
+ raise ReqKeyAPIError(
337
+ _error_message(body, f"ReqKey returned HTTP {response.status_code}."),
338
+ status_code=response.status_code,
339
+ body=body,
340
+ )
341
+
342
+ def close(self) -> None:
343
+ if self._owns_client:
344
+ self._client.close()
345
+
346
+ def __enter__(self) -> ReqKey:
347
+ return self
348
+
349
+ def __exit__(self, *_: object) -> None:
350
+ self.close()
351
+
352
+
353
+ class AsyncReqKey:
354
+ """Asynchronous ReqKey client for FastAPI and other async applications."""
355
+
356
+ def __init__(
357
+ self,
358
+ *,
359
+ project_key: str | None = None,
360
+ root_key: str | None = None,
361
+ base_url: str = DEFAULT_BASE_URL,
362
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
363
+ http_client: httpx.AsyncClient | None = None,
364
+ ) -> None:
365
+ self._config = _ClientConfig(project_key, root_key, base_url, timeout)
366
+ self._owns_client = http_client is None
367
+ self._client = http_client or httpx.AsyncClient(
368
+ timeout=timeout,
369
+ headers=self._config.headers,
370
+ )
371
+
372
+ @classmethod
373
+ def from_env(cls, **kwargs: Any) -> AsyncReqKey:
374
+ """Create a client using `REQKEY_PROJECT_KEY` or `REQKEY_ROOT_KEY`."""
375
+
376
+ return cls(
377
+ project_key=os.getenv("REQKEY_PROJECT_KEY") or os.getenv("REQKEY_ROOT_KEY"),
378
+ **kwargs,
379
+ )
380
+
381
+ async def verify(
382
+ self,
383
+ key: str,
384
+ *,
385
+ api_id: str | None = None,
386
+ credits: int = 1,
387
+ resource: str | None = None,
388
+ ) -> VerificationResult:
389
+ """Validate a consumer key and atomically deduct credits."""
390
+
391
+ try:
392
+ response = await self._client.post(
393
+ f"{self._config.base_url}/key/validate",
394
+ json=_verify_payload(key=key, api_id=api_id, credits=credits, resource=resource),
395
+ headers=self._config.headers,
396
+ )
397
+ except httpx.TimeoutException as exc:
398
+ raise ReqKeyTimeoutError("The ReqKey validation request timed out.") from exc
399
+ except httpx.RequestError as exc:
400
+ raise ReqKeyTransportError(f"Could not reach ReqKey: {exc}") from exc
401
+ return _verification_result(response)
402
+
403
+ async def ingest(
404
+ self,
405
+ request_id: str | None = None,
406
+ *,
407
+ api_id: str | None = None,
408
+ method: str | None = None,
409
+ endpoint: str | None = None,
410
+ path: str | None = None,
411
+ status_code: int | None = None,
412
+ latency_ms: int | None = None,
413
+ client_ip: str | None = None,
414
+ user_agent: str | None = None,
415
+ user_id: str | None = None,
416
+ query_params: Mapping[str, Any] | None = None,
417
+ request_headers: Mapping[str, str] | None = None,
418
+ response_headers: Mapping[str, str] | None = None,
419
+ request_body: str | None = None,
420
+ response_body: str | None = None,
421
+ timestamp: datetime | str | None = None,
422
+ ) -> None:
423
+ """Send request/response metadata to ReqKey analytics."""
424
+
425
+ payload = _ingest_payload(
426
+ request_id=request_id,
427
+ api_id=api_id,
428
+ method=method,
429
+ endpoint=endpoint,
430
+ path=path,
431
+ status_code=status_code,
432
+ latency_ms=latency_ms,
433
+ client_ip=client_ip,
434
+ user_agent=user_agent,
435
+ user_id=user_id,
436
+ query_params=query_params,
437
+ request_headers=request_headers,
438
+ response_headers=response_headers,
439
+ request_body=request_body,
440
+ response_body=response_body,
441
+ timestamp=timestamp,
442
+ )
443
+ try:
444
+ response = await self._client.post(
445
+ f"{self._config.base_url}/ingest",
446
+ json=payload,
447
+ headers=self._config.headers,
448
+ )
449
+ except httpx.TimeoutException as exc:
450
+ raise ReqKeyTimeoutError("The ReqKey ingestion request timed out.") from exc
451
+ except httpx.RequestError as exc:
452
+ raise ReqKeyTransportError(f"Could not reach ReqKey: {exc}") from exc
453
+ if response.status_code not in {200, 202}:
454
+ body = _json_object(response)
455
+ raise ReqKeyAPIError(
456
+ _error_message(body, f"ReqKey returned HTTP {response.status_code}."),
457
+ status_code=response.status_code,
458
+ body=body,
459
+ )
460
+
461
+ async def close(self) -> None:
462
+ if self._owns_client:
463
+ await self._client.aclose()
464
+
465
+ async def __aenter__(self) -> AsyncReqKey:
466
+ return self
467
+
468
+ async def __aexit__(self, *_: object) -> None:
469
+ await self.close()