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.
- {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/MANIFEST.in +2 -1
- django_simple_api_proxy-2.0.0/PKG-INFO +521 -0
- django_simple_api_proxy-2.0.0/README.md +461 -0
- django_simple_api_proxy-2.0.0/docs/images/hero-dark.webp +0 -0
- django_simple_api_proxy-2.0.0/docs/images/hero-light.webp +0 -0
- {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/docs/images/request-flow.html +3 -1
- django_simple_api_proxy-2.0.0/docs/images/src/hero.html +164 -0
- {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
- {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/setup.py +3 -1
- django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/__init__.py +1 -0
- django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/apps.py +14 -0
- django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/checks.py +104 -0
- django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/conf.py +37 -0
- django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/headers.py +69 -0
- django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/py.typed +0 -0
- django_simple_api_proxy-2.0.0/src/django_simple_api_proxy/views.py +415 -0
- django_simple_api_proxy-2.0.0/src/django_simple_api_proxy.egg-info/PKG-INFO +521 -0
- {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
- {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/tests/test_api_proxy.py +71 -1
- django_simple_api_proxy-2.0.0/tests/test_pass_through.py +513 -0
- {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/tests/test_readme.py +93 -4
- django_simple_api_proxy-2.0.0/tests/test_settings.py +225 -0
- django_simple_api_proxy-1.0.3/PKG-INFO +0 -307
- django_simple_api_proxy-1.0.3/README.md +0 -247
- django_simple_api_proxy-1.0.3/src/django_simple_api_proxy/__init__.py +0 -1
- django_simple_api_proxy-1.0.3/src/django_simple_api_proxy/apps.py +0 -6
- django_simple_api_proxy-1.0.3/src/django_simple_api_proxy/views.py +0 -164
- django_simple_api_proxy-1.0.3/src/django_simple_api_proxy.egg-info/PKG-INFO +0 -307
- {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/LICENSE +0 -0
- {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/docs/images/request-flow.png +0 -0
- {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
- {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
- {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
- {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
- {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/example/api_proxy_example/manage.py +0 -0
- {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/pyproject.toml +0 -0
- {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/setup.cfg +0 -0
- {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/src/django_simple_api_proxy/migrations/__init__.py +0 -0
- {django_simple_api_proxy-1.0.3 → django_simple_api_proxy-2.0.0}/src/django_simple_api_proxy/urls.py +0 -0
- {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
- {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
- {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
|
@@ -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)
|