Skip to content

Commit 98bee74

Browse files
authored
Merge pull request #41 from ipinfo/silvano/eng-653-add-resproxy-support-in-ipinfodjango-library
Add Residential Proxy API support
2 parents 5926ce3 + b7a34d4 commit 98bee74

File tree

7 files changed

+259
-2
lines changed

7 files changed

+259
-2
lines changed

ipinfo_django/middleware.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,74 @@ def __init__(self, get_response):
139139
ipinfo_token = getattr(settings, "IPINFO_TOKEN", None)
140140
ipinfo_settings = getattr(settings, "IPINFO_SETTINGS", {})
141141
self.ipinfo = ipinfo.getHandlerAsyncPlus(ipinfo_token, **ipinfo_settings)
142+
143+
144+
class IPinfoResproxyMiddleware:
145+
def __init__(self, get_response=None):
146+
"""
147+
Initializes class while gettings user settings and creating the cache.
148+
"""
149+
self.get_response = get_response
150+
self.filter = getattr(settings, "IPINFO_FILTER", self.is_bot)
151+
152+
ipinfo_token = getattr(settings, "IPINFO_TOKEN", None)
153+
ipinfo_settings = getattr(settings, "IPINFO_SETTINGS", {})
154+
self.ip_selector = getattr(settings, "IPINFO_IP_SELECTOR", DefaultIPSelector())
155+
self.ipinfo = ipinfo.getHandler(ipinfo_token, **ipinfo_settings)
156+
157+
def __call__(self, request):
158+
"""Middleware hook that acts on and modifies request object."""
159+
try:
160+
if self.filter and self.filter(request):
161+
request.ipinfo_resproxy = None
162+
else:
163+
request.ipinfo_resproxy = self.ipinfo.getResproxy(
164+
self.ip_selector.get_ip(request)
165+
)
166+
except Exception:
167+
request.ipinfo_resproxy = None
168+
LOGGER.error(traceback.format_exc())
169+
170+
response = self.get_response(request)
171+
return response
172+
173+
def is_bot(self, request):
174+
return is_bot(request)
175+
176+
177+
class IPinfoAsyncResproxyMiddleware:
178+
sync_capable = False
179+
async_capable = True
180+
181+
def __init__(self, get_response):
182+
"""Initialize class, get settings, and create the cache."""
183+
self.get_response = get_response
184+
185+
self.filter = getattr(settings, "IPINFO_FILTER", self.is_bot)
186+
187+
ipinfo_token = getattr(settings, "IPINFO_TOKEN", None)
188+
ipinfo_settings = getattr(settings, "IPINFO_SETTINGS", {})
189+
self.ip_selector = getattr(settings, "IPINFO_IP_SELECTOR", DefaultIPSelector())
190+
self.ipinfo = ipinfo.getHandlerAsync(ipinfo_token, **ipinfo_settings)
191+
192+
def __call__(self, request):
193+
return self.__acall__(request)
194+
195+
async def __acall__(self, request):
196+
"""Middleware hook that acts on and modifies request object."""
197+
try:
198+
if self.filter and self.filter(request):
199+
request.ipinfo_resproxy = None
200+
else:
201+
request.ipinfo_resproxy = await self.ipinfo.getResproxy(
202+
self.ip_selector.get_ip(request)
203+
)
204+
except Exception:
205+
request.ipinfo_resproxy = None
206+
LOGGER.error(traceback.format_exc())
207+
208+
response = await self.get_response(request)
209+
return response
210+
211+
def is_bot(self, request):
212+
return is_bot(request)

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@
1818
author_email="support@ipinfo.io",
1919
license="Apache License 2.0",
2020
packages=["ipinfo_django", "ipinfo_django.ip_selector"],
21-
install_requires=["django", "ipinfo>=5.3.0"],
21+
install_requires=["django", "ipinfo>=5.4.0"],
2222
zip_safe=False,
2323
)

