django-simple-api-proxy 1.0.2__tar.gz → 1.0.3__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.2 → django_simple_api_proxy-1.0.3}/MANIFEST.in +2 -1
- django_simple_api_proxy-1.0.3/PKG-INFO +307 -0
- django_simple_api_proxy-1.0.3/README.md +247 -0
- django_simple_api_proxy-1.0.3/docs/images/request-flow.html +84 -0
- django_simple_api_proxy-1.0.3/docs/images/request-flow.png +0 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/example/api_proxy_example/api_proxy_example/settings.py +1 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/setup.py +21 -6
- django_simple_api_proxy-1.0.3/src/django_simple_api_proxy/__init__.py +1 -0
- django_simple_api_proxy-1.0.3/src/django_simple_api_proxy/urls.py +11 -0
- django_simple_api_proxy-1.0.3/src/django_simple_api_proxy/views.py +164 -0
- django_simple_api_proxy-1.0.3/src/django_simple_api_proxy.egg-info/PKG-INFO +307 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/src/django_simple_api_proxy.egg-info/SOURCES.txt +4 -4
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/src/django_simple_api_proxy.egg-info/requires.txt +4 -0
- django_simple_api_proxy-1.0.3/tests/test_api_proxy.py +405 -0
- django_simple_api_proxy-1.0.3/tests/test_readme.py +152 -0
- django-simple-api-proxy-1.0.2/PKG-INFO +0 -162
- django-simple-api-proxy-1.0.2/README.md +0 -137
- django-simple-api-proxy-1.0.2/src/django_simple_api_proxy/__init__.py +0 -1
- django-simple-api-proxy-1.0.2/src/django_simple_api_proxy/admin.py +0 -3
- django-simple-api-proxy-1.0.2/src/django_simple_api_proxy/models.py +0 -3
- django-simple-api-proxy-1.0.2/src/django_simple_api_proxy/tests.py +0 -3
- django-simple-api-proxy-1.0.2/src/django_simple_api_proxy/urls.py +0 -7
- django-simple-api-proxy-1.0.2/src/django_simple_api_proxy/views.py +0 -131
- django-simple-api-proxy-1.0.2/src/django_simple_api_proxy.egg-info/PKG-INFO +0 -162
- django-simple-api-proxy-1.0.2/tests/test_api_proxy.py +0 -17
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/LICENSE +0 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/example/api_proxy_example/api_proxy_example/__init__.py +0 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/example/api_proxy_example/api_proxy_example/asgi.py +0 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/example/api_proxy_example/api_proxy_example/urls.py +0 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/example/api_proxy_example/api_proxy_example/wsgi.py +0 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/example/api_proxy_example/manage.py +0 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/pyproject.toml +0 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/setup.cfg +0 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/src/django_simple_api_proxy/apps.py +0 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/src/django_simple_api_proxy/migrations/__init__.py +0 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/src/django_simple_api_proxy.egg-info/dependency_links.txt +0 -0
- {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/src/django_simple_api_proxy.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-simple-api-proxy
|
|
3
|
+
Version: 1.0.3
|
|
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
|
|
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
|
+
# Django Simple API Proxy
|
|
62
|
+
|
|
63
|
+
[](https://github.com/NatLee/django-simple-api-proxy/actions/workflows/test.yml)
|
|
64
|
+
[](https://pypi.org/project/django-simple-api-proxy/)
|
|
65
|
+
[](#supported-versions)
|
|
66
|
+
[](#supported-versions)
|
|
67
|
+
[](https://github.com/NatLee/django-simple-api-proxy/blob/main/LICENSE)
|
|
68
|
+
|
|
69
|
+
Put another HTTP API behind a route on your Django site. Requests to `/my_test_route/…` are sent on to the API server you configure, with the route swapped for the API's path and the caller's username attached, and the JSON the API answers with goes straight back to the client. The proxy is a [Django REST framework](https://www.django-rest-framework.org/) view, so putting your site's login in front of the API takes a couple of lines, and anything else is one overridden method away.
|
|
70
|
+
|
|
71
|
+

|
|
72
|
+
|
|
73
|
+
## Installation
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install django-simple-api-proxy
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
This also installs Django REST framework, [requests](https://requests.readthedocs.io/) and [loguru](https://github.com/Delgan/loguru).
|
|
80
|
+
|
|
81
|
+
## Quick start
|
|
82
|
+
|
|
83
|
+
1. Add Django REST framework and the proxy to `INSTALLED_APPS` in `settings.py`:
|
|
84
|
+
|
|
85
|
+
```py
|
|
86
|
+
INSTALLED_APPS = [
|
|
87
|
+
...,
|
|
88
|
+
'rest_framework',
|
|
89
|
+
'django_simple_api_proxy',
|
|
90
|
+
]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
2. Tell the proxy where to send requests, also in `settings.py`:
|
|
96
|
+
|
|
97
|
+
```py
|
|
98
|
+
TARGET_API_URL = 'https://httpbin.org'
|
|
99
|
+
PROXY_ROUTE_PATH = 'my_test_route'
|
|
100
|
+
PROXY_TARGET_PATH = 'get'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Now `/my_test_route/…` on your site maps to `https://httpbin.org/get/…`.
|
|
104
|
+
|
|
105
|
+
3. Route the requests to the proxy in your project's `urls.py`:
|
|
106
|
+
|
|
107
|
+
```py
|
|
108
|
+
from django.conf import settings
|
|
109
|
+
from django.urls import include, path
|
|
110
|
+
|
|
111
|
+
urlpatterns += [
|
|
112
|
+
path(settings.PROXY_ROUTE_PATH, include('django_simple_api_proxy.urls')),
|
|
113
|
+
]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
4. Start the server and send a request:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
python manage.py runserver
|
|
120
|
+
curl "http://127.0.0.1:8000/my_test_route/?q=django"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
httpbin answers with what it received. Formatted, and without `headers` and `origin`:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"args": {"q": "django", "username": "#anonymous"},
|
|
128
|
+
"method": "GET",
|
|
129
|
+
"url": "https://httpbin.org/get?q=django&username=%23anonymous"
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
A path the API server doesn't know gets the proxy's error answer:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
curl "http://127.0.0.1:8000/my_test_route/nope"
|
|
137
|
+
# {"status": "error"}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
To try `POST`, `PUT`, `PATCH` and `DELETE`, set `PROXY_TARGET_PATH = 'anything'`; httpbin's `/anything` echoes every method:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
curl -X POST http://127.0.0.1:8000/my_test_route/ \
|
|
144
|
+
-H 'Content-Type: application/json' -d '{"hello": "world"}'
|
|
145
|
+
# {..., "method": "POST", "json": {"hello": "world", "username": "#anonymous"}, ...}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Settings
|
|
149
|
+
|
|
150
|
+
| Setting | Example | What it is |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `TARGET_API_URL` | `'https://httpbin.org'` | The API server, without a trailing slash. |
|
|
153
|
+
| `PROXY_ROUTE_PATH` | `'my_test_route'` | The route on your site that is proxied. |
|
|
154
|
+
| `PROXY_TARGET_PATH` | `'get'` | The path on the API server that the route maps to. |
|
|
155
|
+
|
|
156
|
+
All three are required. The proxy reads them when Django loads your URLs, so a missing one stops `manage.py check` and `runserver` with `AttributeError: 'Settings' object has no attribute 'TARGET_API_URL'`.
|
|
157
|
+
|
|
158
|
+
Write both paths without slashes at either end, and don't leave `PROXY_TARGET_PATH` empty: either one builds wrong URLs (see [Known limitations](#known-limitations)). With the settings above, the paths map like this:
|
|
159
|
+
|
|
160
|
+
| Request to your site | Request to the API server |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `/my_test_route` or `/my_test_route/` | `https://httpbin.org/get` |
|
|
163
|
+
| `GET /my_test_route/items/42/?a=1` | `https://httpbin.org/get/items/42?a=1&username=%23anonymous` |
|
|
164
|
+
|
|
165
|
+
## Customising
|
|
166
|
+
|
|
167
|
+
Subclass `APIProxy`. This one only lets logged-in users through and also sends their e-mail address:
|
|
168
|
+
|
|
169
|
+
```py
|
|
170
|
+
from rest_framework.authentication import SessionAuthentication
|
|
171
|
+
from rest_framework.permissions import IsAuthenticated
|
|
172
|
+
from rest_framework_simplejwt.authentication import JWTAuthentication
|
|
173
|
+
|
|
174
|
+
from django_simple_api_proxy.views import APIProxy
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class MyAPIProxy(APIProxy):
|
|
178
|
+
# Anyone not logged in gets a 403, and nothing is forwarded.
|
|
179
|
+
authentication_classes = [SessionAuthentication, JWTAuthentication]
|
|
180
|
+
permission_classes = [IsAuthenticated]
|
|
181
|
+
|
|
182
|
+
def update_payload(self, request, params):
|
|
183
|
+
params = super().update_payload(request, params) # adds "username"
|
|
184
|
+
params["email"] = request.user.email
|
|
185
|
+
return params
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`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.
|
|
189
|
+
|
|
190
|
+
Then route `PROXY_ROUTE_PATH` to your class in `urls.py`, instead of including `django_simple_api_proxy.urls`:
|
|
191
|
+
|
|
192
|
+
```py
|
|
193
|
+
from django.conf import settings
|
|
194
|
+
from django.urls import path
|
|
195
|
+
|
|
196
|
+
from .views import MyAPIProxy # wherever you defined it
|
|
197
|
+
|
|
198
|
+
urlpatterns += [
|
|
199
|
+
path(settings.PROXY_ROUTE_PATH, MyAPIProxy.as_view(), {"resource": ""}),
|
|
200
|
+
path(settings.PROXY_ROUTE_PATH + "<path:resource>", MyAPIProxy.as_view()),
|
|
201
|
+
]
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
What else a subclass can change:
|
|
205
|
+
|
|
206
|
+
| Override | By default | Override it to |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| `authentication_classes`, `permission_classes` | `[]`: no login, anyone can use the proxy | require a login, as above |
|
|
209
|
+
| `target_api_url`, `proxy_route_path`, `proxy_target_path` | the three settings | proxy a second API from another subclass |
|
|
210
|
+
| `update_payload(request, params)` | sets `username` | add or drop what is sent |
|
|
211
|
+
| `send_request(method, url, params=None, data=None, json=None, timeout=180, verify=True)` | calls `requests.request` | add headers, change the timeout, reuse a session |
|
|
212
|
+
| `response(resp)` | returns the JSON as is, non-ASCII text unescaped | reshape the reply |
|
|
213
|
+
| `get`, `post`, `put`, `patch`, `delete` | forward the request | replace one method completely |
|
|
214
|
+
|
|
215
|
+
## How requests are forwarded
|
|
216
|
+
|
|
217
|
+
| The client sends | The API server gets |
|
|
218
|
+
|---|---|
|
|
219
|
+
| `GET` | `GET` with the query string as query parameters, plus `username` |
|
|
220
|
+
| `POST`, `PUT`, `PATCH`, `DELETE` | the same method with the JSON body, plus `username`. The body must be a JSON object or empty. |
|
|
221
|
+
| `HEAD` | a `GET`; the client gets the headers only |
|
|
222
|
+
| `OPTIONS` | nothing: Django REST framework answers it |
|
|
223
|
+
| anything else | nothing: the client gets `405 Method Not Allowed` |
|
|
224
|
+
|
|
225
|
+
- **Path**: the route is replaced with `PROXY_TARGET_PATH`, the rest of the path is kept, and a trailing slash is dropped.
|
|
226
|
+
- **`username`**: 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; [subclass it](#customising) to send real names. A `username` sent by the client is replaced.
|
|
227
|
+
- **Headers**: the client's headers, cookies included, stay on your site. The API server only gets the default headers of requests.
|
|
228
|
+
- **Reply**: the API server's JSON goes back as is, with HTTP 200. Its status code and headers are not passed on.
|
|
229
|
+
- **Errors**: if the body is not a JSON object, the API server can't be reached or doesn't answer within 180 seconds, or its reply is not JSON, the client gets `{"status": "error"}` with HTTP 200, and the error is logged with its traceback.
|
|
230
|
+
- **TLS**: the API server's certificate is checked.
|
|
231
|
+
- **Logs**: each request is logged at `DEBUG` level. Of a JSON body, only the field names are logged, never the values. See [Logging](#logging).
|
|
232
|
+
|
|
233
|
+
### Known limitations
|
|
234
|
+
|
|
235
|
+
This is how the proxy behaves today; plan around it:
|
|
236
|
+
|
|
237
|
+
1. Errors and the API server's own status codes (404, 201, …) all reach the client as HTTP 200.
|
|
238
|
+
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.
|
|
239
|
+
3. `POST`, `PUT`, `PATCH` and `DELETE` drop the query string, and form-encoded bodies get `{"status": "error"}`.
|
|
240
|
+
|
|
241
|
+
## Logging
|
|
242
|
+
|
|
243
|
+
The proxy logs through loguru: every request at `DEBUG` level, failures at `ERROR` level with the traceback. Request bodies are logged by field name only, never by value. A `POST` with `{"user": "nat", "password": "hunter2"}` logs this:
|
|
244
|
+
|
|
245
|
+
```log
|
|
246
|
+
2026-11-03 12:56:05.547 | DEBUG | django_simple_api_proxy.views:_forward:147 - Proxy POST /my_test_route/
|
|
247
|
+
2026-11-03 12:56:05.547 | DEBUG | django_simple_api_proxy.views:preprocess_request_body:106 - JSON body fields: ['user', 'password']
|
|
248
|
+
2026-11-03 12:56:05.548 | DEBUG | django_simple_api_proxy.views:get_proxy_path:84 - Upstream URL: https://httpbin.org/anything
|
|
249
|
+
2026-11-03 12:56:05.548 | DEBUG | django_simple_api_proxy.views:update_payload:95 - Username: #anonymous
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
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:
|
|
253
|
+
|
|
254
|
+
```py
|
|
255
|
+
import sys
|
|
256
|
+
|
|
257
|
+
from loguru import logger
|
|
258
|
+
|
|
259
|
+
logger.disable("django_simple_api_proxy") # no log from the proxy at all
|
|
260
|
+
|
|
261
|
+
# or
|
|
262
|
+
logger.remove()
|
|
263
|
+
logger.add(sys.stderr, level="INFO", diagnose=False)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Supported versions
|
|
267
|
+
|
|
268
|
+
| | Versions |
|
|
269
|
+
|---|---|
|
|
270
|
+
| Python | 3.8 – 3.14 |
|
|
271
|
+
| Django | 4.0 – 6.1 |
|
|
272
|
+
| Django REST framework | the release pip picks for your Django |
|
|
273
|
+
|
|
274
|
+
CI tests every Django release from 4.0 to 6.1, each on the oldest and the newest Python it supports: 17 combinations.
|
|
275
|
+
|
|
276
|
+
## Development
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
git clone https://github.com/NatLee/django-simple-api-proxy.git
|
|
280
|
+
cd django-simple-api-proxy
|
|
281
|
+
pip install -e ".[test]"
|
|
282
|
+
pytest tests
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The tests don't touch the network: the API server is replaced by a fake `requests.request`. `tests/test_readme.py` runs the Python blocks of this README, so keep them working when you edit it.
|
|
286
|
+
|
|
287
|
+
To run the CI combinations locally:
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
pip install tox
|
|
291
|
+
tox -e py # the Python on PATH with the newest Django it can install
|
|
292
|
+
tox -e py312-django42 # one combination; `tox -l` lists them all
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
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).
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
pip install -e .
|
|
299
|
+
cd example/api_proxy_example
|
|
300
|
+
python manage.py runserver
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The diagram at the top is drawn in [`docs/images/request-flow.html`](https://github.com/NatLee/django-simple-api-proxy/blob/main/docs/images/request-flow.html); the command to render it is in the file.
|
|
304
|
+
|
|
305
|
+
## License
|
|
306
|
+
|
|
307
|
+
[MIT](https://github.com/NatLee/django-simple-api-proxy/blob/main/LICENSE)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Django Simple API Proxy
|
|
2
|
+
|
|
3
|
+
[](https://github.com/NatLee/django-simple-api-proxy/actions/workflows/test.yml)
|
|
4
|
+
[](https://pypi.org/project/django-simple-api-proxy/)
|
|
5
|
+
[](#supported-versions)
|
|
6
|
+
[](#supported-versions)
|
|
7
|
+
[](https://github.com/NatLee/django-simple-api-proxy/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
Put another HTTP API behind a route on your Django site. Requests to `/my_test_route/…` are sent on to the API server you configure, with the route swapped for the API's path and the caller's username attached, and the JSON the API answers with goes straight back to the client. The proxy is a [Django REST framework](https://www.django-rest-framework.org/) view, so putting your site's login in front of the API takes a couple of lines, and anything else is one overridden method away.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install django-simple-api-proxy
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This also installs Django REST framework, [requests](https://requests.readthedocs.io/) and [loguru](https://github.com/Delgan/loguru).
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
1. Add Django REST framework and the proxy to `INSTALLED_APPS` in `settings.py`:
|
|
24
|
+
|
|
25
|
+
```py
|
|
26
|
+
INSTALLED_APPS = [
|
|
27
|
+
...,
|
|
28
|
+
'rest_framework',
|
|
29
|
+
'django_simple_api_proxy',
|
|
30
|
+
]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
2. Tell the proxy where to send requests, also in `settings.py`:
|
|
36
|
+
|
|
37
|
+
```py
|
|
38
|
+
TARGET_API_URL = 'https://httpbin.org'
|
|
39
|
+
PROXY_ROUTE_PATH = 'my_test_route'
|
|
40
|
+
PROXY_TARGET_PATH = 'get'
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Now `/my_test_route/…` on your site maps to `https://httpbin.org/get/…`.
|
|
44
|
+
|
|
45
|
+
3. Route the requests to the proxy in your project's `urls.py`:
|
|
46
|
+
|
|
47
|
+
```py
|
|
48
|
+
from django.conf import settings
|
|
49
|
+
from django.urls import include, path
|
|
50
|
+
|
|
51
|
+
urlpatterns += [
|
|
52
|
+
path(settings.PROXY_ROUTE_PATH, include('django_simple_api_proxy.urls')),
|
|
53
|
+
]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
4. Start the server and send a request:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
python manage.py runserver
|
|
60
|
+
curl "http://127.0.0.1:8000/my_test_route/?q=django"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
httpbin answers with what it received. Formatted, and without `headers` and `origin`:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"args": {"q": "django", "username": "#anonymous"},
|
|
68
|
+
"method": "GET",
|
|
69
|
+
"url": "https://httpbin.org/get?q=django&username=%23anonymous"
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A path the API server doesn't know gets the proxy's error answer:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
curl "http://127.0.0.1:8000/my_test_route/nope"
|
|
77
|
+
# {"status": "error"}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
To try `POST`, `PUT`, `PATCH` and `DELETE`, set `PROXY_TARGET_PATH = 'anything'`; httpbin's `/anything` echoes every method:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
curl -X POST http://127.0.0.1:8000/my_test_route/ \
|
|
84
|
+
-H 'Content-Type: application/json' -d '{"hello": "world"}'
|
|
85
|
+
# {..., "method": "POST", "json": {"hello": "world", "username": "#anonymous"}, ...}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Settings
|
|
89
|
+
|
|
90
|
+
| Setting | Example | What it is |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `TARGET_API_URL` | `'https://httpbin.org'` | The API server, without a trailing slash. |
|
|
93
|
+
| `PROXY_ROUTE_PATH` | `'my_test_route'` | The route on your site that is proxied. |
|
|
94
|
+
| `PROXY_TARGET_PATH` | `'get'` | The path on the API server that the route maps to. |
|
|
95
|
+
|
|
96
|
+
All three are required. The proxy reads them when Django loads your URLs, so a missing one stops `manage.py check` and `runserver` with `AttributeError: 'Settings' object has no attribute 'TARGET_API_URL'`.
|
|
97
|
+
|
|
98
|
+
Write both paths without slashes at either end, and don't leave `PROXY_TARGET_PATH` empty: either one builds wrong URLs (see [Known limitations](#known-limitations)). With the settings above, the paths map like this:
|
|
99
|
+
|
|
100
|
+
| Request to your site | Request to the API server |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `/my_test_route` or `/my_test_route/` | `https://httpbin.org/get` |
|
|
103
|
+
| `GET /my_test_route/items/42/?a=1` | `https://httpbin.org/get/items/42?a=1&username=%23anonymous` |
|
|
104
|
+
|
|
105
|
+
## Customising
|
|
106
|
+
|
|
107
|
+
Subclass `APIProxy`. This one only lets logged-in users through and also sends their e-mail address:
|
|
108
|
+
|
|
109
|
+
```py
|
|
110
|
+
from rest_framework.authentication import SessionAuthentication
|
|
111
|
+
from rest_framework.permissions import IsAuthenticated
|
|
112
|
+
from rest_framework_simplejwt.authentication import JWTAuthentication
|
|
113
|
+
|
|
114
|
+
from django_simple_api_proxy.views import APIProxy
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class MyAPIProxy(APIProxy):
|
|
118
|
+
# Anyone not logged in gets a 403, and nothing is forwarded.
|
|
119
|
+
authentication_classes = [SessionAuthentication, JWTAuthentication]
|
|
120
|
+
permission_classes = [IsAuthenticated]
|
|
121
|
+
|
|
122
|
+
def update_payload(self, request, params):
|
|
123
|
+
params = super().update_payload(request, params) # adds "username"
|
|
124
|
+
params["email"] = request.user.email
|
|
125
|
+
return params
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`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.
|
|
129
|
+
|
|
130
|
+
Then route `PROXY_ROUTE_PATH` to your class in `urls.py`, instead of including `django_simple_api_proxy.urls`:
|
|
131
|
+
|
|
132
|
+
```py
|
|
133
|
+
from django.conf import settings
|
|
134
|
+
from django.urls import path
|
|
135
|
+
|
|
136
|
+
from .views import MyAPIProxy # wherever you defined it
|
|
137
|
+
|
|
138
|
+
urlpatterns += [
|
|
139
|
+
path(settings.PROXY_ROUTE_PATH, MyAPIProxy.as_view(), {"resource": ""}),
|
|
140
|
+
path(settings.PROXY_ROUTE_PATH + "<path:resource>", MyAPIProxy.as_view()),
|
|
141
|
+
]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
What else a subclass can change:
|
|
145
|
+
|
|
146
|
+
| Override | By default | Override it to |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `authentication_classes`, `permission_classes` | `[]`: no login, anyone can use the proxy | require a login, as above |
|
|
149
|
+
| `target_api_url`, `proxy_route_path`, `proxy_target_path` | the three settings | proxy a second API from another subclass |
|
|
150
|
+
| `update_payload(request, params)` | sets `username` | add or drop what is sent |
|
|
151
|
+
| `send_request(method, url, params=None, data=None, json=None, timeout=180, verify=True)` | calls `requests.request` | add headers, change the timeout, reuse a session |
|
|
152
|
+
| `response(resp)` | returns the JSON as is, non-ASCII text unescaped | reshape the reply |
|
|
153
|
+
| `get`, `post`, `put`, `patch`, `delete` | forward the request | replace one method completely |
|
|
154
|
+
|
|
155
|
+
## How requests are forwarded
|
|
156
|
+
|
|
157
|
+
| The client sends | The API server gets |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `GET` | `GET` with the query string as query parameters, plus `username` |
|
|
160
|
+
| `POST`, `PUT`, `PATCH`, `DELETE` | the same method with the JSON body, plus `username`. The body must be a JSON object or empty. |
|
|
161
|
+
| `HEAD` | a `GET`; the client gets the headers only |
|
|
162
|
+
| `OPTIONS` | nothing: Django REST framework answers it |
|
|
163
|
+
| anything else | nothing: the client gets `405 Method Not Allowed` |
|
|
164
|
+
|
|
165
|
+
- **Path**: the route is replaced with `PROXY_TARGET_PATH`, the rest of the path is kept, and a trailing slash is dropped.
|
|
166
|
+
- **`username`**: 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; [subclass it](#customising) to send real names. A `username` sent by the client is replaced.
|
|
167
|
+
- **Headers**: the client's headers, cookies included, stay on your site. The API server only gets the default headers of requests.
|
|
168
|
+
- **Reply**: the API server's JSON goes back as is, with HTTP 200. Its status code and headers are not passed on.
|
|
169
|
+
- **Errors**: if the body is not a JSON object, the API server can't be reached or doesn't answer within 180 seconds, or its reply is not JSON, the client gets `{"status": "error"}` with HTTP 200, and the error is logged with its traceback.
|
|
170
|
+
- **TLS**: the API server's certificate is checked.
|
|
171
|
+
- **Logs**: each request is logged at `DEBUG` level. Of a JSON body, only the field names are logged, never the values. See [Logging](#logging).
|
|
172
|
+
|
|
173
|
+
### Known limitations
|
|
174
|
+
|
|
175
|
+
This is how the proxy behaves today; plan around it:
|
|
176
|
+
|
|
177
|
+
1. Errors and the API server's own status codes (404, 201, …) all reach the client as HTTP 200.
|
|
178
|
+
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.
|
|
179
|
+
3. `POST`, `PUT`, `PATCH` and `DELETE` drop the query string, and form-encoded bodies get `{"status": "error"}`.
|
|
180
|
+
|
|
181
|
+
## Logging
|
|
182
|
+
|
|
183
|
+
The proxy logs through loguru: every request at `DEBUG` level, failures at `ERROR` level with the traceback. Request bodies are logged by field name only, never by value. A `POST` with `{"user": "nat", "password": "hunter2"}` logs this:
|
|
184
|
+
|
|
185
|
+
```log
|
|
186
|
+
2026-11-03 12:56:05.547 | DEBUG | django_simple_api_proxy.views:_forward:147 - Proxy POST /my_test_route/
|
|
187
|
+
2026-11-03 12:56:05.547 | DEBUG | django_simple_api_proxy.views:preprocess_request_body:106 - JSON body fields: ['user', 'password']
|
|
188
|
+
2026-11-03 12:56:05.548 | DEBUG | django_simple_api_proxy.views:get_proxy_path:84 - Upstream URL: https://httpbin.org/anything
|
|
189
|
+
2026-11-03 12:56:05.548 | DEBUG | django_simple_api_proxy.views:update_payload:95 - Username: #anonymous
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
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:
|
|
193
|
+
|
|
194
|
+
```py
|
|
195
|
+
import sys
|
|
196
|
+
|
|
197
|
+
from loguru import logger
|
|
198
|
+
|
|
199
|
+
logger.disable("django_simple_api_proxy") # no log from the proxy at all
|
|
200
|
+
|
|
201
|
+
# or
|
|
202
|
+
logger.remove()
|
|
203
|
+
logger.add(sys.stderr, level="INFO", diagnose=False)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Supported versions
|
|
207
|
+
|
|
208
|
+
| | Versions |
|
|
209
|
+
|---|---|
|
|
210
|
+
| Python | 3.8 – 3.14 |
|
|
211
|
+
| Django | 4.0 – 6.1 |
|
|
212
|
+
| Django REST framework | the release pip picks for your Django |
|
|
213
|
+
|
|
214
|
+
CI tests every Django release from 4.0 to 6.1, each on the oldest and the newest Python it supports: 17 combinations.
|
|
215
|
+
|
|
216
|
+
## Development
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
git clone https://github.com/NatLee/django-simple-api-proxy.git
|
|
220
|
+
cd django-simple-api-proxy
|
|
221
|
+
pip install -e ".[test]"
|
|
222
|
+
pytest tests
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The tests don't touch the network: the API server is replaced by a fake `requests.request`. `tests/test_readme.py` runs the Python blocks of this README, so keep them working when you edit it.
|
|
226
|
+
|
|
227
|
+
To run the CI combinations locally:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
pip install tox
|
|
231
|
+
tox -e py # the Python on PATH with the newest Django it can install
|
|
232
|
+
tox -e py312-django42 # one combination; `tox -l` lists them all
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
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).
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
pip install -e .
|
|
239
|
+
cd example/api_proxy_example
|
|
240
|
+
python manage.py runserver
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The diagram at the top is drawn in [`docs/images/request-flow.html`](https://github.com/NatLee/django-simple-api-proxy/blob/main/docs/images/request-flow.html); the command to render it is in the file.
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
[MIT](https://github.com/NatLee/django-simple-api-proxy/blob/main/LICENSE)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
Source of request-flow.png in the README. Re-render it with a headless browser, e.g.
|
|
4
|
+
msedge --headless=new --hide-scrollbars --force-device-scale-factor=2
|
|
5
|
+
--window-size=860,430 --screenshot=request-flow.png request-flow.html
|
|
6
|
+
-->
|
|
7
|
+
<meta charset="utf-8">
|
|
8
|
+
<title>Request flow</title>
|
|
9
|
+
<style>
|
|
10
|
+
html, body { margin: 0; background: #fff; }
|
|
11
|
+
svg { display: block; }
|
|
12
|
+
.t { font-family: "Segoe UI", system-ui, sans-serif; fill: #1f2328; }
|
|
13
|
+
.m { font-family: Consolas, "Cascadia Mono", monospace; fill: #1f2328; }
|
|
14
|
+
.head { font-size: 15px; font-weight: 600; }
|
|
15
|
+
.sub { font-size: 12px; fill: #59636e; }
|
|
16
|
+
.label { font-size: 12.5px; }
|
|
17
|
+
.step { font-size: 13px; }
|
|
18
|
+
.note { font-size: 11.5px; fill: #59636e; }
|
|
19
|
+
.err { font-size: 11.5px; fill: #a40e26; }
|
|
20
|
+
.num { font-family: "Segoe UI", system-ui, sans-serif; font-size: 11px; font-weight: 700; fill: #fff; }
|
|
21
|
+
</style>
|
|
22
|
+
<svg width="860" height="430" viewBox="0 0 860 430" xmlns="http://www.w3.org/2000/svg">
|
|
23
|
+
<defs>
|
|
24
|
+
<marker id="req" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
|
|
25
|
+
<path d="M0,0 L10,5 L0,10 z" fill="#0969da"/>
|
|
26
|
+
</marker>
|
|
27
|
+
<marker id="rep" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
|
|
28
|
+
<path d="M0,0 L10,5 L0,10 z" fill="#1a7f37"/>
|
|
29
|
+
</marker>
|
|
30
|
+
</defs>
|
|
31
|
+
|
|
32
|
+
<rect x="0.5" y="0.5" width="859" height="429" rx="14" fill="#ffffff" stroke="#d0d7de"/>
|
|
33
|
+
|
|
34
|
+
<!-- lifelines -->
|
|
35
|
+
<g stroke="#d0d7de" stroke-width="1.5" stroke-dasharray="4 5">
|
|
36
|
+
<line x1="105" y1="86" x2="105" y2="404"/>
|
|
37
|
+
<line x1="430" y1="86" x2="430" y2="404"/>
|
|
38
|
+
<line x1="750" y1="86" x2="750" y2="404"/>
|
|
39
|
+
</g>
|
|
40
|
+
|
|
41
|
+
<!-- participants -->
|
|
42
|
+
<rect x="30" y="24" width="150" height="62" rx="10" fill="#f6f8fa" stroke="#d0d7de"/>
|
|
43
|
+
<text class="t head" x="105" y="50" text-anchor="middle">Client</text>
|
|
44
|
+
<text class="t sub" x="105" y="70" text-anchor="middle">browser, app, curl</text>
|
|
45
|
+
|
|
46
|
+
<rect x="300" y="24" width="260" height="62" rx="10" fill="#0c4b33"/>
|
|
47
|
+
<text class="t head" x="430" y="50" text-anchor="middle" style="fill:#fff">Your Django site</text>
|
|
48
|
+
<text class="m sub" x="430" y="70" text-anchor="middle" style="fill:#cfe8dc">APIProxy at /my_test_route/</text>
|
|
49
|
+
|
|
50
|
+
<rect x="665" y="24" width="170" height="62" rx="10" fill="#f6f8fa" stroke="#d0d7de"/>
|
|
51
|
+
<text class="t head" x="750" y="50" text-anchor="middle">API server</text>
|
|
52
|
+
<text class="m sub" x="750" y="70" text-anchor="middle">https://httpbin.org</text>
|
|
53
|
+
|
|
54
|
+
<!-- activation bars -->
|
|
55
|
+
<rect x="424" y="120" width="12" height="252" rx="3" fill="#e6efea" stroke="#0c4b33" stroke-width="1"/>
|
|
56
|
+
<rect x="744" y="270" width="12" height="54" rx="3" fill="#eaeef2" stroke="#59636e" stroke-width="1"/>
|
|
57
|
+
|
|
58
|
+
<!-- 1. request in -->
|
|
59
|
+
<text class="m label" x="262" y="120" text-anchor="middle">GET /my_test_route/?q=django</text>
|
|
60
|
+
<line x1="105" y1="130" x2="422" y2="130" stroke="#0969da" stroke-width="2" marker-end="url(#req)"/>
|
|
61
|
+
|
|
62
|
+
<!-- what the proxy does -->
|
|
63
|
+
<rect x="134" y="146" width="280" height="104" rx="8" fill="#f6f8fa" stroke="#d0d7de"/>
|
|
64
|
+
<circle cx="154" cy="170" r="9" fill="#0c4b33"/><text class="num" x="154" y="174" text-anchor="middle">1</text>
|
|
65
|
+
<text class="t step" x="170" y="169">Checks the login</text>
|
|
66
|
+
<text class="t note" x="170" y="184">only if you set authentication classes</text>
|
|
67
|
+
<circle cx="154" cy="206" r="9" fill="#0c4b33"/><text class="num" x="154" y="210" text-anchor="middle">2</text>
|
|
68
|
+
<text class="t step" x="170" y="211">Maps the path <tspan class="m" style="font-size:12.5px">my_test_route → get</tspan></text>
|
|
69
|
+
<circle cx="154" cy="232" r="9" fill="#0c4b33"/><text class="num" x="154" y="236" text-anchor="middle">3</text>
|
|
70
|
+
<text class="t step" x="170" y="237">Adds <tspan class="m" style="font-size:12.5px">username</tspan> (or #anonymous)</text>
|
|
71
|
+
|
|
72
|
+
<!-- 2. request out -->
|
|
73
|
+
<text class="m label" x="592" y="270" text-anchor="middle">GET /get?q=django&username=%23anonymous</text>
|
|
74
|
+
<line x1="436" y1="280" x2="742" y2="280" stroke="#0969da" stroke-width="2" marker-end="url(#req)"/>
|
|
75
|
+
|
|
76
|
+
<!-- 3. reply from the API server -->
|
|
77
|
+
<text class="m label" x="592" y="308" text-anchor="middle">JSON reply</text>
|
|
78
|
+
<line x1="744" y1="318" x2="438" y2="318" stroke="#1a7f37" stroke-width="2" stroke-dasharray="6 4" marker-end="url(#rep)"/>
|
|
79
|
+
|
|
80
|
+
<!-- 4. reply to the client -->
|
|
81
|
+
<text class="m label" x="262" y="352" text-anchor="middle">200 · the same JSON</text>
|
|
82
|
+
<line x1="424" y1="362" x2="107" y2="362" stroke="#1a7f37" stroke-width="2" stroke-dasharray="6 4" marker-end="url(#rep)"/>
|
|
83
|
+
<text class="m err" x="262" y="384" text-anchor="middle">if anything fails: 200 · {"status": "error"}</text>
|
|
84
|
+
</svg>
|
|
Binary file
|