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.
Files changed (40) hide show
  1. {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
  2. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/README.md +8 -8
  3. django_simple_api_proxy-2.0.1/src/django_simple_api_proxy/__init__.py +1 -0
  4. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/checks.py +5 -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
  6. django_simple_api_proxy-2.0.1/src/django_simple_api_proxy/json_mode.py +202 -0
  7. django_simple_api_proxy-2.0.1/src/django_simple_api_proxy/pass_through.py +308 -0
  8. django_simple_api_proxy-2.0.1/src/django_simple_api_proxy/views.py +34 -0
  9. {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
  10. {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
  11. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/tests/test_pass_through.py +15 -0
  12. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/tests/test_settings.py +30 -20
  13. django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/__init__.py +0 -1
  14. django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/views.py +0 -415
  15. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/LICENSE +0 -0
  16. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/MANIFEST.in +0 -0
  17. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/docs/images/hero-dark.webp +0 -0
  18. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/docs/images/hero-light.webp +0 -0
  19. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/docs/images/request-flow.html +0 -0
  20. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/docs/images/request-flow.png +0 -0
  21. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/docs/images/src/hero.html +0 -0
  22. {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
  23. {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
  24. {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
  25. {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
  26. {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
  27. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/example/api_proxy_example/manage.py +0 -0
  28. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/pyproject.toml +0 -0
  29. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/setup.cfg +0 -0
  30. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/setup.py +0 -0
  31. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/apps.py +0 -0
  32. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/conf.py +0 -0
  33. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/migrations/__init__.py +0 -0
  34. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/py.typed +0 -0
  35. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/src/django_simple_api_proxy/urls.py +0 -0
  36. {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
  37. {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
  38. {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
  39. {django_simple_api_proxy-2.0.0 → django_simple_api_proxy-2.0.1}/tests/test_api_proxy.py +0 -0
  40. {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.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 15:19:50.689 | DEBUG | django_simple_api_proxy.views:_forward:322 - Proxy PUT /my_test_route/anything/items
452
- 2026-11-03 15:19:50.689 | DEBUG | django_simple_api_proxy.views:get_proxy_path:157 - Upstream URL: https://httpbin.org/anything/items
453
- 2026-11-03 15:19:50.689 | DEBUG | django_simple_api_proxy.views:_pass_through:365 - Forwarding headers ['Accept', 'Content-Type', 'User-Agent', 'X-Hello'] and 306 bytes of body
454
- 2026-11-03 15:19:51.344 | DEBUG | django_simple_api_proxy.views:_pass_through:375 - API server answered 200 (application/json, 557 bytes)
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 15:20:04.450 | DEBUG | django_simple_api_proxy.views:_forward:322 - Proxy POST /my_test_route/
461
- 2026-11-03 15:20:04.450 | DEBUG | django_simple_api_proxy.views:preprocess_request_body:191 - JSON body fields: ['user', 'password']
462
- 2026-11-03 15:20:04.450 | DEBUG | django_simple_api_proxy.views:get_proxy_path:157 - Upstream URL: https://httpbin.org/anything
463
- 2026-11-03 15:20:04.450 | DEBUG | django_simple_api_proxy.views:update_payload:174 - Username: #anonymous
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 15:19:50.689 | DEBUG | django_simple_api_proxy.views:_forward:322 - Proxy PUT /my_test_route/anything/items
392
- 2026-11-03 15:19:50.689 | DEBUG | django_simple_api_proxy.views:get_proxy_path:157 - Upstream URL: https://httpbin.org/anything/items
393
- 2026-11-03 15:19:50.689 | DEBUG | django_simple_api_proxy.views:_pass_through:365 - Forwarding headers ['Accept', 'Content-Type', 'User-Agent', 'X-Hello'] and 306 bytes of body
394
- 2026-11-03 15:19:51.344 | DEBUG | django_simple_api_proxy.views:_pass_through:375 - API server answered 200 (application/json, 557 bytes)
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 15:20:04.450 | DEBUG | django_simple_api_proxy.views:_forward:322 - Proxy POST /my_test_route/
401
- 2026-11-03 15:20:04.450 | DEBUG | django_simple_api_proxy.views:preprocess_request_body:191 - JSON body fields: ['user', 'password']
402
- 2026-11-03 15:20:04.450 | DEBUG | django_simple_api_proxy.views:get_proxy_path:157 - Upstream URL: https://httpbin.org/anything
403
- 2026-11-03 15:20:04.450 | DEBUG | django_simple_api_proxy.views:update_payload:174 - Username: #anonymous
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(("http://", "https://")):
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, ``Content-Type: text/plain`` is dropped: ``runserver`` adds it to
48
- requests that have no body and no ``Content-Type``.
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 == "text/plain")
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)