tests/conftest.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,17 @@ def ipinfo_async_plus_middleware(settings):
5555
settings.MIDDLEWARE = [
5656
"ipinfo_django.middleware.IPinfoAsyncPlusMiddleware",
5757
]
58+
59+
60+
@pytest.fixture
61+
def ipinfo_resproxy_middleware(settings):
62+
settings.MIDDLEWARE = [
63+
"ipinfo_django.middleware.IPinfoResproxyMiddleware",
64+
]
65+
66+
67+
@pytest.fixture
68+
def ipinfo_async_resproxy_middleware(settings):
69+
settings.MIDDLEWARE = [
70+
"ipinfo_django.middleware.IPinfoAsyncResproxyMiddleware",
71+
]
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from http import HTTPStatus
2+
from unittest import mock
3+
4+
import pytest
5+
from ipinfo.details import Details
6+
7+
8+
@pytest.mark.asyncio
9+
async def test_middleware_appends_resproxy_info(
10+
async_client, ipinfo_async_resproxy_middleware
11+
):
12+
with mock.patch("ipinfo.AsyncHandler.getResproxy") as mocked_getResproxy:
13+
mocked_getResproxy.return_value = Details(
14+
{
15+
"ip": "127.0.0.1",
16+
"last_seen": "2026-01-15",
17+
"percent_days_seen": 100,
18+
"service": "test_service",
19+
}
20+
)
21+
res = await async_client.get("/test_resproxy_view/")
22+
assert res.status_code == HTTPStatus.OK
23+
assert b"Resproxy for: 127.0.0.1" in res.content
24+
25+
26+
@pytest.mark.asyncio
27+
async def test_middleware_filters(async_client, ipinfo_async_resproxy_middleware):
28+
res = await async_client.get("/test_resproxy_view/", USER_AGENT="some bot")
29+
assert res.status_code == HTTPStatus.OK
30+
assert b"Request filtered." in res.content
31+
32+
33+
@pytest.mark.asyncio
34+
async def test_middleware_behind_proxy(async_client, ipinfo_async_resproxy_middleware):
35+
with mock.patch("ipinfo.AsyncHandler.getResproxy") as mocked_getResproxy:
36+
mocked_getResproxy.return_value = Details(
37+
{
38+
"ip": "93.44.186.197",
39+
"last_seen": "2026-01-15",
40+
"percent_days_seen": 100,
41+
"service": "test_service",
42+
}
43+
)
44+
res = await async_client.get(
45+
"/test_resproxy_view/", X_FORWARDED_FOR="93.44.186.197"
46+
)
47+
48+
mocked_getResproxy.assert_called_once_with("93.44.186.197")
49+
assert res.status_code == HTTPStatus.OK
50+
assert b"Resproxy for: 93.44.186.197" in res.content
51+
52+
53+
@pytest.mark.asyncio
54+
async def test_middleware_not_behind_proxy(
55+
async_client, ipinfo_async_resproxy_middleware
56+
):
57+
with mock.patch("ipinfo.AsyncHandler.getResproxy") as mocked_getResproxy:
58+
mocked_getResproxy.return_value = Details(
59+
{
60+
"ip": "127.0.0.1",
61+
"last_seen": "2026-01-15",
62+
"percent_days_seen": 100,
63+
"service": "test_service",
64+
}
65+
)
66+
res = await async_client.get("/test_resproxy_view/")
67+
68+
mocked_getResproxy.assert_called_once_with("127.0.0.1")
69+
assert res.status_code == HTTPStatus.OK
70+
assert b"Resproxy for: 127.0.0.1" in res.content
71+
72+
73+
@pytest.mark.asyncio
74+
async def test_middleware_empty_response(
75+
async_client, ipinfo_async_resproxy_middleware
76+
):
77+
"""Test that empty response from API (IP not in resproxy database) is passed through."""
78+
with mock.patch("ipinfo.AsyncHandler.getResproxy") as mocked_getResproxy:
79+
mocked_getResproxy.return_value = Details({})
80+
res = await async_client.get("/test_resproxy_view/")
81+
82+
mocked_getResproxy.assert_called_once_with("127.0.0.1")
83+
assert res.status_code == HTTPStatus.OK
84+
assert b"Empty resproxy response." in res.content

