django-simple-api-proxy 2.0.0__tar.gz → 2.0.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.
- {django_simple_api_proxy-2.0.0/src/django_simple_api_proxy.egg-info → django_simple_api_proxy-2.0.1}/PKG-INFO +9 -9
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/README.md +8 -8
- django_simple_api_proxy-2.0.1/src/django_simple_api_proxy/__init__.py +1 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/checks.py +5 -5
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/headers.py +8 -3
- django_simple_api_proxy-2.0.1/src/django_simple_api_proxy/json_mode.py +202 -0
- django_simple_api_proxy-2.0.1/src/django_simple_api_proxy/pass_through.py +308 -0
- django_simple_api_proxy-2.0.1/src/django_simple_api_proxy/views.py +34 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1/src/django_simple_api_proxy.egg-info}/PKG-INFO +9 -9
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy.egg-info/SOURCES.txt +2 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/tests/test_pass_through.py +15 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/tests/test_settings.py +30 -20
- django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/__init__.py +0 -1
- django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/views.py +0 -415
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/LICENSE +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/MANIFEST.in +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/docs/images/hero-dark.webp +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/docs/images/hero-light.webp +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/docs/images/request-flow.html +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/docs/images/request-flow.png +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/docs/images/src/hero.html +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/example/api_proxy_example/api_proxy_example/__init__.py +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/example/api_proxy_example/api_proxy_example/asgi.py +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/example/api_proxy_example/api_proxy_example/settings.py +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/example/api_proxy_example/api_proxy_example/urls.py +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/example/api_proxy_example/api_proxy_example/wsgi.py +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/example/api_proxy_example/manage.py +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/pyproject.toml +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/setup.cfg +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/setup.py +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/apps.py +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/conf.py +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/migrations/__init__.py +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/py.typed +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/urls.py +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy.egg-info/dependency_links.txt +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy.egg-info/requires.txt +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy.egg-info/top_level.txt +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/tests/test_api_proxy.py +0 -0
- {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/tests/test_readme.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-simple-api-proxy
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: Easy to proxy any APIs you want.
|
|
5
5
|
Home-page: https://github.com/NatLee/django-simple-api-proxy
|
|
6
6
|
Author: Nat Lee
|
|
@@ -448,19 +448,19 @@ These are why pass-through became the default; JSON mode keeps them for compatib
|
|
|
448
448
|
The proxy logs through loguru: every request at `DEBUG` level, failures at `ERROR` level with the reason and the traceback. Request bodies are logged by size or field name only, never by value, and headers by name only. The `PUT` from [What goes through](#what-goes-through) logs this:
|
|
449
449
|
|
|
450
450
|
```log
|
|
451
|
-
2026-11-03
|
|
452
|
-
2026-11-03
|
|
453
|
-
2026-11-03
|
|
454
|
-
2026-11-03
|
|
451
|
+
2026-11-03 16:39:45.063 | DEBUG | django_simple_api_proxy.pass_through:_forward:258 - Proxy PUT /my_test_route/anything/items
|
|
452
|
+
2026-11-03 16:39:45.064 | DEBUG | django_simple_api_proxy.pass_through:get_proxy_path:135 - Upstream URL: https://httpbin.org/anything/items
|
|
453
|
+
2026-11-03 16:39:45.064 | DEBUG | django_simple_api_proxy.pass_through:_forward:265 - Forwarding headers ['Accept', 'Content-Type', 'User-Agent', 'X-Hello'] and 306 bytes of body
|
|
454
|
+
2026-11-03 16:39:45.714 | DEBUG | django_simple_api_proxy.pass_through:_forward:275 - API server answered 200 (application/json, 557 bytes)
|
|
455
455
|
```
|
|
456
456
|
|
|
457
457
|
and a `POST` of `{"user": "nat", "password": "hunter2"}` in JSON mode this:
|
|
458
458
|
|
|
459
459
|
```log
|
|
460
|
-
2026-11-03
|
|
461
|
-
2026-11-03
|
|
462
|
-
2026-11-03
|
|
463
|
-
2026-11-03
|
|
460
|
+
2026-11-03 16:40:00.380 | DEBUG | django_simple_api_proxy.json_mode:_forward:171 - Proxy POST /my_test_route/
|
|
461
|
+
2026-11-03 16:40:00.383 | DEBUG | django_simple_api_proxy.json_mode:preprocess_request_body:101 - JSON body fields: ['user', 'password']
|
|
462
|
+
2026-11-03 16:40:00.383 | DEBUG | django_simple_api_proxy.json_mode:get_proxy_path:146 - Upstream URL: https://httpbin.org/anything
|
|
463
|
+
2026-11-03 16:40:00.383 | DEBUG | django_simple_api_proxy.json_mode:update_payload:84 - Username: #anonymous
|
|
464
464
|
```
|
|
465
465
|
|
|
466
466
|
Unless you configure it, loguru prints all of this to stderr. Its default handler also prints the values of local variables in tracebacks (`diagnose=True`), so when a request fails, its body can still show up in the traceback. If bodies may hold passwords or tokens, turn the proxy's log off, or keep only `INFO` and up without the variable values:
|
|
@@ -388,19 +388,19 @@ These are why pass-through became the default; JSON mode keeps them for compatib
|
|
|
388
388
|
The proxy logs through loguru: every request at `DEBUG` level, failures at `ERROR` level with the reason and the traceback. Request bodies are logged by size or field name only, never by value, and headers by name only. The `PUT` from [What goes through](#what-goes-through) logs this:
|
|
389
389
|
|
|
390
390
|
```log
|
|
391
|
-
2026-11-03
|
|
392
|
-
2026-11-03
|
|
393
|
-
2026-11-03
|
|
394
|
-
2026-11-03
|
|
391
|
+
2026-11-03 16:39:45.063 | DEBUG | django_simple_api_proxy.pass_through:_forward:258 - Proxy PUT /my_test_route/anything/items
|
|
392
|
+
2026-11-03 16:39:45.064 | DEBUG | django_simple_api_proxy.pass_through:get_proxy_path:135 - Upstream URL: https://httpbin.org/anything/items
|
|
393
|
+
2026-11-03 16:39:45.064 | DEBUG | django_simple_api_proxy.pass_through:_forward:265 - Forwarding headers ['Accept', 'Content-Type', 'User-Agent', 'X-Hello'] and 306 bytes of body
|
|
394
|
+
2026-11-03 16:39:45.714 | DEBUG | django_simple_api_proxy.pass_through:_forward:275 - API server answered 200 (application/json, 557 bytes)
|
|
395
395
|
```
|
|
396
396
|
|
|
397
397
|
and a `POST` of `{"user": "nat", "password": "hunter2"}` in JSON mode this:
|
|
398
398
|
|
|
399
399
|
```log
|
|
400
|
-
2026-11-03
|
|
401
|
-
2026-11-03
|
|
402
|
-
2026-11-03
|
|
403
|
-
2026-11-03
|
|
400
|
+
2026-11-03 16:40:00.380 | DEBUG | django_simple_api_proxy.json_mode:_forward:171 - Proxy POST /my_test_route/
|
|
401
|
+
2026-11-03 16:40:00.383 | DEBUG | django_simple_api_proxy.json_mode:preprocess_request_body:101 - JSON body fields: ['user', 'password']
|
|
402
|
+
2026-11-03 16:40:00.383 | DEBUG | django_simple_api_proxy.json_mode:get_proxy_path:146 - Upstream URL: https://httpbin.org/anything
|
|
403
|
+
2026-11-03 16:40:00.383 | DEBUG | django_simple_api_proxy.json_mode:update_payload:84 - Username: #anonymous
|
|
404
404
|
```
|
|
405
405
|
|
|
406
406
|
Unless you configure it, loguru prints all of this to stderr. Its default handler also prints the values of local variables in tracebacks (`diagnose=True`), so when a request fails, its body can still show up in the traceback. If bodies may hold passwords or tokens, turn the proxy's log off, or keep only `INFO` and up without the variable values:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '2.0.1'
|
|
@@ -20,6 +20,9 @@ from django_simple_api_proxy.conf import pass_through_enabled
|
|
|
20
20
|
|
|
21
21
|
_ID = "django_simple_api_proxy."
|
|
22
22
|
|
|
23
|
+
#: The schemes requests can send to.
|
|
24
|
+
URL_SCHEMES = ("http://", "https://")
|
|
25
|
+
|
|
23
26
|
|
|
24
27
|
def check_settings(app_configs: Any = None, **kwargs: Any) -> list[checks.CheckMessage]:
|
|
25
28
|
"""Warn about proxy settings that send requests to the wrong place."""
|
|
@@ -30,7 +33,7 @@ def check_settings(app_configs: Any = None, **kwargs: Any) -> list[checks.CheckM
|
|
|
30
33
|
# Pass-through mode trims the slashes itself, so only the scheme matters there.
|
|
31
34
|
exact_paths = not pass_through_enabled()
|
|
32
35
|
|
|
33
|
-
if isinstance(url, str) and not url.startswith(
|
|
36
|
+
if isinstance(url, str) and not url.startswith(URL_SCHEMES):
|
|
34
37
|
warnings.append(checks.Warning(
|
|
35
38
|
"TARGET_API_URL does not start with http:// or https://, "
|
|
36
39
|
"so no request can reach it.",
|
|
@@ -63,10 +66,6 @@ def check_settings(app_configs: Any = None, **kwargs: Any) -> list[checks.CheckM
|
|
|
63
66
|
return warnings
|
|
64
67
|
|
|
65
68
|
|
|
66
|
-
#: Methods only JSON mode calls; in pass-through mode an override of one does nothing.
|
|
67
|
-
JSON_MODE_HOOKS = ("update_payload", "preprocess_request_body", "response")
|
|
68
|
-
|
|
69
|
-
|
|
70
69
|
def _subclasses(cls: Any) -> Iterator[Any]: # APIProxy, imported late (it needs the settings)
|
|
71
70
|
for sub in cls.__subclasses__():
|
|
72
71
|
yield sub
|
|
@@ -76,6 +75,7 @@ def _subclasses(cls: Any) -> Iterator[Any]: # APIProxy, imported late (it needs
|
|
|
76
75
|
def check_json_mode_hooks(app_configs: Any = None, **kwargs: Any) -> list[checks.CheckMessage]:
|
|
77
76
|
"""Warn about subclasses that override JSON-mode methods but run in pass-through mode."""
|
|
78
77
|
try:
|
|
78
|
+
from django_simple_api_proxy.json_mode import JSON_MODE_HOOKS
|
|
79
79
|
from django_simple_api_proxy.views import APIProxy
|
|
80
80
|
except ImproperlyConfigured:
|
|
81
81
|
return [] # a setting is missing, and Django reports that already
|
|
@@ -19,6 +19,11 @@ HOP_BY_HOP_HEADERS = frozenset({
|
|
|
19
19
|
#: already answered ``Expect: 100-continue``.
|
|
20
20
|
NOT_FORWARDED_REQUEST_HEADERS = frozenset({"host", "content-length", "accept-encoding", "expect"})
|
|
21
21
|
|
|
22
|
+
#: The Content-Type that Django's runserver (Python's wsgiref) fills in for requests
|
|
23
|
+
#: that have none. On a request without a body it came from the development server,
|
|
24
|
+
#: not from the client, so it stays behind.
|
|
25
|
+
RUNSERVER_DEFAULT_CONTENT_TYPE = "text/plain"
|
|
26
|
+
|
|
22
27
|
#: Response headers that no longer fit once requests has read and decoded the reply,
|
|
23
28
|
#: or that your web server sets itself (nginx hides ``Date`` and ``Server`` too).
|
|
24
29
|
#: ``Set-Cookie`` goes through separately, one cookie at a time.
|
|
@@ -44,14 +49,14 @@ def _without(headers: Mapping[str, str], names: Iterable[str]) -> dict[str, str]
|
|
|
44
49
|
def request_headers_to_forward(headers: Mapping[str, str], has_body: bool) -> dict[str, str]:
|
|
45
50
|
"""The client's headers that go on to the API server.
|
|
46
51
|
|
|
47
|
-
Without a body,
|
|
48
|
-
|
|
52
|
+
Without a body, the Content-Type runserver makes up (RUNSERVER_DEFAULT_CONTENT_TYPE)
|
|
53
|
+
stays behind.
|
|
49
54
|
"""
|
|
50
55
|
forwarded = _without(headers, NOT_FORWARDED_REQUEST_HEADERS)
|
|
51
56
|
if not has_body:
|
|
52
57
|
forwarded = {
|
|
53
58
|
name: value for name, value in forwarded.items()
|
|
54
|
-
if not (name.lower() == "content-type" and value ==
|
|
59
|
+
if not (name.lower() == "content-type" and value == RUNSERVER_DEFAULT_CONTENT_TYPE)
|
|
55
60
|
}
|
|
56
61
|
return forwarded
|
|
57
62
|
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""JSON mode: the proxy as it worked in 1.x, kept as a compatibility layer.
|
|
2
|
+
|
|
3
|
+
Why it is kept: 2.0 made pass-through mode the default, which changes what clients
|
|
4
|
+
get (the API server's status codes, any content type, no ``username``). With
|
|
5
|
+
``PROXY_PASS_THROUGH = False``, or ``pass_through = False`` on a subclass, a project
|
|
6
|
+
upgrades and keeps the 1.x answers until it is ready to switch.
|
|
7
|
+
|
|
8
|
+
How it fits in: ``JSONMode`` sits on top of ``PassThroughView``. In the few steps
|
|
9
|
+
where the two modes differ it checks ``pass_through`` and either does what 1.x did
|
|
10
|
+
or hands the step down to pass-through mode, which knows nothing about this module.
|
|
11
|
+
``APIProxy`` (views.py) is ``JSONMode`` under its public name.
|
|
12
|
+
|
|
13
|
+
When it can go: in a major release, once projects no longer set
|
|
14
|
+
``PROXY_PASS_THROUGH = False``. Removing it means deleting this module, the JSON-mode
|
|
15
|
+
checks in checks.py (W002-W005) and the 1.x tests in tests/test_api_proxy.py, and
|
|
16
|
+
basing ``APIProxy`` on ``PassThroughView``.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
from typing import Any
|
|
23
|
+
from urllib.parse import urlparse
|
|
24
|
+
|
|
25
|
+
import requests
|
|
26
|
+
from django.http import HttpResponse, JsonResponse
|
|
27
|
+
from loguru import logger
|
|
28
|
+
from rest_framework.request import Request
|
|
29
|
+
|
|
30
|
+
from django_simple_api_proxy import conf
|
|
31
|
+
from django_simple_api_proxy.pass_through import (
|
|
32
|
+
ERROR_REPLY,
|
|
33
|
+
ERROR_REPLY_TEXT,
|
|
34
|
+
PassThroughView,
|
|
35
|
+
_content_type,
|
|
36
|
+
_failure_reason,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
#: Sent as ``username`` when the request has no authenticated user.
|
|
40
|
+
ANONYMOUS_USERNAME = "#anonymous"
|
|
41
|
+
|
|
42
|
+
#: The methods only JSON mode calls. A subclass in pass-through mode that overrides
|
|
43
|
+
#: one of them gets warning W005, because the override does nothing there.
|
|
44
|
+
JSON_MODE_HOOKS = ("update_payload", "preprocess_request_body", "response")
|
|
45
|
+
|
|
46
|
+
_JSON_TYPE_NAMES = {list: "an array", str: "a string", int: "a number", float: "a number",
|
|
47
|
+
bool: "true or false", type(None): "null"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ProxyError(ValueError):
|
|
51
|
+
"""A request JSON mode cannot forward; the message says why, for the log."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _field_names(body: Any) -> str:
|
|
55
|
+
"""Describe a decoded JSON body for the log without any of its values."""
|
|
56
|
+
if isinstance(body, dict):
|
|
57
|
+
return str(list(body))
|
|
58
|
+
return "(not a JSON object)"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class JSONMode(PassThroughView):
|
|
62
|
+
"""JSON in, JSON out, as in 1.x, when ``pass_through`` is off."""
|
|
63
|
+
|
|
64
|
+
#: True (the default since 2.0) passes everything through; False brings back
|
|
65
|
+
#: the JSON mode of 1.x.
|
|
66
|
+
pass_through: bool = conf.pass_through_enabled()
|
|
67
|
+
|
|
68
|
+
# -- Hooks only JSON mode calls (JSON_MODE_HOOKS) --------------------------
|
|
69
|
+
|
|
70
|
+
def update_payload(self, request: Request, params: Any) -> dict[str, Any]:
|
|
71
|
+
"""Set ``username`` in ``params`` and return ``params``.
|
|
72
|
+
|
|
73
|
+
The value is the name of the user Django REST framework authenticated, or
|
|
74
|
+
``#anonymous``. A ``username`` sent by the client is replaced.
|
|
75
|
+
"""
|
|
76
|
+
if not isinstance(params, dict):
|
|
77
|
+
kind = _JSON_TYPE_NAMES.get(type(params), type(params).__name__)
|
|
78
|
+
raise ProxyError(f"the JSON body must be an object ({{...}}), not {kind}")
|
|
79
|
+
user = request.user
|
|
80
|
+
username = getattr(user, "username", None)
|
|
81
|
+
if username is None and hasattr(user, "get_username"):
|
|
82
|
+
username = user.get_username() # a custom user model without `username`
|
|
83
|
+
username = username or ANONYMOUS_USERNAME
|
|
84
|
+
logger.debug("Username: {}", username)
|
|
85
|
+
params.update({"username": username})
|
|
86
|
+
return params
|
|
87
|
+
|
|
88
|
+
def preprocess_request_body(self, request: Request) -> tuple[str, dict[str, Any]]:
|
|
89
|
+
"""Return the API server URL and the JSON body to send for ``request``.
|
|
90
|
+
|
|
91
|
+
An empty body counts as ``{}``; ``username`` is added by ``update_payload``.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
params = json.loads(request.body) if request.body else {}
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise ProxyError(
|
|
97
|
+
f"the request body is not JSON (Content-Type: {request.content_type or 'none'}); "
|
|
98
|
+
"JSON mode only forwards JSON, set PROXY_PASS_THROUGH = True to forward any body"
|
|
99
|
+
) from exc
|
|
100
|
+
# Field names only: the values may be passwords or tokens.
|
|
101
|
+
logger.debug("JSON body fields: {}", _field_names(params))
|
|
102
|
+
path = self.get_proxy_path(request)
|
|
103
|
+
params = self.update_payload(request, params)
|
|
104
|
+
return path, params
|
|
105
|
+
|
|
106
|
+
# Django REST framework stores the finished reply in `self.response` after the
|
|
107
|
+
# handler returns, which only hides this method once it is no longer needed.
|
|
108
|
+
def response(self, resp: Any) -> JsonResponse: # type: ignore[override]
|
|
109
|
+
"""Build the reply for the client from the API server's decoded JSON."""
|
|
110
|
+
return JsonResponse(resp, safe=False, json_dumps_params={"ensure_ascii": False})
|
|
111
|
+
|
|
112
|
+
# -- Steps where the modes differ ------------------------------------------
|
|
113
|
+
# Each one does what 1.x did when pass_through is off and hands the step to
|
|
114
|
+
# pass-through mode otherwise. super(PassThroughView, self) is Django REST
|
|
115
|
+
# framework's own behaviour, which 1.x used for these steps.
|
|
116
|
+
|
|
117
|
+
def head(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
118
|
+
"""JSON mode answers HEAD like GET, as Django does."""
|
|
119
|
+
if self.pass_through:
|
|
120
|
+
return super().head(request, *args, **kwargs)
|
|
121
|
+
return self.get(request, *args, **kwargs)
|
|
122
|
+
|
|
123
|
+
def options( # type: ignore[override]
|
|
124
|
+
self, request: Request, *args: Any, **kwargs: Any
|
|
125
|
+
) -> HttpResponse:
|
|
126
|
+
"""JSON mode lets Django REST framework answer OPTIONS."""
|
|
127
|
+
if self.pass_through:
|
|
128
|
+
return super().options(request, *args, **kwargs)
|
|
129
|
+
return super(PassThroughView, self).options(request, *args, **kwargs)
|
|
130
|
+
|
|
131
|
+
def parse_path(self, request: Request) -> str:
|
|
132
|
+
"""JSON mode: the first match of ``proxy_route_path`` (a regular expression) is
|
|
133
|
+
replaced with ``proxy_target_path`` and a trailing slash is dropped, so
|
|
134
|
+
``/my_test_route/items/7/`` becomes ``/get/items/7``.
|
|
135
|
+
"""
|
|
136
|
+
if self.pass_through:
|
|
137
|
+
return super().parse_path(request)
|
|
138
|
+
path = urlparse(request.get_full_path()).path.rstrip("/")
|
|
139
|
+
return re.sub(self.proxy_route_path, self.proxy_target_path, path, count=1)
|
|
140
|
+
|
|
141
|
+
def get_proxy_path(self, request: Request) -> str:
|
|
142
|
+
"""JSON mode puts ``target_api_url`` and the path together as they are."""
|
|
143
|
+
if self.pass_through:
|
|
144
|
+
return super().get_proxy_path(request)
|
|
145
|
+
url = f"{self.target_api_url}{self.parse_path(request)}"
|
|
146
|
+
logger.debug("Upstream URL: {}", url)
|
|
147
|
+
return url
|
|
148
|
+
|
|
149
|
+
def perform_authentication(self, request: Request) -> None:
|
|
150
|
+
if self.pass_through:
|
|
151
|
+
return super().perform_authentication(request)
|
|
152
|
+
return super(PassThroughView, self).perform_authentication(request)
|
|
153
|
+
|
|
154
|
+
def perform_content_negotiation(self, request: Request, force: bool = False) -> Any:
|
|
155
|
+
if self.pass_through:
|
|
156
|
+
return super().perform_content_negotiation(request, force)
|
|
157
|
+
return super(PassThroughView, self).perform_content_negotiation(request, force)
|
|
158
|
+
|
|
159
|
+
def finalize_response(self, request: Request, response: Any, *args: Any, **kwargs: Any) -> Any:
|
|
160
|
+
if self.pass_through:
|
|
161
|
+
return super().finalize_response(request, response, *args, **kwargs)
|
|
162
|
+
return super(PassThroughView, self).finalize_response(request, response, *args, **kwargs)
|
|
163
|
+
|
|
164
|
+
def _forward(self, request: Request, method: str) -> HttpResponse:
|
|
165
|
+
"""JSON mode: if anything fails on the way (a body that is not a JSON object,
|
|
166
|
+
an API server that cannot be reached, a reply that is not JSON), the error is
|
|
167
|
+
logged and the client gets ``{"status": "error"}`` with HTTP 200.
|
|
168
|
+
"""
|
|
169
|
+
if self.pass_through:
|
|
170
|
+
return super()._forward(request, method)
|
|
171
|
+
logger.debug("Proxy {} {}", method, request.path)
|
|
172
|
+
try:
|
|
173
|
+
reply = self._json_from(self._send_upstream(request, method))
|
|
174
|
+
except Exception as exc: # Whatever went wrong, the client gets the same answer.
|
|
175
|
+
reason = str(exc) if isinstance(exc, ProxyError) else _failure_reason(exc)
|
|
176
|
+
logger.exception("Could not proxy {} {}: {}. Replying {}",
|
|
177
|
+
method, request.path, reason, ERROR_REPLY_TEXT)
|
|
178
|
+
reply = ERROR_REPLY.copy()
|
|
179
|
+
return self.response(reply)
|
|
180
|
+
|
|
181
|
+
# -- Internals ------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def _send_upstream(self, request: Request, method: str) -> requests.Response:
|
|
184
|
+
"""GET sends the query string on; the other methods send the JSON body on."""
|
|
185
|
+
if method == "GET":
|
|
186
|
+
params = dict(request.GET)
|
|
187
|
+
url = self.get_proxy_path(request)
|
|
188
|
+
params = self.update_payload(request, params)
|
|
189
|
+
return self.send_request(method, url, params=params)
|
|
190
|
+
url, body = self.preprocess_request_body(request)
|
|
191
|
+
return self.send_request(method, url, json=body)
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _json_from(upstream: requests.Response) -> Any:
|
|
195
|
+
try:
|
|
196
|
+
return upstream.json()
|
|
197
|
+
except ValueError as exc:
|
|
198
|
+
raise ProxyError(
|
|
199
|
+
f"the API server answered HTTP {upstream.status_code} "
|
|
200
|
+
f"({_content_type(upstream)}), which is not JSON; "
|
|
201
|
+
"set PROXY_PASS_THROUGH = True to pass any reply on"
|
|
202
|
+
) from exc
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Pass-through mode: one route of your site passed through to another server.
|
|
2
|
+
|
|
3
|
+
``PassThroughView`` sends a request for ``/<PROXY_ROUTE_PATH>/<rest>`` to
|
|
4
|
+
``<TARGET_API_URL>/<PROXY_TARGET_PATH>/<rest>`` as the client sent it (method,
|
|
5
|
+
path, query string, headers, body) and relays the reply as the server gave it
|
|
6
|
+
(status, headers, cookies, body). Redirects go back to the client.
|
|
7
|
+
|
|
8
|
+
This module knows nothing about the JSON mode of 1.x: ``APIProxy`` (views.py) is
|
|
9
|
+
this view with the JSON mode (json_mode.py) layered on top.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from http import HTTPStatus
|
|
16
|
+
from http.cookies import SimpleCookie
|
|
17
|
+
from typing import Any, Mapping
|
|
18
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
from django.conf import settings
|
|
22
|
+
from django.http import Http404, HttpResponse, JsonResponse
|
|
23
|
+
from django.utils.encoding import escape_uri_path
|
|
24
|
+
from loguru import logger
|
|
25
|
+
from rest_framework.request import Request
|
|
26
|
+
from rest_framework.response import Response
|
|
27
|
+
from rest_framework.views import APIView
|
|
28
|
+
|
|
29
|
+
from django_simple_api_proxy import conf
|
|
30
|
+
from django_simple_api_proxy.headers import (
|
|
31
|
+
request_headers_to_forward,
|
|
32
|
+
response_headers_to_relay,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
conf.require_settings()
|
|
36
|
+
|
|
37
|
+
#: What the client gets when a request cannot be proxied (both modes), and how the
|
|
38
|
+
#: log quotes it.
|
|
39
|
+
ERROR_REPLY = {"status": "error"}
|
|
40
|
+
ERROR_REPLY_TEXT = json.dumps(ERROR_REPLY)
|
|
41
|
+
|
|
42
|
+
#: Seconds the API server has to answer (requests' timeout); 1.x used the same.
|
|
43
|
+
DEFAULT_TIMEOUT = 180
|
|
44
|
+
|
|
45
|
+
#: What pass-through mode answers when the API server gives no answer: it did not
|
|
46
|
+
#: answer in time, or it could not be reached at all.
|
|
47
|
+
NO_ANSWER_IN_TIME = HTTPStatus.GATEWAY_TIMEOUT
|
|
48
|
+
NO_ANSWER = HTTPStatus.BAD_GATEWAY
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _failure_reason(exc: Exception) -> str:
|
|
52
|
+
"""One line for the log about why the API server gave no answer."""
|
|
53
|
+
if isinstance(exc, requests.Timeout):
|
|
54
|
+
return "the API server did not answer in time"
|
|
55
|
+
if isinstance(exc, requests.ConnectionError):
|
|
56
|
+
return "could not connect to the API server"
|
|
57
|
+
if isinstance(exc, requests.RequestException):
|
|
58
|
+
return f"the request to the API server failed ({type(exc).__name__})"
|
|
59
|
+
return f"{type(exc).__name__}: {exc}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _content_type(response: requests.Response) -> str:
|
|
63
|
+
return response.headers.get("Content-Type") or "no Content-Type"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RelayedResponse(HttpResponse):
|
|
67
|
+
"""The API server's reply on its way to the client.
|
|
68
|
+
|
|
69
|
+
Like Django's own redirect responses it has ``url``, the ``Location`` it points
|
|
70
|
+
to, so Django's test client can follow a relayed redirect.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def url(self) -> str | None:
|
|
75
|
+
return self.get("Location")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PassThroughView(APIView):
|
|
79
|
+
"""Pass requests through to the API server and relay its replies as they are."""
|
|
80
|
+
|
|
81
|
+
# Anyone can use the stock proxy. Subclasses set these to require a login.
|
|
82
|
+
authentication_classes = []
|
|
83
|
+
permission_classes = []
|
|
84
|
+
|
|
85
|
+
# Read once, when this module is imported. Subclasses can set their own.
|
|
86
|
+
target_api_url: str = settings.TARGET_API_URL
|
|
87
|
+
proxy_route_path: str = settings.PROXY_ROUTE_PATH
|
|
88
|
+
proxy_target_path: str = settings.PROXY_TARGET_PATH
|
|
89
|
+
|
|
90
|
+
# -- HTTP methods: each one goes through as itself --------------------------
|
|
91
|
+
|
|
92
|
+
def get(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
93
|
+
return self._forward(request, "GET")
|
|
94
|
+
|
|
95
|
+
def post(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
96
|
+
return self._forward(request, "POST")
|
|
97
|
+
|
|
98
|
+
def put(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
99
|
+
return self._forward(request, "PUT")
|
|
100
|
+
|
|
101
|
+
def patch(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
102
|
+
return self._forward(request, "PATCH")
|
|
103
|
+
|
|
104
|
+
def delete(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
105
|
+
return self._forward(request, "DELETE")
|
|
106
|
+
|
|
107
|
+
def head(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse:
|
|
108
|
+
return self._forward(request, "HEAD")
|
|
109
|
+
|
|
110
|
+
# Django REST framework answers OPTIONS with a Response of its own; this relays
|
|
111
|
+
# the API server's reply instead.
|
|
112
|
+
def options( # type: ignore[override]
|
|
113
|
+
self, request: Request, *args: Any, **kwargs: Any
|
|
114
|
+
) -> HttpResponse:
|
|
115
|
+
return self._forward(request, "OPTIONS")
|
|
116
|
+
|
|
117
|
+
# -- Steps that subclasses can override -----------------------------------
|
|
118
|
+
|
|
119
|
+
def parse_path(self, request: Request) -> str:
|
|
120
|
+
"""Return the path on the API server for ``request``.
|
|
121
|
+
|
|
122
|
+
Everything after the route is kept as it is, trailing slash included, so
|
|
123
|
+
``/my_test_route/items/7/`` becomes ``/get/items/7/``. Slashes around the
|
|
124
|
+
two settings don't matter.
|
|
125
|
+
"""
|
|
126
|
+
_, rest = self._split_route(request)
|
|
127
|
+
target = self.proxy_target_path.strip("/")
|
|
128
|
+
if target:
|
|
129
|
+
return f"/{target}{rest}"
|
|
130
|
+
return rest or "/"
|
|
131
|
+
|
|
132
|
+
def get_proxy_path(self, request: Request) -> str:
|
|
133
|
+
"""Return the API server URL for ``request``, without the query string."""
|
|
134
|
+
url = f"{self.target_api_url.rstrip('/')}{self.parse_path(request)}"
|
|
135
|
+
logger.debug("Upstream URL: {}", url)
|
|
136
|
+
return url
|
|
137
|
+
|
|
138
|
+
def upstream_headers(self, request: Request) -> dict[str, str]:
|
|
139
|
+
"""Return the headers to send to the API server.
|
|
140
|
+
|
|
141
|
+
These are the client's headers, cookies and ``Authorization`` included,
|
|
142
|
+
without the hop-by-hop ones and those requests sets itself (``Host``,
|
|
143
|
+
``Content-Length``, ``Accept-Encoding``). Override it to add an API key or
|
|
144
|
+
to keep the client's cookies on your site.
|
|
145
|
+
"""
|
|
146
|
+
return request_headers_to_forward(request.headers, has_body=bool(request.body))
|
|
147
|
+
|
|
148
|
+
def send_request(
|
|
149
|
+
self,
|
|
150
|
+
method: str,
|
|
151
|
+
url: str,
|
|
152
|
+
params: dict[str, Any] | None = None,
|
|
153
|
+
data: Any = None,
|
|
154
|
+
json: Any = None,
|
|
155
|
+
timeout: float | tuple[float, float] = DEFAULT_TIMEOUT,
|
|
156
|
+
verify: bool | str = True,
|
|
157
|
+
headers: Mapping[str, str] | None = None,
|
|
158
|
+
allow_redirects: bool | None = None,
|
|
159
|
+
) -> requests.Response:
|
|
160
|
+
"""Send the request to the API server and return its reply.
|
|
161
|
+
|
|
162
|
+
Pass-through mode puts the query string in ``url`` and passes the raw body
|
|
163
|
+
as ``data``, the result of ``upstream_headers()`` as ``headers`` and
|
|
164
|
+
``allow_redirects=False``, so an override has to accept those
|
|
165
|
+
(``**kwargs`` will do).
|
|
166
|
+
"""
|
|
167
|
+
kwargs: dict[str, Any] = dict(method=method, url=url, params=params, data=data,
|
|
168
|
+
json=json, timeout=timeout, verify=verify)
|
|
169
|
+
# JSON mode passes neither, and calls requests as it always has (following
|
|
170
|
+
# redirects).
|
|
171
|
+
if headers is not None:
|
|
172
|
+
kwargs["headers"] = dict(headers)
|
|
173
|
+
if allow_redirects is not None:
|
|
174
|
+
kwargs["allow_redirects"] = allow_redirects
|
|
175
|
+
return requests.request(**kwargs)
|
|
176
|
+
|
|
177
|
+
def relay_response(self, upstream: requests.Response) -> HttpResponse:
|
|
178
|
+
"""Build the reply for the client from the API server's reply.
|
|
179
|
+
|
|
180
|
+
The status code, headers, cookies and body come back as they are, except
|
|
181
|
+
hop-by-hop headers and those that no longer fit after requests decoded the
|
|
182
|
+
body (``Content-Encoding``, ``Content-Length``). A ``Location`` header goes
|
|
183
|
+
through ``rewrite_location()``.
|
|
184
|
+
"""
|
|
185
|
+
reply = RelayedResponse(upstream.content, status=upstream.status_code,
|
|
186
|
+
reason=upstream.reason or None)
|
|
187
|
+
del reply["Content-Type"] # only the API server's, if it sent one
|
|
188
|
+
head = self.request.method == "HEAD"
|
|
189
|
+
for name, value in response_headers_to_relay(upstream.headers, head=head).items():
|
|
190
|
+
reply[name] = value
|
|
191
|
+
location = reply.get("Location")
|
|
192
|
+
if location:
|
|
193
|
+
reply["Location"] = self.rewrite_location(self.request, location)
|
|
194
|
+
for cookie in _set_cookie_headers(upstream):
|
|
195
|
+
_add_cookie(reply, cookie)
|
|
196
|
+
return reply
|
|
197
|
+
|
|
198
|
+
def rewrite_location(self, request: Request, location: str) -> str:
|
|
199
|
+
"""Point a ``Location`` header of the API server at your site.
|
|
200
|
+
|
|
201
|
+
* An address on the API server under ``proxy_target_path`` goes through the
|
|
202
|
+
proxy route: with the quick start settings, ``https://httpbin.org/get/next``
|
|
203
|
+
and ``/get/next`` both become ``/my_test_route/next``.
|
|
204
|
+
* A relative one (``next``, ``../list``, ``?page=2``) stays as it is: the
|
|
205
|
+
client resolves it against the proxy route, which is right already.
|
|
206
|
+
* An address on another host stays as it is. One on the API server outside
|
|
207
|
+
``proxy_target_path`` (``/login`` above) becomes absolute, so it still
|
|
208
|
+
leads to the API server.
|
|
209
|
+
"""
|
|
210
|
+
if not (location.startswith("/") or urlsplit(location).scheme):
|
|
211
|
+
return location
|
|
212
|
+
base = urlsplit(self.target_api_url.rstrip("/"))
|
|
213
|
+
target = self.proxy_target_path.strip("/")
|
|
214
|
+
base_path = base.path.rstrip("/") + (f"/{target}" if target else "")
|
|
215
|
+
there = urlsplit(urljoin(f"{base.scheme}://{base.netloc}/", location))
|
|
216
|
+
same_server = (there.scheme.lower(), there.netloc.lower()) == (
|
|
217
|
+
base.scheme.lower(), base.netloc.lower())
|
|
218
|
+
under_target = (not base_path or there.path == base_path
|
|
219
|
+
or there.path.startswith(base_path + "/"))
|
|
220
|
+
if same_server and under_target:
|
|
221
|
+
try:
|
|
222
|
+
route, _ = self._split_route(request)
|
|
223
|
+
except Http404: # a parse_path() override sent something else here
|
|
224
|
+
return location
|
|
225
|
+
path = route + there.path[len(base_path):]
|
|
226
|
+
return urlunsplit(("", "", path or "/", there.query, there.fragment))
|
|
227
|
+
if location.startswith("/") and not location.startswith("//"):
|
|
228
|
+
return urlunsplit(there) # on the API server, outside the proxied path
|
|
229
|
+
return location
|
|
230
|
+
|
|
231
|
+
# -- Django REST framework hooks ------------------------------------------
|
|
232
|
+
|
|
233
|
+
def perform_authentication(self, request: Request) -> None:
|
|
234
|
+
# Read the body before authentication can: SessionAuthentication's CSRF
|
|
235
|
+
# check parses forms, and after that a multipart body can't be read again.
|
|
236
|
+
# (Not earlier: Django REST framework's error handling needs the content
|
|
237
|
+
# negotiation that runs first, e.g. for a body over DATA_UPLOAD_MAX_MEMORY_SIZE.)
|
|
238
|
+
_ = request.body
|
|
239
|
+
super().perform_authentication(request)
|
|
240
|
+
|
|
241
|
+
def perform_content_negotiation(self, request: Request, force: bool = False) -> Any:
|
|
242
|
+
# The API server decides what it answers with, so an Accept header Django
|
|
243
|
+
# REST framework can't render is not a 406.
|
|
244
|
+
return super().perform_content_negotiation(request, force=True)
|
|
245
|
+
|
|
246
|
+
def finalize_response(self, request: Request, response: Any, *args: Any, **kwargs: Any) -> Any:
|
|
247
|
+
# Django REST framework would set its own Allow header on every reply and add
|
|
248
|
+
# Accept to Vary. The replies the proxy builds (the API server's, or a 502/504)
|
|
249
|
+
# keep their headers; Django REST framework's own (a 403, say) get its headers.
|
|
250
|
+
if isinstance(response, Response):
|
|
251
|
+
return super().finalize_response(request, response, *args, **kwargs)
|
|
252
|
+
return response
|
|
253
|
+
|
|
254
|
+
# -- Internals ------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def _forward(self, request: Request, method: str) -> HttpResponse:
|
|
257
|
+
"""Send ``request`` to the API server as ``method`` and relay the reply."""
|
|
258
|
+
logger.debug("Proxy {} {}", method, request.path)
|
|
259
|
+
url = self.get_proxy_path(request)
|
|
260
|
+
query = request.META.get("QUERY_STRING", "")
|
|
261
|
+
if query:
|
|
262
|
+
url = f"{url}?{query}"
|
|
263
|
+
body = request.body
|
|
264
|
+
headers = self.upstream_headers(request)
|
|
265
|
+
logger.debug("Forwarding headers {} and {} bytes of body", sorted(headers), len(body))
|
|
266
|
+
try:
|
|
267
|
+
# Redirects go back to the client (see rewrite_location()), cookies and all.
|
|
268
|
+
upstream = self.send_request(method, url, data=body or None, headers=headers,
|
|
269
|
+
allow_redirects=False)
|
|
270
|
+
except requests.RequestException as exc:
|
|
271
|
+
status = NO_ANSWER_IN_TIME if isinstance(exc, requests.Timeout) else NO_ANSWER
|
|
272
|
+
logger.exception("Could not proxy {} {}: {}. Replying {} {}", method, request.path,
|
|
273
|
+
_failure_reason(exc), int(status), ERROR_REPLY_TEXT)
|
|
274
|
+
return JsonResponse(ERROR_REPLY, status=status)
|
|
275
|
+
logger.debug("API server answered {} ({}, {} bytes)", upstream.status_code,
|
|
276
|
+
_content_type(upstream), len(upstream.content))
|
|
277
|
+
return self.relay_response(upstream)
|
|
278
|
+
|
|
279
|
+
def _split_route(self, request: Request) -> tuple[str, str]:
|
|
280
|
+
"""Split the request path into the proxy route on your site and the rest."""
|
|
281
|
+
path = escape_uri_path(request.path)
|
|
282
|
+
route = self.proxy_route_path.strip("/")
|
|
283
|
+
if not route:
|
|
284
|
+
return "", path
|
|
285
|
+
match = re.search("/" + re.escape(route) + "(?=/|$)", path)
|
|
286
|
+
if match is None: # e.g. /my_test_routeXYZ, which the README's URLs also send here
|
|
287
|
+
raise Http404(f"{request.path} is not under /{route}/")
|
|
288
|
+
return path[:match.end()], path[match.end():]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _set_cookie_headers(upstream: requests.Response) -> list[str]:
|
|
292
|
+
"""Every ``Set-Cookie`` header of the reply; requests joins them into one."""
|
|
293
|
+
raw_headers = getattr(getattr(upstream, "raw", None), "headers", None)
|
|
294
|
+
getlist = getattr(raw_headers, "getlist", None)
|
|
295
|
+
if callable(getlist):
|
|
296
|
+
return list(getlist("Set-Cookie"))
|
|
297
|
+
value = upstream.headers.get("Set-Cookie")
|
|
298
|
+
return [value] if value else []
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _add_cookie(reply: HttpResponse, header: str) -> None:
|
|
302
|
+
name = header.split("=", 1)[0].strip()
|
|
303
|
+
jar = SimpleCookie()
|
|
304
|
+
jar.load(header)
|
|
305
|
+
if name in jar:
|
|
306
|
+
reply.cookies[name] = jar[name]
|
|
307
|
+
else:
|
|
308
|
+
logger.debug("Dropped a Set-Cookie header Python can't parse (cookie {!r})", name)
|