reqkey 0.1.0__tar.gz → 0.2.1__tar.gz
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-0.1.0 → reqkey-0.2.1}/PKG-INFO +142 -7
- {reqkey-0.1.0 → reqkey-0.2.1}/README.md +141 -6
- {reqkey-0.1.0 → reqkey-0.2.1}/pyproject.toml +1 -1
- {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/__init__.py +3 -2
- {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/_middleware.py +66 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/asgi.py +145 -8
- {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/client.py +19 -1
- {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/django.py +247 -13
- {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/models.py +19 -2
- {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/wsgi.py +141 -13
- {reqkey-0.1.0 → reqkey-0.2.1}/tests/test_client.py +6 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/tests/test_django.py +188 -6
- {reqkey-0.1.0 → reqkey-0.2.1}/tests/test_fastapi.py +97 -4
- reqkey-0.2.1/tests/test_middleware.py +35 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/tests/test_wsgi.py +128 -5
- {reqkey-0.1.0 → reqkey-0.2.1}/.github/workflows/ci.yml +0 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/.github/workflows/release.yml +0 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/.gitignore +0 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/LICENSE +0 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/examples/django_settings.py +0 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/examples/fastapi_app.py +0 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/examples/flask_app.py +0 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/exceptions.py +0 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/fastapi.py +0 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/flask.py +0 -0
- {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reqkey
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: The official Python SDK for ReqKey API key validation and usage analytics
|
|
5
5
|
Project-URL: Homepage, https://reqkey.com
|
|
6
6
|
Project-URL: Documentation, https://reqkey.com/docs
|
|
@@ -76,6 +76,9 @@ Description-Content-Type: text/markdown
|
|
|
76
76
|
The official Python SDK for API key validation, credit metering, consumer rate
|
|
77
77
|
limits, and correlated API traffic analytics with ReqKey.
|
|
78
78
|
|
|
79
|
+
**Website:** [reqkey.com](https://reqkey.com) ·
|
|
80
|
+
**Documentation:** [reqkey.com/docs](https://reqkey.com/docs)
|
|
81
|
+
|
|
79
82
|
One distribution contains the shared sync/async client and optional framework
|
|
80
83
|
adapters. FastAPI, Starlette, Django, Flask, and generic ASGI/WSGI applications
|
|
81
84
|
can all use ready-made middleware without implementing ReqKey request logic.
|
|
@@ -313,7 +316,7 @@ With `mode="both"`:
|
|
|
313
316
|
Consumer request
|
|
314
317
|
→ extract consumer key
|
|
315
318
|
→ await /key/validate
|
|
316
|
-
→ denied: return 401 / 402 / 403 / 429
|
|
319
|
+
→ denied: await /ingest, then return 401 / 402 / 403 / 429
|
|
317
320
|
→ approved: run the FastAPI endpoint
|
|
318
321
|
→ collect response metadata
|
|
319
322
|
→ await /ingest with the validation requestId
|
|
@@ -328,6 +331,11 @@ starting an unreliable background task:
|
|
|
328
331
|
response.
|
|
329
332
|
- Django's native middleware ingests before returning the response object.
|
|
330
333
|
|
|
334
|
+
Denied requests are recorded by default in `mode="both"`, including requests
|
|
335
|
+
with a missing key. This makes invalid-key attempts, exhausted consumers, and
|
|
336
|
+
rate-limit hammering visible in analytics. Set `ingest_denied_requests=False`
|
|
337
|
+
if you deliberately do not want those events or their ingestion cost.
|
|
338
|
+
|
|
331
339
|
Streaming responses are forwarded chunk by chunk. The middleware keeps only
|
|
332
340
|
enough data to reconstruct the first 1,000 response-body characters and holds
|
|
333
341
|
the final ASGI completion message until ingestion finishes. It does not retain
|
|
@@ -354,6 +362,10 @@ iterator.
|
|
|
354
362
|
| `credits` | `1` | Static cost or sync/async cost resolver. |
|
|
355
363
|
| `exclude_paths` | empty | Exact paths or trailing-`*` prefix patterns. |
|
|
356
364
|
| `skip_methods` | `("OPTIONS",)` | Methods that bypass ReqKey. |
|
|
365
|
+
| `consumer_name_resolver` | — | Resolve a display name from a request header, query parameter, or another trusted source. |
|
|
366
|
+
| `client_ip_resolver` | automatic | Override client-IP extraction for a trusted proxy setup. |
|
|
367
|
+
| `on_error` | — | Receive validation or ingestion service failures without exposing request secrets. |
|
|
368
|
+
| `ingest_denied_requests` | `True` | Record denied traffic in `mode="both"`. |
|
|
357
369
|
| `failure_mode` | `"closed"` | Deny or allow when ReqKey itself is unavailable. |
|
|
358
370
|
| `error_messages` | built in | Override customer-facing denial messages. |
|
|
359
371
|
| `timeout` | `2.0` | Timeout for each ReqKey operation. |
|
|
@@ -539,6 +551,9 @@ Metadata sent by default:
|
|
|
539
551
|
- response status
|
|
540
552
|
- endpoint processing latency
|
|
541
553
|
- user agent
|
|
554
|
+
- the consumer API key in the dedicated `apiKey` field, so ReqKey can resolve
|
|
555
|
+
the internal consumer name
|
|
556
|
+
- `consumerName` when a resolver supplies one
|
|
542
557
|
|
|
543
558
|
Additional data is opt-in:
|
|
544
559
|
|
|
@@ -561,9 +576,75 @@ app.add_middleware(
|
|
|
561
576
|
```
|
|
562
577
|
|
|
563
578
|
Authorization, cookies, `Set-Cookie`, common API-key headers, and the configured
|
|
564
|
-
consumer-key header are always removed from captured headers.
|
|
565
|
-
|
|
566
|
-
|
|
579
|
+
consumer-key header are always removed from captured headers and paths. The
|
|
580
|
+
middleware sends the extracted consumer key only through `/ingest`'s dedicated
|
|
581
|
+
`apiKey` field for consumer resolution. Response-body capture is limited to
|
|
582
|
+
textual content and a hard maximum of 1,000 characters. The limit is enforced
|
|
583
|
+
again by the HTTP client before `/ingest` is called.
|
|
584
|
+
|
|
585
|
+
### Consumer names
|
|
586
|
+
|
|
587
|
+
Use `consumer_name_resolver` when an upstream gateway or your application
|
|
588
|
+
provides a useful customer label. The resolver may read any part of the
|
|
589
|
+
framework request and may be synchronous or asynchronous in ASGI/Django:
|
|
590
|
+
|
|
591
|
+
```python
|
|
592
|
+
def consumer_name(request: Request) -> str | None:
|
|
593
|
+
return (
|
|
594
|
+
request.headers.get("X-RapidAPI-User")
|
|
595
|
+
or request.query_params.get("consumerName")
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
app.add_middleware(
|
|
600
|
+
ReqKeyMiddleware,
|
|
601
|
+
project_key=os.environ["REQKEY_PROJECT_KEY"],
|
|
602
|
+
api_id="api_payments",
|
|
603
|
+
consumer_name_resolver=consumer_name,
|
|
604
|
+
)
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
The resolved value is optional analytics metadata. It is not used as the
|
|
608
|
+
consumer API key and does not change authorization. ReqKey resolves the
|
|
609
|
+
consumer display name in this order:
|
|
610
|
+
|
|
611
|
+
1. A non-empty `consumerName` supplied by the resolver.
|
|
612
|
+
2. The middleware's extracted consumer key, sent as `apiKey` for an internal
|
|
613
|
+
consumer lookup.
|
|
614
|
+
3. A `consumerId` supplied to the direct client.
|
|
615
|
+
|
|
616
|
+
No resolver is required for normal ReqKey-managed consumers. The middleware
|
|
617
|
+
still sends the validation `requestId` for log correlation, but `/ingest` does
|
|
618
|
+
not use it to resolve consumer identity.
|
|
619
|
+
|
|
620
|
+
For Django, set `CONSUMER_NAME_RESOLVER` in `REQKEY`. For WSGI, the resolver
|
|
621
|
+
receives the WSGI environment, so a header can be read as
|
|
622
|
+
`environ.get("HTTP_X_RAPIDAPI_USER")`.
|
|
623
|
+
|
|
624
|
+
### Client IPs and trusted proxies
|
|
625
|
+
|
|
626
|
+
Client-IP capture is opt-in with `capture_client_ip=True`. By default, the SDK
|
|
627
|
+
keeps the framework-provided peer address first: `request.client.host` for
|
|
628
|
+
ASGI, `REMOTE_ADDR` for WSGI, and `request.META["REMOTE_ADDR"]` for Django. It
|
|
629
|
+
only falls back when that value is absent, checking common proxy headers such
|
|
630
|
+
as `X-Forwarded-For`, `X-Real-IP`, Cloudflare, Vercel, Fly, Fastly, Azure,
|
|
631
|
+
App Engine, CloudFront, and standardized `Forwarded` headers.
|
|
632
|
+
|
|
633
|
+
If a trusted proxy always supplies the real client in a specific header, use a
|
|
634
|
+
resolver to override that default explicitly:
|
|
635
|
+
|
|
636
|
+
```python
|
|
637
|
+
app.add_middleware(
|
|
638
|
+
ReqKeyMiddleware,
|
|
639
|
+
project_key=os.environ["REQKEY_PROJECT_KEY"],
|
|
640
|
+
api_id="api_payments",
|
|
641
|
+
capture_client_ip=True,
|
|
642
|
+
client_ip_resolver=lambda request: request.headers.get("CF-Connecting-IP"),
|
|
643
|
+
)
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
Only trust forwarding headers when your deployment proxy overwrites them;
|
|
647
|
+
otherwise a caller can spoof their value.
|
|
567
648
|
|
|
568
649
|
## Custom denial messages
|
|
569
650
|
|
|
@@ -616,8 +697,9 @@ The response includes these values when available:
|
|
|
616
697
|
- `X-ReqKey-Credits-Remaining`
|
|
617
698
|
- `X-ReqKey-Validation-Time-Ms`
|
|
618
699
|
|
|
619
|
-
|
|
620
|
-
|
|
700
|
+
No CORS configuration is needed for server-to-server clients or for the server
|
|
701
|
+
itself to set these headers. Only cross-origin browser JavaScript that must read
|
|
702
|
+
them needs the names added to the CORS middleware's `expose_headers` list.
|
|
621
703
|
|
|
622
704
|
## Availability behavior
|
|
623
705
|
|
|
@@ -636,6 +718,44 @@ but it will not be correlated to a validated consumer.
|
|
|
636
718
|
The SDK does not automatically retry `/key/validate`, because validation can
|
|
637
719
|
deduct credits. Safe automatic retries require server-side idempotency.
|
|
638
720
|
|
|
721
|
+
### Failure alerts
|
|
722
|
+
|
|
723
|
+
Webhook payload formats differ between Discord, Slack, Teams, and custom alert
|
|
724
|
+
receivers, so the SDK does not post one supposed universal webhook format.
|
|
725
|
+
Instead, `on_error` receives a provider-neutral `MiddlewareErrorEvent` for
|
|
726
|
+
ReqKey validation or ingestion failures:
|
|
727
|
+
|
|
728
|
+
```python
|
|
729
|
+
from reqkey import MiddlewareErrorEvent
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
async def report_reqkey_failure(event: MiddlewareErrorEvent) -> None:
|
|
733
|
+
await send_to_your_alert_provider(
|
|
734
|
+
{
|
|
735
|
+
"operation": event.operation,
|
|
736
|
+
"message": event.message,
|
|
737
|
+
"method": event.method,
|
|
738
|
+
"path": event.path,
|
|
739
|
+
"request_id": event.request_id,
|
|
740
|
+
"status_code": event.status_code,
|
|
741
|
+
}
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
app.add_middleware(
|
|
746
|
+
ReqKeyMiddleware,
|
|
747
|
+
project_key=os.environ["REQKEY_PROJECT_KEY"],
|
|
748
|
+
api_id="api_payments",
|
|
749
|
+
on_error=report_reqkey_failure,
|
|
750
|
+
)
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
The event intentionally excludes API keys, project credentials, headers,
|
|
754
|
+
query parameters, and bodies. Exceptions raised by the callback are logged and
|
|
755
|
+
do not replace the application response. WSGI callbacks are synchronous;
|
|
756
|
+
ASGI callbacks may be synchronous or asynchronous, and Django follows the
|
|
757
|
+
sync/async mode of its middleware chain.
|
|
758
|
+
|
|
639
759
|
## Direct sync and async clients
|
|
640
760
|
|
|
641
761
|
Async:
|
|
@@ -661,6 +781,21 @@ with ReqKey(project_key=os.environ["REQKEY_PROJECT_KEY"]) as reqkey:
|
|
|
661
781
|
decision = reqkey.verify("consumer_key_...", api_id="api_payments")
|
|
662
782
|
```
|
|
663
783
|
|
|
784
|
+
Direct ingestion accepts the same consumer identity inputs as `/ingest`:
|
|
785
|
+
|
|
786
|
+
```python
|
|
787
|
+
reqkey.ingest(
|
|
788
|
+
decision.request_id,
|
|
789
|
+
api_id="api_payments",
|
|
790
|
+
method="POST",
|
|
791
|
+
path="/payments",
|
|
792
|
+
status_code=201,
|
|
793
|
+
api_key="consumer_key_...", # resolves the internal consumer name
|
|
794
|
+
# consumer_name="rapidapi-user", # optional explicit override
|
|
795
|
+
# consumer_id="consumer_...", # fallback when no API key is available
|
|
796
|
+
)
|
|
797
|
+
```
|
|
798
|
+
|
|
664
799
|
ASGI and FastAPI use the asynchronous client and await both network operations.
|
|
665
800
|
WSGI uses the synchronous client. Django automatically selects the correct
|
|
666
801
|
client based on its middleware chain, avoiding blocking HTTP calls inside an
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
The official Python SDK for API key validation, credit metering, consumer rate
|
|
4
4
|
limits, and correlated API traffic analytics with ReqKey.
|
|
5
5
|
|
|
6
|
+
**Website:** [reqkey.com](https://reqkey.com) ·
|
|
7
|
+
**Documentation:** [reqkey.com/docs](https://reqkey.com/docs)
|
|
8
|
+
|
|
6
9
|
One distribution contains the shared sync/async client and optional framework
|
|
7
10
|
adapters. FastAPI, Starlette, Django, Flask, and generic ASGI/WSGI applications
|
|
8
11
|
can all use ready-made middleware without implementing ReqKey request logic.
|
|
@@ -240,7 +243,7 @@ With `mode="both"`:
|
|
|
240
243
|
Consumer request
|
|
241
244
|
→ extract consumer key
|
|
242
245
|
→ await /key/validate
|
|
243
|
-
→ denied: return 401 / 402 / 403 / 429
|
|
246
|
+
→ denied: await /ingest, then return 401 / 402 / 403 / 429
|
|
244
247
|
→ approved: run the FastAPI endpoint
|
|
245
248
|
→ collect response metadata
|
|
246
249
|
→ await /ingest with the validation requestId
|
|
@@ -255,6 +258,11 @@ starting an unreliable background task:
|
|
|
255
258
|
response.
|
|
256
259
|
- Django's native middleware ingests before returning the response object.
|
|
257
260
|
|
|
261
|
+
Denied requests are recorded by default in `mode="both"`, including requests
|
|
262
|
+
with a missing key. This makes invalid-key attempts, exhausted consumers, and
|
|
263
|
+
rate-limit hammering visible in analytics. Set `ingest_denied_requests=False`
|
|
264
|
+
if you deliberately do not want those events or their ingestion cost.
|
|
265
|
+
|
|
258
266
|
Streaming responses are forwarded chunk by chunk. The middleware keeps only
|
|
259
267
|
enough data to reconstruct the first 1,000 response-body characters and holds
|
|
260
268
|
the final ASGI completion message until ingestion finishes. It does not retain
|
|
@@ -281,6 +289,10 @@ iterator.
|
|
|
281
289
|
| `credits` | `1` | Static cost or sync/async cost resolver. |
|
|
282
290
|
| `exclude_paths` | empty | Exact paths or trailing-`*` prefix patterns. |
|
|
283
291
|
| `skip_methods` | `("OPTIONS",)` | Methods that bypass ReqKey. |
|
|
292
|
+
| `consumer_name_resolver` | — | Resolve a display name from a request header, query parameter, or another trusted source. |
|
|
293
|
+
| `client_ip_resolver` | automatic | Override client-IP extraction for a trusted proxy setup. |
|
|
294
|
+
| `on_error` | — | Receive validation or ingestion service failures without exposing request secrets. |
|
|
295
|
+
| `ingest_denied_requests` | `True` | Record denied traffic in `mode="both"`. |
|
|
284
296
|
| `failure_mode` | `"closed"` | Deny or allow when ReqKey itself is unavailable. |
|
|
285
297
|
| `error_messages` | built in | Override customer-facing denial messages. |
|
|
286
298
|
| `timeout` | `2.0` | Timeout for each ReqKey operation. |
|
|
@@ -466,6 +478,9 @@ Metadata sent by default:
|
|
|
466
478
|
- response status
|
|
467
479
|
- endpoint processing latency
|
|
468
480
|
- user agent
|
|
481
|
+
- the consumer API key in the dedicated `apiKey` field, so ReqKey can resolve
|
|
482
|
+
the internal consumer name
|
|
483
|
+
- `consumerName` when a resolver supplies one
|
|
469
484
|
|
|
470
485
|
Additional data is opt-in:
|
|
471
486
|
|
|
@@ -488,9 +503,75 @@ app.add_middleware(
|
|
|
488
503
|
```
|
|
489
504
|
|
|
490
505
|
Authorization, cookies, `Set-Cookie`, common API-key headers, and the configured
|
|
491
|
-
consumer-key header are always removed from captured headers.
|
|
492
|
-
|
|
493
|
-
|
|
506
|
+
consumer-key header are always removed from captured headers and paths. The
|
|
507
|
+
middleware sends the extracted consumer key only through `/ingest`'s dedicated
|
|
508
|
+
`apiKey` field for consumer resolution. Response-body capture is limited to
|
|
509
|
+
textual content and a hard maximum of 1,000 characters. The limit is enforced
|
|
510
|
+
again by the HTTP client before `/ingest` is called.
|
|
511
|
+
|
|
512
|
+
### Consumer names
|
|
513
|
+
|
|
514
|
+
Use `consumer_name_resolver` when an upstream gateway or your application
|
|
515
|
+
provides a useful customer label. The resolver may read any part of the
|
|
516
|
+
framework request and may be synchronous or asynchronous in ASGI/Django:
|
|
517
|
+
|
|
518
|
+
```python
|
|
519
|
+
def consumer_name(request: Request) -> str | None:
|
|
520
|
+
return (
|
|
521
|
+
request.headers.get("X-RapidAPI-User")
|
|
522
|
+
or request.query_params.get("consumerName")
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
app.add_middleware(
|
|
527
|
+
ReqKeyMiddleware,
|
|
528
|
+
project_key=os.environ["REQKEY_PROJECT_KEY"],
|
|
529
|
+
api_id="api_payments",
|
|
530
|
+
consumer_name_resolver=consumer_name,
|
|
531
|
+
)
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
The resolved value is optional analytics metadata. It is not used as the
|
|
535
|
+
consumer API key and does not change authorization. ReqKey resolves the
|
|
536
|
+
consumer display name in this order:
|
|
537
|
+
|
|
538
|
+
1. A non-empty `consumerName` supplied by the resolver.
|
|
539
|
+
2. The middleware's extracted consumer key, sent as `apiKey` for an internal
|
|
540
|
+
consumer lookup.
|
|
541
|
+
3. A `consumerId` supplied to the direct client.
|
|
542
|
+
|
|
543
|
+
No resolver is required for normal ReqKey-managed consumers. The middleware
|
|
544
|
+
still sends the validation `requestId` for log correlation, but `/ingest` does
|
|
545
|
+
not use it to resolve consumer identity.
|
|
546
|
+
|
|
547
|
+
For Django, set `CONSUMER_NAME_RESOLVER` in `REQKEY`. For WSGI, the resolver
|
|
548
|
+
receives the WSGI environment, so a header can be read as
|
|
549
|
+
`environ.get("HTTP_X_RAPIDAPI_USER")`.
|
|
550
|
+
|
|
551
|
+
### Client IPs and trusted proxies
|
|
552
|
+
|
|
553
|
+
Client-IP capture is opt-in with `capture_client_ip=True`. By default, the SDK
|
|
554
|
+
keeps the framework-provided peer address first: `request.client.host` for
|
|
555
|
+
ASGI, `REMOTE_ADDR` for WSGI, and `request.META["REMOTE_ADDR"]` for Django. It
|
|
556
|
+
only falls back when that value is absent, checking common proxy headers such
|
|
557
|
+
as `X-Forwarded-For`, `X-Real-IP`, Cloudflare, Vercel, Fly, Fastly, Azure,
|
|
558
|
+
App Engine, CloudFront, and standardized `Forwarded` headers.
|
|
559
|
+
|
|
560
|
+
If a trusted proxy always supplies the real client in a specific header, use a
|
|
561
|
+
resolver to override that default explicitly:
|
|
562
|
+
|
|
563
|
+
```python
|
|
564
|
+
app.add_middleware(
|
|
565
|
+
ReqKeyMiddleware,
|
|
566
|
+
project_key=os.environ["REQKEY_PROJECT_KEY"],
|
|
567
|
+
api_id="api_payments",
|
|
568
|
+
capture_client_ip=True,
|
|
569
|
+
client_ip_resolver=lambda request: request.headers.get("CF-Connecting-IP"),
|
|
570
|
+
)
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
Only trust forwarding headers when your deployment proxy overwrites them;
|
|
574
|
+
otherwise a caller can spoof their value.
|
|
494
575
|
|
|
495
576
|
## Custom denial messages
|
|
496
577
|
|
|
@@ -543,8 +624,9 @@ The response includes these values when available:
|
|
|
543
624
|
- `X-ReqKey-Credits-Remaining`
|
|
544
625
|
- `X-ReqKey-Validation-Time-Ms`
|
|
545
626
|
|
|
546
|
-
|
|
547
|
-
|
|
627
|
+
No CORS configuration is needed for server-to-server clients or for the server
|
|
628
|
+
itself to set these headers. Only cross-origin browser JavaScript that must read
|
|
629
|
+
them needs the names added to the CORS middleware's `expose_headers` list.
|
|
548
630
|
|
|
549
631
|
## Availability behavior
|
|
550
632
|
|
|
@@ -563,6 +645,44 @@ but it will not be correlated to a validated consumer.
|
|
|
563
645
|
The SDK does not automatically retry `/key/validate`, because validation can
|
|
564
646
|
deduct credits. Safe automatic retries require server-side idempotency.
|
|
565
647
|
|
|
648
|
+
### Failure alerts
|
|
649
|
+
|
|
650
|
+
Webhook payload formats differ between Discord, Slack, Teams, and custom alert
|
|
651
|
+
receivers, so the SDK does not post one supposed universal webhook format.
|
|
652
|
+
Instead, `on_error` receives a provider-neutral `MiddlewareErrorEvent` for
|
|
653
|
+
ReqKey validation or ingestion failures:
|
|
654
|
+
|
|
655
|
+
```python
|
|
656
|
+
from reqkey import MiddlewareErrorEvent
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
async def report_reqkey_failure(event: MiddlewareErrorEvent) -> None:
|
|
660
|
+
await send_to_your_alert_provider(
|
|
661
|
+
{
|
|
662
|
+
"operation": event.operation,
|
|
663
|
+
"message": event.message,
|
|
664
|
+
"method": event.method,
|
|
665
|
+
"path": event.path,
|
|
666
|
+
"request_id": event.request_id,
|
|
667
|
+
"status_code": event.status_code,
|
|
668
|
+
}
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
app.add_middleware(
|
|
673
|
+
ReqKeyMiddleware,
|
|
674
|
+
project_key=os.environ["REQKEY_PROJECT_KEY"],
|
|
675
|
+
api_id="api_payments",
|
|
676
|
+
on_error=report_reqkey_failure,
|
|
677
|
+
)
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
The event intentionally excludes API keys, project credentials, headers,
|
|
681
|
+
query parameters, and bodies. Exceptions raised by the callback are logged and
|
|
682
|
+
do not replace the application response. WSGI callbacks are synchronous;
|
|
683
|
+
ASGI callbacks may be synchronous or asynchronous, and Django follows the
|
|
684
|
+
sync/async mode of its middleware chain.
|
|
685
|
+
|
|
566
686
|
## Direct sync and async clients
|
|
567
687
|
|
|
568
688
|
Async:
|
|
@@ -588,6 +708,21 @@ with ReqKey(project_key=os.environ["REQKEY_PROJECT_KEY"]) as reqkey:
|
|
|
588
708
|
decision = reqkey.verify("consumer_key_...", api_id="api_payments")
|
|
589
709
|
```
|
|
590
710
|
|
|
711
|
+
Direct ingestion accepts the same consumer identity inputs as `/ingest`:
|
|
712
|
+
|
|
713
|
+
```python
|
|
714
|
+
reqkey.ingest(
|
|
715
|
+
decision.request_id,
|
|
716
|
+
api_id="api_payments",
|
|
717
|
+
method="POST",
|
|
718
|
+
path="/payments",
|
|
719
|
+
status_code=201,
|
|
720
|
+
api_key="consumer_key_...", # resolves the internal consumer name
|
|
721
|
+
# consumer_name="rapidapi-user", # optional explicit override
|
|
722
|
+
# consumer_id="consumer_...", # fallback when no API key is available
|
|
723
|
+
)
|
|
724
|
+
```
|
|
725
|
+
|
|
591
726
|
ASGI and FastAPI use the asynchronous client and await both network operations.
|
|
592
727
|
WSGI uses the synchronous client. Django automatically selects the correct
|
|
593
728
|
client based on its middleware chain, avoiding blocking HTTP calls inside an
|
|
@@ -9,12 +9,13 @@ from .exceptions import (
|
|
|
9
9
|
ReqKeyTimeoutError,
|
|
10
10
|
ReqKeyTransportError,
|
|
11
11
|
)
|
|
12
|
-
from .models import VerificationReason, VerificationResult
|
|
12
|
+
from .models import MiddlewareErrorEvent, VerificationReason, VerificationResult
|
|
13
13
|
|
|
14
|
-
__version__ = "0.1
|
|
14
|
+
__version__ = "0.2.1"
|
|
15
15
|
|
|
16
16
|
__all__ = [
|
|
17
17
|
"AsyncReqKey",
|
|
18
|
+
"MiddlewareErrorEvent",
|
|
18
19
|
"ReqKey",
|
|
19
20
|
"ReqKeyAPIError",
|
|
20
21
|
"ReqKeyAuthenticationError",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import ipaddress
|
|
5
6
|
from collections.abc import Iterable, Mapping
|
|
6
7
|
from typing import Literal
|
|
7
8
|
|
|
@@ -32,6 +33,20 @@ DEFAULT_EXCLUDED_HEADERS = frozenset(
|
|
|
32
33
|
}
|
|
33
34
|
)
|
|
34
35
|
|
|
36
|
+
PROXY_CLIENT_IP_HEADERS = (
|
|
37
|
+
"x-forwarded-for",
|
|
38
|
+
"x-real-ip",
|
|
39
|
+
"cf-connecting-ip",
|
|
40
|
+
"true-client-ip",
|
|
41
|
+
"x-vercel-forwarded-for",
|
|
42
|
+
"fly-client-ip",
|
|
43
|
+
"fastly-client-ip",
|
|
44
|
+
"x-azure-clientip",
|
|
45
|
+
"x-appengine-user-ip",
|
|
46
|
+
"cloudfront-viewer-address",
|
|
47
|
+
"forwarded",
|
|
48
|
+
)
|
|
49
|
+
|
|
35
50
|
|
|
36
51
|
def validate_middleware_options(
|
|
37
52
|
*,
|
|
@@ -130,3 +145,54 @@ def filtered_headers(
|
|
|
130
145
|
for key, value in headers.items()
|
|
131
146
|
if key.lower() not in excluded
|
|
132
147
|
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def client_ip_from_headers(headers: Mapping[str, str]) -> str | None:
|
|
151
|
+
"""Return the first valid IP from common proxy headers.
|
|
152
|
+
|
|
153
|
+
Middleware adapters use this only when the framework did not provide a
|
|
154
|
+
client address. Applications that intentionally trust a proxy header can
|
|
155
|
+
override the behavior with ``client_ip_resolver``.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
normalized = {key.lower(): value for key, value in headers.items()}
|
|
159
|
+
for name in PROXY_CLIENT_IP_HEADERS:
|
|
160
|
+
value = normalized.get(name)
|
|
161
|
+
if not value:
|
|
162
|
+
continue
|
|
163
|
+
candidates = _forwarded_candidates(value) if name == "forwarded" else value.split(",")
|
|
164
|
+
for candidate in candidates:
|
|
165
|
+
parsed = _parse_ip(candidate)
|
|
166
|
+
if parsed is not None:
|
|
167
|
+
return parsed
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _forwarded_candidates(value: str) -> list[str]:
|
|
172
|
+
candidates: list[str] = []
|
|
173
|
+
for element in value.split(","):
|
|
174
|
+
for parameter in element.split(";"):
|
|
175
|
+
name, separator, raw = parameter.strip().partition("=")
|
|
176
|
+
if separator and name.lower() == "for":
|
|
177
|
+
candidates.append(raw)
|
|
178
|
+
return candidates
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_ip(value: str) -> str | None:
|
|
182
|
+
candidate = value.strip().strip('"')
|
|
183
|
+
if not candidate or candidate.lower() == "unknown" or candidate.startswith("_"):
|
|
184
|
+
return None
|
|
185
|
+
if candidate.startswith("["):
|
|
186
|
+
end = candidate.find("]")
|
|
187
|
+
if end > 0:
|
|
188
|
+
candidate = candidate[1:end]
|
|
189
|
+
else:
|
|
190
|
+
try:
|
|
191
|
+
return str(ipaddress.ip_address(candidate))
|
|
192
|
+
except ValueError:
|
|
193
|
+
if candidate.count(":") == 1:
|
|
194
|
+
candidate = candidate.partition(":")[0]
|
|
195
|
+
try:
|
|
196
|
+
return str(ipaddress.ip_address(candidate))
|
|
197
|
+
except ValueError:
|
|
198
|
+
return None
|