tests/test_resproxy_middleware.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from http import HTTPStatus
2+
from unittest import mock
3+
4+
from ipinfo.details import Details
5+
6+
7+
def test_middleware_appends_resproxy_info(client, ipinfo_resproxy_middleware):
8+
with mock.patch("ipinfo.Handler.getResproxy") as mocked_getResproxy:
9+
mocked_getResproxy.return_value = Details(
10+
{
11+
"ip": "127.0.0.1",
12+
"last_seen": "2026-01-15",
13+
"percent_days_seen": 100,
14+
"service": "test_service",
15+
}
16+
)
17+
res = client.get("/test_resproxy_view/")
18+
assert res.status_code == HTTPStatus.OK
19+
assert b"Resproxy for: 127.0.0.1" in res.content
20+
21+
22+
def test_middleware_filters(client, ipinfo_resproxy_middleware):
23+
res = client.get("/test_resproxy_view/", HTTP_USER_AGENT="some bot")
24+
assert res.status_code == HTTPStatus.OK
25+
assert b"Request filtered." in res.content
26+
27+
28+
def test_middleware_behind_proxy(client, ipinfo_resproxy_middleware):
29+
with mock.patch("ipinfo.Handler.getResproxy") as mocked_getResproxy:
30+
mocked_getResproxy.return_value = Details(
31+
{
32+
"ip": "93.44.186.197",
33+
"last_seen": "2026-01-15",
34+
"percent_days_seen": 100,
35+
"service": "test_service",
36+
}
37+
)
38+
res = client.get("/test_resproxy_view/", HTTP_X_FORWARDED_FOR="93.44.186.197")
39+
40+
mocked_getResproxy.assert_called_once_with("93.44.186.197")
41+
assert res.status_code == HTTPStatus.OK
42+
assert b"Resproxy for: 93.44.186.197" in res.content
43+
44+
45+
def test_middleware_not_behind_proxy(client, ipinfo_resproxy_middleware):
46+
with mock.patch("ipinfo.Handler.getResproxy") as mocked_getResproxy:
47+
mocked_getResproxy.return_value = Details(
48+
{
49+
"ip": "127.0.0.1",
50+
"last_seen": "2026-01-15",
51+
"percent_days_seen": 100,
52+
"service": "test_service",
53+
}
54+
)
55+
res = client.get("/test_resproxy_view/")
56+
57+
mocked_getResproxy.assert_called_once_with("127.0.0.1")
58+
assert res.status_code == HTTPStatus.OK
59+
assert b"Resproxy for: 127.0.0.1" in res.content
60+
61+
62+
def test_middleware_empty_response(client, ipinfo_resproxy_middleware):
63+
"""Test that empty response from API (IP not in resproxy database) is passed through."""
64+
with mock.patch("ipinfo.Handler.getResproxy") as mocked_getResproxy:
65+
mocked_getResproxy.return_value = Details({})
66+
res = client.get("/test_resproxy_view/")
67+
68+
mocked_getResproxy.assert_called_once_with("127.0.0.1")
69+
assert res.status_code == HTTPStatus.OK
70+
assert b"Empty resproxy response." in res.content

tests/urls.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,19 @@ async def test_view(request):
1111
return HttpResponse("Request filtered.", status=200)
1212

1313

14-
urlpatterns = [path("test_view/", test_view)]
14+
async def test_resproxy_view(request):
15+
ipinfo_resproxy = getattr(request, "ipinfo_resproxy", None)
16+
17+
if ipinfo_resproxy:
18+
ip = getattr(ipinfo_resproxy, "ip", None)
19+
if ip:
20+
return HttpResponse(f"Resproxy for: {ip}", status=200)
21+
return HttpResponse("Empty resproxy response.", status=200)
22+
23+
return HttpResponse("Request filtered.", status=200)
24+
25+
26+
urlpatterns = [
27+
path("test_view/", test_view),
28+
path("test_resproxy_view/", test_resproxy_view),
29+
]

uv.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)