django-simple-api-proxy 1.0.3__tar.gz → 2.0.0__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 (42) hide show
  1. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/MANIFEST.in +2 -1
  2. django_simple_api_proxy-2.0.0/PKG-INFO +521 -0
  3. django_simple_api_proxy-2.0.0/README.md +461 -0
  4. django_simple_api_proxy-2.0.0/docs/images/hero-dark.webp +0 -0
  5. django_simple_api_proxy-2.0.0/docs/images/hero-light.webp +0 -0
  6. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/docs/images/request-flow.html +3 -1
  7. django_simple_api_proxy-2.0.0/docs/images/src/hero.html +164 -0
  8. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/example/api_proxy_example/api_proxy_example/settings.py +1 -0
  9. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/setup.py +3 -1
  10. django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/__init__.py +1 -0
  11. django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/apps.py +14 -0
  12. django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/checks.py +104 -0
  13. django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/conf.py +37 -0
  14. django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/headers.py +69 -0
  15. django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/py.typed +0 -0
  16. django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/views.py +415 -0
  17. django_simple_api_proxy-2.0.0/src/django_simple_api_proxy.egg-info/PKG-INFO +521 -0
  18. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/src/django_simple_api_proxy.egg-info/SOURCES.txt +10 -1
  19. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/tests/test_api_proxy.py +71 -1
  20. django_simple_api_proxy-2.0.0/tests/test_pass_through.py +513 -0
  21. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/tests/test_readme.py +93 -4
  22. django_simple_api_proxy-2.0.0/tests/test_settings.py +225 -0
  23. django_simple_api_proxy-1.0.3/PKG-INFO +0 -307
  24. django_simple_api_proxy-1.0.3/README.md +0 -247
  25. django_simple_api_proxy-1.0.3/src/django_simple_api_proxy/__init__.py +0 -1
  26. django_simple_api_proxy-1.0.3/src/django_simple_api_proxy/apps.py +0 -6
  27. django_simple_api_proxy-1.0.3/src/django_simple_api_proxy/views.py +0 -164
  28. django_simple_api_proxy-1.0.3/src/django_simple_api_proxy.egg-info/PKG-INFO +0 -307
  29. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/LICENSE +0 -0
  30. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/docs/images/request-flow.png +0 -0
  31. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/example/api_proxy_example/api_proxy_example/__init__.py +0 -0
  32. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/example/api_proxy_example/api_proxy_example/asgi.py +0 -0
  33. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/example/api_proxy_example/api_proxy_example/urls.py +0 -0
  34. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/example/api_proxy_example/api_proxy_example/wsgi.py +0 -0
  35. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/example/api_proxy_example/manage.py +0 -0
  36. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/pyproject.toml +0 -0
  37. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/setup.cfg +0 -0
  38. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/src/django_simple_api_proxy/migrations/__init__.py +0 -0
  39. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/src/django_simple_api_proxy/urls.py +0 -0
  40. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/src/django_simple_api_proxy.egg-info/dependency_links.txt +0 -0
  41. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/src/django_simple_api_proxy.egg-info/requires.txt +0 -0
  42. {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/src/django_simple_api_proxy.egg-info/top_level.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  include pyproject.toml
2
2
  include *.md
3
3
  include LICENSE
4
+ include src/django_simple_api_proxy/py.typed
4
5
  recursive-include tests test*.py
5
6
  recursive-include example *.py
6
- recursive-include docs *.png *.html
7
+ recursive-include docs *.png *.webp *.html
@@ -0,0 +1,521 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-simple-api-proxy
3
+ Version: 2.0.0
4
+ Summary: Easy to proxy any APIs you want.
5
+ Home-page: https://github.com/NatLee/django-simple-api-proxy
6
+ Author: Nat Lee
7
+ Author-email: natlee.work@gmail.com
8
+ Project-URL: Documentation, https://github.com/NatLee/django-simple-api-proxy
9
+ Project-URL: Bug Reports, https://github.com/NatLee/django-simple-api-proxy/issues
10
+ Project-URL: Source Code, https://github.com/NatLee/django-simple-api-proxy
11
+ Keywords: django,api,proxy,proxy pass,reverse proxy,pass-through
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Internet :: WWW/HTTP
15
+ Classifier: Framework :: Django
16
+ Classifier: Framework :: Django :: 4.0
17
+ Classifier: Framework :: Django :: 4.1
18
+ Classifier: Framework :: Django :: 4.2
19
+ Classifier: Framework :: Django :: 5.0
20
+ Classifier: Framework :: Django :: 5.1
21
+ Classifier: Framework :: Django :: 5.2
22
+ Classifier: Framework :: Django :: 6.0
23
+ Classifier: Framework :: Django :: 6.1
24
+ Classifier: Programming Language :: Python :: 3
25
+ Classifier: Programming Language :: Python :: 3.8
26
+ Classifier: Programming Language :: Python :: 3.9
27
+ Classifier: Programming Language :: Python :: 3.10
28
+ Classifier: Programming Language :: Python :: 3.11
29
+ Classifier: Programming Language :: Python :: 3.12
30
+ Classifier: Programming Language :: Python :: 3.13
31
+ Classifier: Programming Language :: Python :: 3.14
32
+ Classifier: Programming Language :: Python :: 3 :: Only
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Requires-Python: >=3.8
36
+ Description-Content-Type: text/markdown
37
+ License-File: LICENSE
38
+ Requires-Dist: django>=4.0.0
39
+ Requires-Dist: requests
40
+ Requires-Dist: loguru
41
+ Requires-Dist: djangorestframework
42
+ Provides-Extra: dev
43
+ Requires-Dist: check-manifest; extra == "dev"
44
+ Provides-Extra: test
45
+ Requires-Dist: pytest; extra == "test"
46
+ Requires-Dist: djangorestframework-simplejwt; extra == "test"
47
+ Dynamic: author
48
+ Dynamic: author-email
49
+ Dynamic: classifier
50
+ Dynamic: description
51
+ Dynamic: description-content-type
52
+ Dynamic: home-page
53
+ Dynamic: keywords
54
+ Dynamic: license-file
55
+ Dynamic: project-url
56
+ Dynamic: provides-extra
57
+ Dynamic: requires-dist
58
+ Dynamic: requires-python
59
+ Dynamic: summary
60
+
61
+ <p align="center">
62
+ <picture>
63
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/NatLee/django-simple-api-proxy/main/docs/images/hero-dark.webp">
64
+ <img src="https://raw.githubusercontent.com/NatLee/django-simple-api-proxy/main/docs/images/hero-light.webp" width="880" alt="A client sends GET /my_test_route/items/7 to your Django site. APIProxy on your site maps /my_test_route/ to /get/ and sends GET /get/items/7 on to the target site, https://httpbin.org. The reply comes back the same way. Pass-through mode, the default since 2.0, passes the method, path, query, headers, any body, and the reply's status, headers, cookies, body and redirects through as they are. JSON mode, PROXY_PASS_THROUGH = False, is the 1.x behaviour: JSON in, JSON out, adds the caller's username and always answers HTTP 200.">
65
+ </picture>
66
+ </p>
67
+
68
+ <p align="center">
69
+ <a href="https://github.com/NatLee/django-simple-api-proxy/actions/workflows/test.yml"><img src="https://github.com/NatLee/django-simple-api-proxy/actions/workflows/test.yml/badge.svg" alt="Test"></a>
70
+ <a href="https://pypi.org/project/django-simple-api-proxy/"><img src="https://img.shields.io/pypi/v/django-simple-api-proxy" alt="PyPI"></a>
71
+ <a href="#supported-versions"><img src="https://img.shields.io/pypi/pyversions/django-simple-api-proxy" alt="Python"></a>
72
+ <a href="#supported-versions"><img src="https://img.shields.io/pypi/frameworkversions/django/django-simple-api-proxy?label=django" alt="Django"></a>
73
+ <a href="https://github.com/NatLee/django-simple-api-proxy/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License: MIT"></a>
74
+ </p>
75
+
76
+ Django Simple API Proxy turns one route of your Django site into a door to another server. A request that comes in at `/my_test_route/…` goes out to the server you point it at, and the answer comes back the same way, so your browser code, apps and users only ever talk to your site. Handy when the other server has no CORS headers, needs an API key you don't want in the browser, or should only be reachable for people logged in to your site. The proxy is a [Django REST framework](https://www.django-rest-framework.org/) view, so putting your login in front of it takes two lines.
77
+
78
+ Everything goes through as it is: the method, path, query string, headers and body (JSON, forms, files, anything) go to the other server, and its status code, headers, cookies and body come back. Redirects come back too, pointed at your route, so the client keeps going through your site.
79
+
80
+ The JSON-in, JSON-out behaviour of 1.x is still there, one setting away: see [JSON mode](#json-mode).
81
+
82
+ ## Upgrading to 2.0
83
+
84
+ 2.0 makes pass-through the default. If you upgrade from 1.x and set nothing, your clients get the other server's answers as they are:
85
+
86
+ | | 1.x | 2.0 |
87
+ |---|---|---|
88
+ | Status code | always 200 | the other server's; 502 or 504 if it can't be reached in time |
89
+ | Failures | `{"status": "error"}` | the other server's own error reply |
90
+ | Reply | JSON only | any content type, with headers and cookies |
91
+ | `username` | added to the query string or JSON body | not added |
92
+ | Headers, forms, files, query strings on `POST` | dropped or refused | passed on |
93
+ | Trailing slash | dropped | kept |
94
+ | Redirects | followed on your server | handed to the client |
95
+
96
+ You are affected if your clients look for `{"status": "error"}` or count on HTTP 200, if the other server reads `username`, or if you subclass `APIProxy` and override `update_payload()`, `preprocess_request_body()` or `response()`. Those three only run in JSON mode, and `manage.py check` points out subclasses that still override them (`django_simple_api_proxy.W005`).
97
+
98
+ To keep the 1.x behaviour, add one line to `settings.py`:
99
+
100
+ ```py
101
+ PROXY_PASS_THROUGH = False
102
+ ```
103
+
104
+ or set `pass_through = False` on your subclass. Everything else (settings, URLs, method names) works as in 1.x.
105
+
106
+ ## Installation
107
+
108
+ ```bash
109
+ pip install django-simple-api-proxy
110
+ ```
111
+
112
+ This also installs Django REST framework, [requests](https://requests.readthedocs.io/) and [loguru](https://github.com/Delgan/loguru).
113
+
114
+ ## Quick start
115
+
116
+ 1. Add Django REST framework and the proxy to `INSTALLED_APPS` in `settings.py`:
117
+
118
+ ```py
119
+ INSTALLED_APPS = [
120
+ ...,
121
+ 'rest_framework',
122
+ 'django_simple_api_proxy',
123
+ ]
124
+ ```
125
+
126
+ Don't skip `rest_framework`: without it, the error pages Django REST framework shows to browsers (for example a 403 from a permission class) fail and turn into a 500.
127
+
128
+ 2. Say where requests go, also in `settings.py`:
129
+
130
+ ```py
131
+ TARGET_API_URL = 'https://httpbin.org'
132
+ PROXY_ROUTE_PATH = 'my_test_route'
133
+ PROXY_TARGET_PATH = 'get'
134
+ ```
135
+
136
+ Now `/my_test_route/…` on your site maps to `https://httpbin.org/get/…`.
137
+
138
+ 3. Send that route to the proxy in your project's `urls.py`:
139
+
140
+ ```py
141
+ from django.conf import settings
142
+ from django.urls import include, path
143
+
144
+ urlpatterns += [
145
+ path(settings.PROXY_ROUTE_PATH, include('django_simple_api_proxy.urls')),
146
+ ]
147
+ ```
148
+
149
+ 4. Start the server and send your first proxied request:
150
+
151
+ ```bash
152
+ python manage.py runserver
153
+ curl "http://127.0.0.1:8000/my_test_route?q=django"
154
+ ```
155
+
156
+ httpbin answers with what it received (formatted, and without `headers` and `origin`):
157
+
158
+ ```json
159
+ {
160
+ "args": {"q": "django"},
161
+ "url": "https://httpbin.org/get?q=django"
162
+ }
163
+ ```
164
+
165
+ A path the other server doesn't know gets its own 404:
166
+
167
+ ```bash
168
+ curl -i "http://127.0.0.1:8000/my_test_route/nope"
169
+ # HTTP/1.1 404 Not Found
170
+ # content-type: text/html
171
+ # ...
172
+ ```
173
+
174
+ The same goes for `/my_test_route/` with a slash: it maps to `/get/`, which httpbin doesn't have. The proxy keeps your trailing slash, because the other server may care.
175
+
176
+ ## What goes through
177
+
178
+ Point the route at the whole server with `PROXY_TARGET_PATH = ''`; the examples below use httpbin that way.
179
+
180
+ Status codes, reason phrases, content types and headers come back as they are. Here httpbin's teapot, with its own `418 Unknown` (your site's `Date`, `Server` and security headers left out):
181
+
182
+ ```bash
183
+ curl -i http://127.0.0.1:8000/my_test_route/status/418
184
+ ```
185
+
186
+ ```http
187
+ HTTP/1.1 418 Unknown
188
+ access-control-allow-credentials: true
189
+ access-control-allow-origin: *
190
+ access-control-expose-headers: Retry-After, RateLimit, RateLimit-Policy
191
+ x-content-type-options: nosniff
192
+ x-more-info: http://tools.ietf.org/html/rfc2324
193
+ Content-Length: 135
194
+
195
+
196
+ -=[ teapot ]=-
197
+
198
+ _...._
199
+ .' _ _ `.
200
+ | ."` ^ `". _,
201
+ \_;`"---"`|//
202
+ | ;/
203
+ \_ _/
204
+ `"""`
205
+ ```
206
+
207
+ Any method, query string, header and body goes the other way. httpbin shows what it got (trimmed):
208
+
209
+ ```bash
210
+ printf 'hello\n' > hello.txt
211
+ curl "http://127.0.0.1:8000/my_test_route/anything/items?a=1&a=2" -X PUT \
212
+ -H "X-Hello: world" -F "note=hi" -F "file=@hello.txt"
213
+ ```
214
+
215
+ ```json
216
+ {
217
+ "args": {"a": ["1", "2"]},
218
+ "files": {"file": "hello\n"},
219
+ "form": {"note": "hi"},
220
+ "headers": {
221
+ "Content-Type": "multipart/form-data; boundary=------------------------O3Q6yIpVNGhVzhwQ2ShfTu",
222
+ "X-Hello": "world",
223
+ ...
224
+ },
225
+ "method": "PUT",
226
+ "url": "https://httpbin.org/anything/items?a=1&a=2"
227
+ }
228
+ ```
229
+
230
+ Binary replies and cookies come back too:
231
+
232
+ ```bash
233
+ curl -s -o duck.png -w '%{http_code} %{content_type} %{size_download} bytes\n' \
234
+ http://127.0.0.1:8000/my_test_route/image/png
235
+ # 200 image/png 8090 bytes
236
+
237
+ curl -i "http://127.0.0.1:8000/my_test_route/response-headers?X-Powered-By=httpbin&Set-Cookie=flavour%3Doatmeal"
238
+ # HTTP/1.1 200 OK
239
+ # content-type: application/json
240
+ # x-powered-by: httpbin
241
+ # Set-Cookie: flavour=oatmeal
242
+ # ...
243
+ ```
244
+
245
+ Redirects go back to the client. A `Location` on the other server is rewritten to go through your route, and the cookies on the redirect come along, so a client that follows it stays on your site:
246
+
247
+ ```bash
248
+ curl -i "http://127.0.0.1:8000/my_test_route/cookies/set?flavour=oatmeal"
249
+ # HTTP/1.1 302 Found
250
+ # Location: /my_test_route/cookies (httpbin said /cookies)
251
+ # Set-Cookie: flavour=oatmeal; Path=/
252
+ # ...
253
+
254
+ curl -L -c jar.txt -b jar.txt "http://127.0.0.1:8000/my_test_route/cookies/set?flavour=oatmeal"
255
+ # {"cookies": {"flavour": "oatmeal"}}
256
+ ```
257
+
258
+ ## Settings
259
+
260
+ | Setting | Example | What it is |
261
+ |---|---|---|
262
+ | `TARGET_API_URL` | `'https://httpbin.org'` | The server requests go to, without a trailing slash. Required. |
263
+ | `PROXY_ROUTE_PATH` | `'my_test_route'` | The route on your site that is proxied. Required. |
264
+ | `PROXY_TARGET_PATH` | `'get'` | The path on that server the route maps to; `''` for the whole server. Required. |
265
+ | `PROXY_PASS_THROUGH` | `False` | `True` (the default since 2.0) passes everything through; `False` is the [JSON mode](#json-mode) of 1.x. Optional. |
266
+
267
+ The proxy reads them when Django loads your URLs. If `TARGET_API_URL` or `PROXY_TARGET_PATH` is missing, `manage.py check` and `runserver` stop and tell you what to add (a missing `PROXY_ROUTE_PATH` already stops them at the line of your `urls.py` that uses it):
268
+
269
+ ```
270
+ django.core.exceptions.ImproperlyConfigured: django-simple-api-proxy needs TARGET_API_URL in your settings. Add to settings.py, for example:
271
+ TARGET_API_URL = 'https://httpbin.org' # the server requests are sent on to
272
+ ```
273
+
274
+ They also warn, without stopping, about settings that can't work, such as a `TARGET_API_URL` without `https://`:
275
+
276
+ ```
277
+ System check identified some issues:
278
+
279
+ WARNINGS:
280
+ ?: (django_simple_api_proxy.W001) TARGET_API_URL does not start with http:// or https://, so no request can reach it.
281
+ HINT: Write the full address, e.g. TARGET_API_URL = 'https://httpbin.org'.
282
+
283
+ System check identified 1 issue (0 silenced).
284
+ ```
285
+
286
+ In JSON mode they also warn about slashes around the two paths, which build wrong URLs there (see [JSON mode](#json-mode)); pass-through mode trims those slashes itself. To silence a warning, add its id to `SILENCED_SYSTEM_CHECKS`.
287
+
288
+ With the quick start settings, paths map like this:
289
+
290
+ | Request to your site | Pass-through mode sends | JSON mode sends |
291
+ |---|---|---|
292
+ | `/my_test_route` or `/my_test_route/` | `https://httpbin.org/get` or `…/get/` | `https://httpbin.org/get` |
293
+ | `GET /my_test_route/items/42/?a=1` | `https://httpbin.org/get/items/42/?a=1` | `https://httpbin.org/get/items/42?a=1&username=%23anonymous` |
294
+
295
+ ## Customising
296
+
297
+ Subclass `APIProxy`. This one only lets logged-in users through, keeps your site's own credentials on your site, and adds an API key on the server, so the key never reaches the browser:
298
+
299
+ ```py
300
+ from django.conf import settings
301
+ from rest_framework.authentication import SessionAuthentication
302
+ from rest_framework.permissions import IsAuthenticated
303
+ from rest_framework_simplejwt.authentication import JWTAuthentication
304
+
305
+ from django_simple_api_proxy.views import APIProxy
306
+
307
+
308
+ class MyAPIProxy(APIProxy):
309
+ # Anyone not logged in gets a 403, and nothing is forwarded.
310
+ authentication_classes = [SessionAuthentication, JWTAuthentication]
311
+ permission_classes = [IsAuthenticated]
312
+
313
+ def upstream_headers(self, request):
314
+ headers = super().upstream_headers(request)
315
+ headers.pop("Cookie", None) # your session cookie stays here
316
+ headers.pop("Authorization", None) # and so does your JWT
317
+ headers["X-Api-Key"] = settings.UPSTREAM_API_KEY
318
+ headers["X-User"] = request.user.get_username()
319
+ return headers
320
+ ```
321
+
322
+ `JWTAuthentication` comes from a separate package: `pip install djangorestframework-simplejwt`. With `SessionAuthentication`, Django REST framework also checks the CSRF token on `POST`, `PUT`, `PATCH` and `DELETE` from logged-in browsers.
323
+
324
+ Then route `PROXY_ROUTE_PATH` to your class in `urls.py`, instead of including `django_simple_api_proxy.urls`:
325
+
326
+ ```py
327
+ from django.conf import settings
328
+ from django.urls import path
329
+
330
+ from .views import MyAPIProxy # wherever you defined it
331
+
332
+ urlpatterns += [
333
+ path(settings.PROXY_ROUTE_PATH, MyAPIProxy.as_view(), {"resource": ""}),
334
+ path(settings.PROXY_ROUTE_PATH + "<path:resource>", MyAPIProxy.as_view()),
335
+ ]
336
+ ```
337
+
338
+ A logged-in request with a cookie and a JWT, to httpbin's `/headers` (with `PROXY_TARGET_PATH = ''` and `UPSTREAM_API_KEY = 'demo-key'`):
339
+
340
+ ```bash
341
+ curl -H "Authorization: Bearer $TOKEN" -H "Cookie: sessionid=abc" \
342
+ http://127.0.0.1:8000/my_test_route/headers
343
+ # {"headers": {"X-Api-Key": "demo-key", "X-User": "nat", ...}} no Authorization, no Cookie
344
+ ```
345
+
346
+ **Why drop `Cookie` and `Authorization`?** Pass-through mode sends the client's headers on as they are, cookies and `Authorization` included. That is what makes logins on the other server work through your site. But if your site uses them for its own login and the other server isn't yours, they would reach a third party, so drop them in `upstream_headers()` as above.
347
+
348
+ What a subclass can change:
349
+
350
+ | Override | By default | Override it to |
351
+ |---|---|---|
352
+ | `authentication_classes`, `permission_classes` | `[]`: no login, anyone can use the proxy | require a login, as above |
353
+ | `target_api_url`, `proxy_route_path`, `proxy_target_path` | the three settings | proxy a second server from another subclass |
354
+ | `pass_through` | `PROXY_PASS_THROUGH` (`True`) | switch the mode for one subclass |
355
+ | `upstream_headers(request)` | the client's headers | add an API key, drop cookies |
356
+ | `relay_response(upstream)` | copies status, headers, cookies and body | change the reply |
357
+ | `rewrite_location(request, location)` | points `Location` at your route, see below | map redirects differently |
358
+ | `send_request(method, url, params=None, data=None, json=None, timeout=180, verify=True, headers=None, allow_redirects=None)` | calls `requests.request` | change the timeout, reuse a session |
359
+ | `get`, `post`, `put`, `patch`, `delete`, `head`, `options` | forward the request | replace one method completely |
360
+ | `update_payload(request, params)`, `response(resp)` | JSON mode only, see [JSON mode](#json-mode) | |
361
+
362
+ If you override `send_request`, accept `**kwargs`: pass-through mode also passes `headers` and `allow_redirects=False` through it.
363
+
364
+ ## How requests are forwarded
365
+
366
+ | | Pass-through mode (default) | JSON mode (`PROXY_PASS_THROUGH = False`) |
367
+ |---|---|---|
368
+ | Methods | `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`, each as itself | `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. `HEAD` goes as `GET`; Django REST framework answers `OPTIONS` |
369
+ | Path | the route swapped, the rest kept exactly, trailing slash too | the route swapped for `PROXY_TARGET_PATH`, trailing slash dropped |
370
+ | Query string | every method, exactly as sent | `GET` only, plus `username` |
371
+ | Request headers | the client's, see below | none: requests' defaults |
372
+ | Request body | any body, byte for byte | a JSON object, plus `username` |
373
+ | Reply status | the other server's | always 200 |
374
+ | Reply headers | the other server's, see below | none from the other server: `Content-Type: application/json` |
375
+ | Reply body | byte for byte, decompressed | the JSON, encoded again |
376
+ | Redirects | handed to the client, `Location` pointed at your route | followed on your server |
377
+ | On failure | 502 (can't reach it) or 504 (no answer in time) with `{"status": "error"}` | 200 with `{"status": "error"}` |
378
+
379
+ Both modes:
380
+
381
+ - **Login**: the proxy is a Django REST framework view, so `authentication_classes` and `permission_classes` work in both modes, and a refused request is never forwarded.
382
+ - **TLS and timeout**: the other server's certificate is checked, and it has 180 seconds to answer. Override `send_request` to change either.
383
+ - **Other methods** (`TRACE`, …) get `405 Method Not Allowed` and are not forwarded.
384
+ - **Logs**: each request is logged at `DEBUG` level, failures at `ERROR` level with the reason and the traceback. The proxy's own log lines never hold bodies or header values. See [Logging](#logging).
385
+
386
+ Pass-through mode:
387
+
388
+ - **Request headers**: all the client sends, cookies and `Authorization` included, except the ones that only describe one connection (`Connection` and the headers it names, `Keep-Alive`, `Proxy-Authorization`, `TE`, `Trailer`, `Transfer-Encoding`, `Upgrade`) and the ones requests sets itself (`Host`, `Content-Length`, `Accept-Encoding`, `Expect`). The proxy adds nothing of its own (no `username`, no `X-Forwarded-For`); requests only fills in `User-Agent` and `Accept` when the client sent none.
389
+ - **Reply headers**: all the other server sends, every `Set-Cookie` included, except the per-connection ones, `Content-Encoding` and `Content-Length` (requests has already decompressed the body), and `Date` and `Server` (your web server sets its own).
390
+ - **Redirects** (301, 302, 303, 307, 308) and other replies with a `Location` (such as 201) come back with it rewritten:
391
+ - an address on the other server under `PROXY_TARGET_PATH` goes through your route: with the quick start settings, `https://httpbin.org/get/next` and `/get/next` both become `/my_test_route/next`;
392
+ - a relative one (`next`, `../list`, `?page=2`) stays as it is, because it already resolves against your route;
393
+ - an address on another host stays as it is, and one on the other server outside `PROXY_TARGET_PATH` becomes absolute, so it still leads there.
394
+ - **Bodies are read into memory**, in both directions. Django refuses request bodies over [`DATA_UPLOAD_MAX_MEMORY_SIZE`](https://docs.djangoproject.com/en/stable/ref/settings/#data-upload-max-memory-size) (2.5 MB by default) with a 400; raise it for bigger uploads. Streams (server-sent events, WebSockets) don't go through.
395
+ - **Bodies and cookies are not rewritten**: links in HTML still point at the other server, and a cookie's `Path` and `Domain` stay as the other server set them.
396
+
397
+ ## JSON mode
398
+
399
+ `PROXY_PASS_THROUGH = False` (or `pass_through = False` on a subclass) brings back the 1.x proxy: JSON in, JSON out. With the quick start settings:
400
+
401
+ ```bash
402
+ curl "http://127.0.0.1:8000/my_test_route/?q=django"
403
+ # {"args": {"q": "django", "username": "#anonymous"}, ..., "url": "https://httpbin.org/get?q=django&username=%23anonymous"}
404
+
405
+ curl "http://127.0.0.1:8000/my_test_route/nope"
406
+ # {"status": "error"}
407
+ ```
408
+
409
+ and the server log says why: `Could not proxy GET /my_test_route/nope: the API server answered HTTP 404 (text/html), which is not JSON; set PROXY_PASS_THROUGH = True to pass any reply on.`
410
+
411
+ - **`username`** goes along with every request: the user Django REST framework authenticated, or `#anonymous`. The stock `APIProxy` has no authentication classes, so it sends `#anonymous` even for users logged in to your site; give your subclass authentication classes to send real names. A `username` sent by the client is replaced. User models without a `username` field send what `get_username()` returns.
412
+ - **Errors**: if the body is not a JSON object, the other server can't be reached or doesn't answer in time, or its reply is not JSON, the client gets `{"status": "error"}` with HTTP 200, and the log says which it was.
413
+ - **Redirects** are followed on your server, so the client gets the final answer.
414
+
415
+ A JSON-mode subclass changes what is sent through `update_payload()`. This one also sends the user's e-mail address:
416
+
417
+ ```py
418
+ from rest_framework.authentication import SessionAuthentication
419
+ from rest_framework.permissions import IsAuthenticated
420
+ from rest_framework_simplejwt.authentication import JWTAuthentication
421
+
422
+ from django_simple_api_proxy.views import APIProxy
423
+
424
+
425
+ class MyJSONProxy(APIProxy):
426
+ pass_through = False # JSON mode, as in 1.x
427
+ authentication_classes = [SessionAuthentication, JWTAuthentication]
428
+ permission_classes = [IsAuthenticated]
429
+
430
+ def update_payload(self, request, params):
431
+ params = super().update_payload(request, params) # adds "username"
432
+ params["email"] = request.user.email
433
+ return params
434
+ ```
435
+
436
+ `response(resp)` reshapes the decoded JSON before it goes back (by default it returns it as is, non-ASCII text unescaped).
437
+
438
+ ### Known limitations
439
+
440
+ These are why pass-through became the default; JSON mode keeps them for compatibility:
441
+
442
+ 1. Errors and the other server's own status codes (404, 201, …) all reach the client as HTTP 200.
443
+ 2. A `PROXY_ROUTE_PATH` with a trailing slash, or an empty `PROXY_TARGET_PATH`, builds wrong URLs: with `'my_test_route/'`, `/my_test_route/abc` goes to `/getabc`. `PROXY_ROUTE_PATH` is also used as a regular expression, and `/my_test_routeabc` reaches the proxy too.
444
+ 3. `POST`, `PUT`, `PATCH` and `DELETE` drop the query string, and form-encoded bodies get `{"status": "error"}`.
445
+
446
+ ## Logging
447
+
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
+
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)
455
+ ```
456
+
457
+ and a `POST` of `{"user": "nat", "password": "hunter2"}` in JSON mode this:
458
+
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
464
+ ```
465
+
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:
467
+
468
+ ```py
469
+ import sys
470
+
471
+ from loguru import logger
472
+
473
+ logger.disable("django_simple_api_proxy") # no log from the proxy at all
474
+
475
+ # or
476
+ logger.remove()
477
+ logger.add(sys.stderr, level="INFO", diagnose=False)
478
+ ```
479
+
480
+ ## Supported versions
481
+
482
+ | | Versions |
483
+ |---|---|
484
+ | Python | 3.8 – 3.14 |
485
+ | Django | 4.0 – 6.1 |
486
+ | Django REST framework | the release pip picks for your Django |
487
+
488
+ CI tests every Django release from 4.0 to 6.1, each on the oldest and the newest Python it supports: 17 combinations. The package ships type hints (`py.typed`).
489
+
490
+ ## Development
491
+
492
+ ```bash
493
+ git clone https://github.com/NatLee/django-simple-api-proxy.git
494
+ cd django-simple-api-proxy
495
+ pip install -e ".[test]"
496
+ pytest tests
497
+ ```
498
+
499
+ The tests don't need the internet. Pass-through mode is tested against a real HTTP server the tests start on `127.0.0.1` (`tests/upstream.py`); JSON mode against a fake `requests.request`, with the test settings pinned to `PROXY_PASS_THROUGH = False` so the 1.x tests keep running unchanged. `tests/test_readme.py` runs the Python blocks of this README, so keep them working when you edit it.
500
+
501
+ To run the CI combinations locally:
502
+
503
+ ```bash
504
+ pip install tox
505
+ tox -e py # the Python on PATH with the newest Django it can install
506
+ tox -e py312-django42 # one combination; `tox -l` lists them all
507
+ ```
508
+
509
+ There is also a small Django project to try things in: [`example/api_proxy_example`](https://github.com/NatLee/django-simple-api-proxy/tree/main/example/api_proxy_example).
510
+
511
+ ```bash
512
+ pip install -e .
513
+ cd example/api_proxy_example
514
+ python manage.py runserver
515
+ ```
516
+
517
+ The picture at the top is drawn in [`docs/images/src/hero.html`](https://github.com/NatLee/django-simple-api-proxy/blob/main/docs/images/src/hero.html); the commands to render it are in the file.
518
+
519
+ ## License
520
+
521
+ [MIT](https://github.com/NatLee/django-simple-api-proxy/blob/main/LICENSE)