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.
Files changed (37) hide show
  1. {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/MANIFEST.in +2 -1
  2. django_simple_api_proxy-1.0.3/PKG-INFO +307 -0
  3. django_simple_api_proxy-1.0.3/README.md +247 -0
  4. django_simple_api_proxy-1.0.3/docs/images/request-flow.html +84 -0
  5. django_simple_api_proxy-1.0.3/docs/images/request-flow.png +0 -0
  6. {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
  7. {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/setup.py +21 -6
  8. django_simple_api_proxy-1.0.3/src/django_simple_api_proxy/__init__.py +1 -0
  9. django_simple_api_proxy-1.0.3/src/django_simple_api_proxy/urls.py +11 -0
  10. django_simple_api_proxy-1.0.3/src/django_simple_api_proxy/views.py +164 -0
  11. django_simple_api_proxy-1.0.3/src/django_simple_api_proxy.egg-info/PKG-INFO +307 -0
  12. {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
  13. {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
  14. django_simple_api_proxy-1.0.3/tests/test_api_proxy.py +405 -0
  15. django_simple_api_proxy-1.0.3/tests/test_readme.py +152 -0
  16. django-simple-api-proxy-1.0.2/PKG-INFO +0 -162
  17. django-simple-api-proxy-1.0.2/README.md +0 -137
  18. django-simple-api-proxy-1.0.2/src/django_simple_api_proxy/__init__.py +0 -1
  19. django-simple-api-proxy-1.0.2/src/django_simple_api_proxy/admin.py +0 -3
  20. django-simple-api-proxy-1.0.2/src/django_simple_api_proxy/models.py +0 -3
  21. django-simple-api-proxy-1.0.2/src/django_simple_api_proxy/tests.py +0 -3
  22. django-simple-api-proxy-1.0.2/src/django_simple_api_proxy/urls.py +0 -7
  23. django-simple-api-proxy-1.0.2/src/django_simple_api_proxy/views.py +0 -131
  24. django-simple-api-proxy-1.0.2/src/django_simple_api_proxy.egg-info/PKG-INFO +0 -162
  25. django-simple-api-proxy-1.0.2/tests/test_api_proxy.py +0 -17
  26. {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/LICENSE +0 -0
  27. {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
  28. {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
  29. {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
  30. {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
  31. {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/example/api_proxy_example/manage.py +0 -0
  32. {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/pyproject.toml +0 -0
  33. {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/setup.cfg +0 -0
  34. {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/src/django_simple_api_proxy/apps.py +0 -0
  35. {django-simple-api-proxy-1.0.2 → django_simple_api_proxy-1.0.3}/src/django_simple_api_proxy/migrations/__init__.py +0 -0
  36. {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
  37. {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
@@ -2,4 +2,5 @@ include pyproject.toml
2
2
  include *.md
3
3
  include LICENSE
4
4
  recursive-include tests test*.py
5
- recursive-include example *.py
5
+ recursive-include example *.py
6
+ recursive-include docs *.png *.html
@@ -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
+ [![Test](https://github.com/NatLee/django-simple-api-proxy/actions/workflows/test.yml/badge.svg)](https://github.com/NatLee/django-simple-api-proxy/actions/workflows/test.yml)
64
+ [![PyPI](https://img.shields.io/pypi/v/django-simple-api-proxy)](https://pypi.org/project/django-simple-api-proxy/)
65
+ [![Python 3.8–3.14](https://img.shields.io/badge/python-3.8%20%E2%80%93%203.14-3776ab)](#supported-versions)
66
+ [![Django 4.0–6.1](https://img.shields.io/badge/django-4.0%20%E2%80%93%206.1-0c4b33)](#supported-versions)
67
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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
+ ![A client calls GET /my_test_route/?q=django on your Django site. The proxy checks the login if you asked it to, maps my_test_route to get and adds username, then calls GET /get?q=django&username=%23anonymous on https://httpbin.org. The JSON reply goes back to the client with HTTP 200; if anything fails, the client gets 200 with {"status": "error"}.](https://raw.githubusercontent.com/NatLee/django-simple-api-proxy/main/docs/images/request-flow.png)
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
+ [![Test](https://github.com/NatLee/django-simple-api-proxy/actions/workflows/test.yml/badge.svg)](https://github.com/NatLee/django-simple-api-proxy/actions/workflows/test.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/django-simple-api-proxy)](https://pypi.org/project/django-simple-api-proxy/)
5
+ [![Python 3.8–3.14](https://img.shields.io/badge/python-3.8%20%E2%80%93%203.14-3776ab)](#supported-versions)
6
+ [![Django 4.0–6.1](https://img.shields.io/badge/django-4.0%20%E2%80%93%206.1-0c4b33)](#supported-versions)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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
+ ![A client calls GET /my_test_route/?q=django on your Django site. The proxy checks the login if you asked it to, maps my_test_route to get and adds username, then calls GET /get?q=django&username=%23anonymous on https://httpbin.org. The JSON reply goes back to the client with HTTP 200; if anything fails, the client gets 200 with {"status": "error"}.](https://raw.githubusercontent.com/NatLee/django-simple-api-proxy/main/docs/images/request-flow.png)
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&amp;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>
@@ -37,6 +37,7 @@ INSTALLED_APPS = [
37
37
  'django.contrib.sessions',
38
38
  'django.contrib.messages',
39
39
  'django.contrib.staticfiles',
40
+ 'rest_framework', # add here
40
41
  'django_simple_api_proxy' # add here
41
42
  ]
42
43