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/django.py ADDED
@@ -0,0 +1,628 @@
1
+ """Django-native ReqKey middleware for synchronous and asynchronous deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import logging
7
+ import os
8
+ import time
9
+ import zlib
10
+ from collections.abc import Awaitable, Callable, Iterable, Mapping
11
+ from datetime import datetime
12
+ from typing import Any, Protocol, cast
13
+ from urllib.parse import urlencode
14
+
15
+ from asgiref.sync import iscoroutinefunction, markcoroutinefunction
16
+ from django.conf import settings
17
+ from django.http import HttpRequest, HttpResponse, JsonResponse
18
+ from django.http.response import HttpResponseBase
19
+
20
+ from ._middleware import (
21
+ DEFAULT_ERROR_MESSAGES,
22
+ FailureMode,
23
+ KeyLocation,
24
+ KeyScheme,
25
+ Mode,
26
+ decision_headers,
27
+ denial,
28
+ excluded_header_names,
29
+ extract_credential,
30
+ filtered_headers,
31
+ path_matches,
32
+ validate_credit_cost,
33
+ validate_middleware_options,
34
+ )
35
+ from .client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT_SECONDS, AsyncReqKey, ReqKey
36
+ from .exceptions import ReqKeyConfigurationError, ReqKeyError
37
+ from .models import VerificationResult
38
+
39
+ logger = logging.getLogger("reqkey")
40
+
41
+ MAX_RESPONSE_BODY_CHARACTERS = 1000
42
+ _MAX_CAPTURE_BYTES = MAX_RESPONSE_BODY_CHARACTERS * 4 + 4
43
+
44
+
45
+ class SyncReqKeyLike(Protocol):
46
+ def verify(
47
+ self,
48
+ key: str,
49
+ *,
50
+ api_id: str | None = None,
51
+ credits: int = 1,
52
+ resource: str | None = None,
53
+ ) -> VerificationResult: ...
54
+
55
+ def ingest(
56
+ self,
57
+ request_id: str | None = None,
58
+ *,
59
+ api_id: str | None = None,
60
+ method: str | None = None,
61
+ endpoint: str | None = None,
62
+ path: str | None = None,
63
+ status_code: int | None = None,
64
+ latency_ms: int | None = None,
65
+ client_ip: str | None = None,
66
+ user_agent: str | None = None,
67
+ user_id: str | None = None,
68
+ query_params: Mapping[str, Any] | None = None,
69
+ request_headers: Mapping[str, str] | None = None,
70
+ response_headers: Mapping[str, str] | None = None,
71
+ request_body: str | None = None,
72
+ response_body: str | None = None,
73
+ timestamp: datetime | str | None = None,
74
+ ) -> None: ...
75
+
76
+
77
+ class AsyncReqKeyLike(Protocol):
78
+ async def verify(
79
+ self,
80
+ key: str,
81
+ *,
82
+ api_id: str | None = None,
83
+ credits: int = 1,
84
+ resource: str | None = None,
85
+ ) -> VerificationResult: ...
86
+
87
+ async def ingest(
88
+ self,
89
+ request_id: str | None = None,
90
+ *,
91
+ api_id: str | None = None,
92
+ method: str | None = None,
93
+ endpoint: str | None = None,
94
+ path: str | None = None,
95
+ status_code: int | None = None,
96
+ latency_ms: int | None = None,
97
+ client_ip: str | None = None,
98
+ user_agent: str | None = None,
99
+ user_id: str | None = None,
100
+ query_params: Mapping[str, Any] | None = None,
101
+ request_headers: Mapping[str, str] | None = None,
102
+ response_headers: Mapping[str, str] | None = None,
103
+ request_body: str | None = None,
104
+ response_body: str | None = None,
105
+ timestamp: datetime | str | None = None,
106
+ ) -> None: ...
107
+
108
+
109
+ SyncGetResponse = Callable[[HttpRequest], HttpResponseBase]
110
+ AsyncGetResponse = Callable[[HttpRequest], Awaitable[HttpResponseBase]]
111
+ CreditsResolver = Callable[[HttpRequest], int | Awaitable[int]]
112
+ ProtectionResolver = Callable[[HttpRequest], bool | Awaitable[bool]]
113
+ ConsumerKeyResolver = Callable[[HttpRequest], str | None | Awaitable[str | None]]
114
+ RequestIdResolver = Callable[[HttpRequest], str | None | Awaitable[str | None]]
115
+ PathResolver = Callable[[HttpRequest], str | Awaitable[str]]
116
+
117
+
118
+ class ReqKeyMiddleware:
119
+ """Django middleware configured through the ``REQKEY`` Django setting."""
120
+
121
+ sync_capable = True
122
+ async_capable = True
123
+
124
+ def __init__(self, get_response: SyncGetResponse | AsyncGetResponse) -> None:
125
+ self.get_response = get_response
126
+ self._is_async = iscoroutinefunction(get_response)
127
+ if self._is_async:
128
+ markcoroutinefunction(self)
129
+
130
+ options = _django_options()
131
+ self.api_id = str(options.pop("api_id", ""))
132
+ self.mode = cast(Mode, options.pop("mode", "both"))
133
+ self.enabled = bool(options.pop("enabled", True))
134
+ self.key_location = cast(KeyLocation, options.pop("key_location", "header"))
135
+ self.key_name = str(options.pop("key_name", "X-API-Key"))
136
+ self.key_scheme = cast(KeyScheme, options.pop("key_scheme", "raw"))
137
+ self.get_consumer_key = cast(
138
+ ConsumerKeyResolver | None,
139
+ options.pop("get_consumer_key", None),
140
+ )
141
+ self.credits = cast(int | CreditsResolver, options.pop("credits", 1))
142
+ self.exclude_paths = tuple(cast(Iterable[str], options.pop("exclude_paths", ())))
143
+ self.skip_methods = frozenset(
144
+ method.upper()
145
+ for method in cast(Iterable[str], options.pop("skip_methods", ("OPTIONS",)))
146
+ )
147
+ self.should_protect = cast(
148
+ ProtectionResolver | None,
149
+ options.pop("should_protect", None),
150
+ )
151
+ self.request_id_resolver = cast(
152
+ RequestIdResolver | None,
153
+ options.pop("request_id_resolver", None),
154
+ )
155
+ self.path_resolver = cast(PathResolver | None, options.pop("path_resolver", None))
156
+ self.error_messages = {
157
+ **DEFAULT_ERROR_MESSAGES,
158
+ **dict(cast(Mapping[str, str], options.pop("error_messages", {}))),
159
+ }
160
+ self.capture_query_params = bool(options.pop("capture_query_params", False))
161
+ self.capture_request_headers = bool(options.pop("capture_request_headers", False))
162
+ self.capture_response_headers = bool(options.pop("capture_response_headers", False))
163
+ self.capture_response_body = bool(options.pop("capture_response_body", False))
164
+ self.capture_client_ip = bool(options.pop("capture_client_ip", False))
165
+ self.capture_user_agent = bool(options.pop("capture_user_agent", True))
166
+ self.excluded_headers = excluded_header_names(
167
+ self.key_name,
168
+ cast(Iterable[str], options.pop("excluded_headers", ())),
169
+ )
170
+ self.failure_mode = cast(FailureMode, options.pop("failure_mode", "closed"))
171
+
172
+ project_key = cast(str | None, options.pop("project_key", None))
173
+ root_key = cast(str | None, options.pop("root_key", None))
174
+ client = options.pop("client", None)
175
+ base_url = str(options.pop("base_url", DEFAULT_BASE_URL))
176
+ timeout = float(options.pop("timeout", DEFAULT_TIMEOUT_SECONDS))
177
+ if options:
178
+ unknown = ", ".join(sorted(options))
179
+ raise ReqKeyConfigurationError(f"Unknown REQKEY settings: {unknown}.")
180
+
181
+ validate_middleware_options(
182
+ api_id=self.api_id,
183
+ mode=self.mode,
184
+ key_location=self.key_location,
185
+ key_scheme=self.key_scheme,
186
+ failure_mode=self.failure_mode,
187
+ credits=self.credits,
188
+ )
189
+ if client is not None and (project_key is not None or root_key is not None):
190
+ raise ReqKeyConfigurationError("Pass CLIENT or PROJECT_KEY/ROOT_KEY, not both.")
191
+ if client is None:
192
+ if project_key is None and root_key is None:
193
+ project_key = os.getenv("REQKEY_PROJECT_KEY") or os.getenv(
194
+ "REQKEY_ROOT_KEY"
195
+ )
196
+ self.client: SyncReqKeyLike | AsyncReqKeyLike = (
197
+ AsyncReqKey(
198
+ project_key=project_key,
199
+ root_key=root_key,
200
+ base_url=base_url,
201
+ timeout=timeout,
202
+ )
203
+ if self._is_async
204
+ else ReqKey(
205
+ project_key=project_key,
206
+ root_key=root_key,
207
+ base_url=base_url,
208
+ timeout=timeout,
209
+ )
210
+ )
211
+ else:
212
+ self.client = cast(SyncReqKeyLike | AsyncReqKeyLike, client)
213
+
214
+ def __call__(
215
+ self,
216
+ request: HttpRequest,
217
+ ) -> HttpResponseBase | Awaitable[HttpResponseBase]:
218
+ if self._is_async:
219
+ return self._acall(request)
220
+ return self._scall(request)
221
+
222
+ def _scall(self, request: HttpRequest) -> HttpResponseBase:
223
+ if not self.enabled or not self._applies_to_sync(request):
224
+ return cast(SyncGetResponse, self.get_response)(request)
225
+
226
+ decision: VerificationResult | None = None
227
+ validation_time_ms: float | None = None
228
+ client = cast(SyncReqKeyLike, self.client)
229
+
230
+ if self.mode in {"validate", "both"}:
231
+ consumer_key = self._consumer_key_sync(request)
232
+ if consumer_key is None:
233
+ return self._respond(401, "missing_api_key")
234
+
235
+ validation_started = time.perf_counter()
236
+ try:
237
+ decision = client.verify(
238
+ consumer_key,
239
+ api_id=self.api_id,
240
+ credits=self._credit_cost_sync(request),
241
+ resource=self._resource_path_sync(request),
242
+ )
243
+ validation_time_ms = (time.perf_counter() - validation_started) * 1000
244
+ except ReqKeyError as exc:
245
+ validation_time_ms = (time.perf_counter() - validation_started) * 1000
246
+ if self.failure_mode == "open":
247
+ request.reqkey_error = exc # type: ignore[attr-defined]
248
+ else:
249
+ logger.warning("ReqKey validation failed closed: %s", exc)
250
+ return self._respond(503, "reqkey_unavailable")
251
+
252
+ denied = self._denied_response(decision)
253
+ if denied is not None:
254
+ return denied
255
+ self._attach_decision(request, decision)
256
+
257
+ started = time.perf_counter()
258
+ try:
259
+ response = cast(SyncGetResponse, self.get_response)(request)
260
+ except Exception:
261
+ if self.mode != "validate":
262
+ self._ingest_sync(
263
+ client,
264
+ request=request,
265
+ decision=decision,
266
+ response=None,
267
+ status_code=500,
268
+ latency_ms=round((time.perf_counter() - started) * 1000),
269
+ )
270
+ raise
271
+
272
+ if self.mode != "validate":
273
+ self._ingest_sync(
274
+ client,
275
+ request=request,
276
+ decision=decision,
277
+ response=response,
278
+ status_code=response.status_code,
279
+ latency_ms=round((time.perf_counter() - started) * 1000),
280
+ )
281
+ self._add_decision_headers(response, decision, validation_time_ms)
282
+ return response
283
+
284
+ async def _acall(self, request: HttpRequest) -> HttpResponseBase:
285
+ if not self.enabled or not await self._applies_to_async(request):
286
+ return await cast(AsyncGetResponse, self.get_response)(request)
287
+
288
+ decision: VerificationResult | None = None
289
+ validation_time_ms: float | None = None
290
+ client = cast(AsyncReqKeyLike, self.client)
291
+
292
+ if self.mode in {"validate", "both"}:
293
+ consumer_key = await self._consumer_key_async(request)
294
+ if consumer_key is None:
295
+ return self._respond(401, "missing_api_key")
296
+
297
+ validation_started = time.perf_counter()
298
+ try:
299
+ decision = await client.verify(
300
+ consumer_key,
301
+ api_id=self.api_id,
302
+ credits=await self._credit_cost_async(request),
303
+ resource=await self._resource_path_async(request),
304
+ )
305
+ validation_time_ms = (time.perf_counter() - validation_started) * 1000
306
+ except ReqKeyError as exc:
307
+ validation_time_ms = (time.perf_counter() - validation_started) * 1000
308
+ if self.failure_mode == "open":
309
+ request.reqkey_error = exc # type: ignore[attr-defined]
310
+ else:
311
+ logger.warning("ReqKey validation failed closed: %s", exc)
312
+ return self._respond(503, "reqkey_unavailable")
313
+
314
+ denied = self._denied_response(decision)
315
+ if denied is not None:
316
+ return denied
317
+ self._attach_decision(request, decision)
318
+
319
+ started = time.perf_counter()
320
+ try:
321
+ response = await cast(AsyncGetResponse, self.get_response)(request)
322
+ except Exception:
323
+ if self.mode != "validate":
324
+ await self._ingest_async(
325
+ client,
326
+ request=request,
327
+ decision=decision,
328
+ response=None,
329
+ status_code=500,
330
+ latency_ms=round((time.perf_counter() - started) * 1000),
331
+ )
332
+ raise
333
+
334
+ if self.mode != "validate":
335
+ await self._ingest_async(
336
+ client,
337
+ request=request,
338
+ decision=decision,
339
+ response=response,
340
+ status_code=response.status_code,
341
+ latency_ms=round((time.perf_counter() - started) * 1000),
342
+ )
343
+ self._add_decision_headers(response, decision, validation_time_ms)
344
+ return response
345
+
346
+ def _applies_to_sync(self, request: HttpRequest) -> bool:
347
+ if (request.method or "GET").upper() in self.skip_methods:
348
+ return False
349
+ if any(path_matches(request.path, pattern) for pattern in self.exclude_paths):
350
+ return False
351
+ if self.should_protect is None:
352
+ return True
353
+ value = self.should_protect(request)
354
+ if inspect.isawaitable(value):
355
+ raise ReqKeyConfigurationError(
356
+ "A synchronous Django stack requires synchronous middleware resolvers."
357
+ )
358
+ return bool(value)
359
+
360
+ async def _applies_to_async(self, request: HttpRequest) -> bool:
361
+ if (request.method or "GET").upper() in self.skip_methods:
362
+ return False
363
+ if any(path_matches(request.path, pattern) for pattern in self.exclude_paths):
364
+ return False
365
+ if self.should_protect is None:
366
+ return True
367
+ value = self.should_protect(request)
368
+ return bool(await value) if inspect.isawaitable(value) else bool(value)
369
+
370
+ def _consumer_key_sync(self, request: HttpRequest) -> str | None:
371
+ value = self._raw_consumer_key(request)
372
+ if inspect.isawaitable(value):
373
+ raise ReqKeyConfigurationError(
374
+ "A synchronous Django stack requires synchronous middleware resolvers."
375
+ )
376
+ return extract_credential(value, self.key_scheme)
377
+
378
+ async def _consumer_key_async(self, request: HttpRequest) -> str | None:
379
+ value = self._raw_consumer_key(request)
380
+ resolved = await value if inspect.isawaitable(value) else value
381
+ return extract_credential(resolved, self.key_scheme)
382
+
383
+ def _raw_consumer_key(
384
+ self,
385
+ request: HttpRequest,
386
+ ) -> str | None | Awaitable[str | None]:
387
+ if self.get_consumer_key is not None:
388
+ return self.get_consumer_key(request)
389
+ if self.key_location == "header":
390
+ return request.headers.get(self.key_name)
391
+ if self.key_location == "query":
392
+ return request.GET.get(self.key_name)
393
+ return request.COOKIES.get(self.key_name)
394
+
395
+ def _credit_cost_sync(self, request: HttpRequest) -> int:
396
+ value = self.credits(request) if callable(self.credits) else self.credits
397
+ if inspect.isawaitable(value):
398
+ raise ReqKeyConfigurationError(
399
+ "A synchronous Django stack requires synchronous middleware resolvers."
400
+ )
401
+ return validate_credit_cost(value)
402
+
403
+ async def _credit_cost_async(self, request: HttpRequest) -> int:
404
+ value = self.credits(request) if callable(self.credits) else self.credits
405
+ resolved = await value if inspect.isawaitable(value) else value
406
+ return validate_credit_cost(resolved)
407
+
408
+ def _resource_path_sync(self, request: HttpRequest) -> str:
409
+ value = request.path if self.path_resolver is None else self.path_resolver(request)
410
+ if inspect.isawaitable(value):
411
+ raise ReqKeyConfigurationError(
412
+ "A synchronous Django stack requires synchronous middleware resolvers."
413
+ )
414
+ if not value:
415
+ raise ReqKeyConfigurationError("The path resolver returned an empty path.")
416
+ return value
417
+
418
+ async def _resource_path_async(self, request: HttpRequest) -> str:
419
+ value = request.path if self.path_resolver is None else self.path_resolver(request)
420
+ resolved = await value if inspect.isawaitable(value) else value
421
+ if not resolved:
422
+ raise ReqKeyConfigurationError("The path resolver returned an empty path.")
423
+ return resolved
424
+
425
+ def _ingest_sync(
426
+ self,
427
+ client: SyncReqKeyLike,
428
+ *,
429
+ request: HttpRequest,
430
+ decision: VerificationResult | None,
431
+ response: HttpResponseBase | None,
432
+ status_code: int,
433
+ latency_ms: int,
434
+ ) -> None:
435
+ try:
436
+ client.ingest(
437
+ self._request_id_sync(request, decision),
438
+ **self._ingest_event(
439
+ request,
440
+ response,
441
+ status_code,
442
+ latency_ms,
443
+ self._resource_path_sync(request),
444
+ ),
445
+ )
446
+ except ReqKeyError as exc:
447
+ logger.warning("ReqKey analytics ingestion failed: %s", exc)
448
+
449
+ async def _ingest_async(
450
+ self,
451
+ client: AsyncReqKeyLike,
452
+ *,
453
+ request: HttpRequest,
454
+ decision: VerificationResult | None,
455
+ response: HttpResponseBase | None,
456
+ status_code: int,
457
+ latency_ms: int,
458
+ ) -> None:
459
+ try:
460
+ await client.ingest(
461
+ await self._request_id_async(request, decision),
462
+ **self._ingest_event(
463
+ request,
464
+ response,
465
+ status_code,
466
+ latency_ms,
467
+ await self._resource_path_async(request),
468
+ ),
469
+ )
470
+ except ReqKeyError as exc:
471
+ logger.warning("ReqKey analytics ingestion failed: %s", exc)
472
+
473
+ def _ingest_event(
474
+ self,
475
+ request: HttpRequest,
476
+ response: HttpResponseBase | None,
477
+ status_code: int,
478
+ latency_ms: int,
479
+ resource_path: str,
480
+ ) -> dict[str, Any]:
481
+ query_params, query = self._captured_query(request)
482
+ request_headers = dict(request.headers.items())
483
+ response_headers = dict(response.items()) if response is not None else {}
484
+ return {
485
+ "api_id": self.api_id,
486
+ "method": request.method,
487
+ "endpoint": resource_path,
488
+ "path": resource_path + (f"?{query}" if query else ""),
489
+ "status_code": status_code,
490
+ "latency_ms": latency_ms,
491
+ "client_ip": (
492
+ str(request.META.get("REMOTE_ADDR"))
493
+ if self.capture_client_ip and request.META.get("REMOTE_ADDR") is not None
494
+ else None
495
+ ),
496
+ "user_agent": (
497
+ request.headers.get("user-agent") if self.capture_user_agent else None
498
+ ),
499
+ "query_params": query_params,
500
+ "request_headers": (
501
+ filtered_headers(request_headers, self.excluded_headers)
502
+ if self.capture_request_headers
503
+ else None
504
+ ),
505
+ "response_headers": (
506
+ filtered_headers(response_headers, self.excluded_headers)
507
+ if self.capture_response_headers
508
+ else None
509
+ ),
510
+ "response_body": self._response_body(response),
511
+ }
512
+
513
+ def _captured_query(self, request: HttpRequest) -> tuple[dict[str, str] | None, str]:
514
+ if not self.capture_query_params:
515
+ return None, ""
516
+ pairs = [
517
+ (key, value)
518
+ for key in request.GET
519
+ for value in request.GET.getlist(key)
520
+ if not (self.key_location == "query" and key == self.key_name)
521
+ ]
522
+ return dict(pairs), urlencode(pairs)
523
+
524
+ def _response_body(self, response: HttpResponseBase | None) -> str | None:
525
+ if (
526
+ not self.capture_response_body
527
+ or response is None
528
+ or response.streaming
529
+ or not isinstance(response, HttpResponse)
530
+ ):
531
+ return None
532
+ content_type = response.headers.get("content-type", "").lower()
533
+ if content_type and not any(
534
+ marker in content_type
535
+ for marker in ("json", "text/", "xml", "javascript", "x-www-form-urlencoded")
536
+ ):
537
+ return None
538
+ content = response.content
539
+ if response.headers.get("content-encoding", "").lower() == "gzip":
540
+ try:
541
+ decompressor = zlib.decompressobj(16 + zlib.MAX_WBITS)
542
+ content = decompressor.decompress(content, _MAX_CAPTURE_BYTES)
543
+ except zlib.error:
544
+ logger.debug("Could not decompress a gzip response for ReqKey analytics.")
545
+ return None
546
+ else:
547
+ content = content[:_MAX_CAPTURE_BYTES]
548
+ return content.decode("utf-8", errors="replace")[:MAX_RESPONSE_BODY_CHARACTERS]
549
+
550
+ def _request_id_sync(
551
+ self,
552
+ request: HttpRequest,
553
+ decision: VerificationResult | None,
554
+ ) -> str | None:
555
+ if decision is not None:
556
+ return decision.request_id
557
+ if self.request_id_resolver is not None:
558
+ value = self.request_id_resolver(request)
559
+ if inspect.isawaitable(value):
560
+ raise ReqKeyConfigurationError(
561
+ "A synchronous Django stack requires synchronous middleware resolvers."
562
+ )
563
+ return value or None
564
+ value = getattr(request, "reqkey_request_id", None)
565
+ return value if isinstance(value, str) else None
566
+
567
+ async def _request_id_async(
568
+ self,
569
+ request: HttpRequest,
570
+ decision: VerificationResult | None,
571
+ ) -> str | None:
572
+ if decision is not None:
573
+ return decision.request_id
574
+ if self.request_id_resolver is not None:
575
+ value = self.request_id_resolver(request)
576
+ resolved = await value if inspect.isawaitable(value) else value
577
+ return resolved or None
578
+ value = getattr(request, "reqkey_request_id", None)
579
+ return value if isinstance(value, str) else None
580
+
581
+ def _denied_response(
582
+ self,
583
+ decision: VerificationResult | None,
584
+ ) -> HttpResponseBase | None:
585
+ if decision is None or decision.valid:
586
+ return None
587
+ status_code, error = denial(decision)
588
+ response = self._respond(status_code, error)
589
+ if decision.retry_after is not None:
590
+ response["Retry-After"] = str(max(0, int(decision.retry_after)))
591
+ return response
592
+
593
+ @staticmethod
594
+ def _attach_decision(
595
+ request: HttpRequest,
596
+ decision: VerificationResult | None,
597
+ ) -> None:
598
+ if decision is None:
599
+ return
600
+ request.reqkey = decision # type: ignore[attr-defined]
601
+ request.reqkey_request_id = decision.request_id # type: ignore[attr-defined]
602
+
603
+ @staticmethod
604
+ def _add_decision_headers(
605
+ response: HttpResponseBase,
606
+ decision: VerificationResult | None,
607
+ validation_time_ms: float | None,
608
+ ) -> None:
609
+ for key, value in decision_headers(decision, validation_time_ms).items():
610
+ response[key] = value
611
+
612
+ def _respond(self, status_code: int, error: str) -> JsonResponse:
613
+ return JsonResponse(
614
+ {"error": error, "message": self.error_messages[error]},
615
+ status=status_code,
616
+ )
617
+
618
+
619
+ def _django_options() -> dict[str, Any]:
620
+ raw = getattr(settings, "REQKEY", {})
621
+ if not isinstance(raw, Mapping):
622
+ raise ReqKeyConfigurationError("The Django REQKEY setting must be a mapping.")
623
+ return {str(key).lower(): value for key, value in raw.items()}
624
+
625
+
626
+ DjangoReqKeyMiddleware = ReqKeyMiddleware
627
+
628
+ __all__ = ["DjangoReqKeyMiddleware", "ReqKeyMiddleware"]
reqkey/exceptions.py ADDED
@@ -0,0 +1,42 @@
1
+ """Exceptions raised by the ReqKey SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class ReqKeyError(Exception):
9
+ """Base class for all ReqKey SDK errors."""
10
+
11
+
12
+ class ReqKeyConfigurationError(ReqKeyError, ValueError):
13
+ """Raised when the SDK is configured with invalid values."""
14
+
15
+
16
+ class ReqKeyTransportError(ReqKeyError):
17
+ """Raised when ReqKey cannot be reached."""
18
+
19
+
20
+ class ReqKeyTimeoutError(ReqKeyTransportError):
21
+ """Raised when a request to ReqKey exceeds its configured timeout."""
22
+
23
+
24
+ class ReqKeyAPIError(ReqKeyError):
25
+ """Raised when ReqKey returns a response that is not an access decision."""
26
+
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ *,
31
+ status_code: int,
32
+ body: dict[str, Any] | None = None,
33
+ ) -> None:
34
+ super().__init__(message)
35
+ self.message = message
36
+ self.status_code = status_code
37
+ self.body = body or {}
38
+
39
+
40
+ class ReqKeyAuthenticationError(ReqKeyAPIError):
41
+ """Raised when the project credential is missing, invalid, or expired."""
42
+
reqkey/fastapi.py ADDED
@@ -0,0 +1,5 @@
1
+ """FastAPI and Starlette compatibility imports for ReqKey."""
2
+
3
+ from .asgi import ReqKeyASGIMiddleware, ReqKeyMiddleware
4
+
5
+ __all__ = ["ReqKeyASGIMiddleware", "ReqKeyMiddleware"]
reqkey/flask.py ADDED
@@ -0,0 +1,5 @@
1
+ """Flask compatibility imports for ReqKey's WSGI middleware."""
2
+
3
+ from .wsgi import ReqKeyMiddleware, ReqKeyWSGIMiddleware
4
+
5
+ __all__ = ["ReqKeyMiddleware", "ReqKeyWSGIMiddleware"]