ipregistry-fastapi 1.0.0__py3-none-any.whl
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.
- ipregistry_fastapi/__init__.py +74 -0
- ipregistry_fastapi/blocking.py +145 -0
- ipregistry_fastapi/dependencies.py +72 -0
- ipregistry_fastapi/exceptions.py +32 -0
- ipregistry_fastapi/guards.py +81 -0
- ipregistry_fastapi/ip.py +153 -0
- ipregistry_fastapi/lifespan.py +65 -0
- ipregistry_fastapi/middleware.py +144 -0
- ipregistry_fastapi/py.typed +0 -0
- ipregistry_fastapi/service.py +202 -0
- ipregistry_fastapi/settings.py +128 -0
- ipregistry_fastapi/testing.py +186 -0
- ipregistry_fastapi/version.py +17 -0
- ipregistry_fastapi-1.0.0.dist-info/METADATA +359 -0
- ipregistry_fastapi-1.0.0.dist-info/RECORD +17 -0
- ipregistry_fastapi-1.0.0.dist-info/WHEEL +4 -0
- ipregistry_fastapi-1.0.0.dist-info/licenses/LICENSE.txt +201 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2026 Ipregistry (https://ipregistry.co).
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Callable, Sequence, Union
|
|
20
|
+
|
|
21
|
+
from fastapi import HTTPException, Request
|
|
22
|
+
from fastapi.responses import PlainTextResponse
|
|
23
|
+
from ipregistry import UserAgents
|
|
24
|
+
|
|
25
|
+
from .blocking import Blocker
|
|
26
|
+
from .dependencies import SERVICE_UNAVAILABLE_DETAIL
|
|
27
|
+
from .service import Ipregistry
|
|
28
|
+
from .settings import IpregistrySettings
|
|
29
|
+
|
|
30
|
+
STATIC_ASSET_SUFFIXES = (
|
|
31
|
+
".avif", ".css", ".gif", ".ico", ".jpeg", ".jpg", ".js", ".map", ".mjs",
|
|
32
|
+
".otf", ".png", ".svg", ".ttf", ".txt", ".webmanifest", ".webp", ".woff", ".woff2",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class IpregistryMiddleware:
|
|
37
|
+
"""Pure ASGI middleware enriching every HTTP request with IP intelligence.
|
|
38
|
+
|
|
39
|
+
Performs a single cached lookup per request, stores the result on
|
|
40
|
+
request.state.ipregistry (shared with the IpInfoDep dependency), and
|
|
41
|
+
optionally applies blocking actions before the request reaches the router:
|
|
42
|
+
|
|
43
|
+
app.add_middleware(
|
|
44
|
+
IpregistryMiddleware,
|
|
45
|
+
actions=[block_countries("XX"), block_threats(tor=True)],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
Prefer route dependencies for per-endpoint rules; use the middleware to
|
|
49
|
+
enforce app-wide policies or to eagerly enrich every request.
|
|
50
|
+
|
|
51
|
+
Parameters:
|
|
52
|
+
service: the Ipregistry service to use. Defaults to the one attached to
|
|
53
|
+
the application state, else one built from settings / the environment.
|
|
54
|
+
settings: settings used when building a service lazily.
|
|
55
|
+
fields / hostname: per-lookup overrides forwarded to the lookup.
|
|
56
|
+
actions: blocking rules (block_countries, block_threats, or any Blocker)
|
|
57
|
+
evaluated in order; the first match returns its error response.
|
|
58
|
+
fail_closed: when True, respond with 503 if the lookup fails; an int
|
|
59
|
+
customizes the status code. Defaults to False (fail open).
|
|
60
|
+
skip_static_assets: skip lookups for common static file extensions.
|
|
61
|
+
skip_bots: skip lookups when the User-Agent looks like a bot or crawler.
|
|
62
|
+
skip: custom predicate receiving the request; return True to skip.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
app,
|
|
68
|
+
*,
|
|
69
|
+
service: Ipregistry | None = None,
|
|
70
|
+
settings: IpregistrySettings | None = None,
|
|
71
|
+
fields: str | None = None,
|
|
72
|
+
hostname: bool | None = None,
|
|
73
|
+
actions: Sequence[Blocker] = (),
|
|
74
|
+
fail_closed: Union[bool, int] = False,
|
|
75
|
+
skip_static_assets: bool = True,
|
|
76
|
+
skip_bots: bool = False,
|
|
77
|
+
skip: Callable[[Request], bool] | None = None,
|
|
78
|
+
):
|
|
79
|
+
self.app = app
|
|
80
|
+
self._service = service
|
|
81
|
+
self._settings = settings
|
|
82
|
+
self._fields = fields
|
|
83
|
+
self._hostname = hostname
|
|
84
|
+
self._actions = tuple(actions)
|
|
85
|
+
self._fail_closed = fail_closed
|
|
86
|
+
self._skip_static_assets = skip_static_assets
|
|
87
|
+
self._skip_bots = skip_bots
|
|
88
|
+
self._skip = skip
|
|
89
|
+
|
|
90
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
91
|
+
if scope["type"] != "http":
|
|
92
|
+
await self.app(scope, receive, send)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
scope.setdefault("state", {})
|
|
96
|
+
request = Request(scope, receive)
|
|
97
|
+
|
|
98
|
+
if not self._should_skip(request):
|
|
99
|
+
service = self._resolve_service(scope)
|
|
100
|
+
info = await service.for_request(request, fields=self._fields, hostname=self._hostname)
|
|
101
|
+
|
|
102
|
+
if info is None and self._fail_closed and service.request_error(request) is not None:
|
|
103
|
+
status_code = 503 if self._fail_closed is True else int(self._fail_closed)
|
|
104
|
+
response = PlainTextResponse(SERVICE_UNAVAILABLE_DETAIL, status_code=status_code)
|
|
105
|
+
await response(scope, receive, send)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
for action in self._actions:
|
|
109
|
+
try:
|
|
110
|
+
action.check(info)
|
|
111
|
+
except HTTPException as exception:
|
|
112
|
+
response = PlainTextResponse(
|
|
113
|
+
str(exception.detail),
|
|
114
|
+
status_code=exception.status_code,
|
|
115
|
+
headers=exception.headers,
|
|
116
|
+
)
|
|
117
|
+
await response(scope, receive, send)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
await self.app(scope, receive, send)
|
|
121
|
+
|
|
122
|
+
def _resolve_service(self, scope) -> Ipregistry:
|
|
123
|
+
if self._service is not None:
|
|
124
|
+
return self._service
|
|
125
|
+
app = scope.get("app")
|
|
126
|
+
if app is not None:
|
|
127
|
+
existing = getattr(app.state, "ipregistry", None)
|
|
128
|
+
if existing is not None:
|
|
129
|
+
self._service = existing
|
|
130
|
+
return existing
|
|
131
|
+
service = Ipregistry(self._settings)
|
|
132
|
+
if app is not None:
|
|
133
|
+
app.state.ipregistry = service
|
|
134
|
+
self._service = service
|
|
135
|
+
return service
|
|
136
|
+
|
|
137
|
+
def _should_skip(self, request: Request) -> bool:
|
|
138
|
+
if self._skip_static_assets and request.url.path.lower().endswith(STATIC_ASSET_SUFFIXES):
|
|
139
|
+
return True
|
|
140
|
+
if self._skip_bots:
|
|
141
|
+
user_agent = request.headers.get("user-agent", "")
|
|
142
|
+
if user_agent and UserAgents.is_bot(user_agent):
|
|
143
|
+
return True
|
|
144
|
+
return self._skip is not None and self._skip(request)
|
|
File without changes
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2026 Ipregistry (https://ipregistry.co).
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Iterable, Union
|
|
21
|
+
|
|
22
|
+
from fastapi.requests import HTTPConnection
|
|
23
|
+
from ipregistry import (
|
|
24
|
+
AsyncIpregistryClient,
|
|
25
|
+
InMemoryCache,
|
|
26
|
+
IpInfo,
|
|
27
|
+
IpregistryConfig,
|
|
28
|
+
IpregistryError,
|
|
29
|
+
IpregistryLookupError,
|
|
30
|
+
NoCache,
|
|
31
|
+
RequesterIpInfo,
|
|
32
|
+
UserAgent,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from .exceptions import MissingApiKeyError
|
|
36
|
+
from .ip import IpExtractor, IpSource, anonymize_ip, build_ip_extractor, is_public_ip
|
|
37
|
+
from .settings import IpregistrySettings
|
|
38
|
+
from .version import __version__
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("ipregistry_fastapi")
|
|
41
|
+
|
|
42
|
+
STATE_KEY = "ipregistry"
|
|
43
|
+
"""Attribute under which the looked up IpInfo is memoized on request.state."""
|
|
44
|
+
|
|
45
|
+
ERROR_STATE_KEY = "ipregistry_error"
|
|
46
|
+
"""Attribute under which a lookup failure is recorded on request.state."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Ipregistry:
|
|
50
|
+
"""Request-aware Ipregistry service built on the asynchronous Ipregistry client.
|
|
51
|
+
|
|
52
|
+
Explicit lookups (lookup, lookup_batch, lookup_origin, parse_user_agent) raise
|
|
53
|
+
on failure. The request-aware for_request never raises: it memoizes a single
|
|
54
|
+
lookup per request on request.state and fails open, recording the error under
|
|
55
|
+
request.state.ipregistry_error.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
settings: IpregistrySettings | None = None,
|
|
61
|
+
*,
|
|
62
|
+
client: AsyncIpregistryClient | None = None,
|
|
63
|
+
ip_extractor: IpSource | None = None,
|
|
64
|
+
):
|
|
65
|
+
self._settings = settings if settings is not None else IpregistrySettings()
|
|
66
|
+
self._ip_extractor: IpExtractor = build_ip_extractor(
|
|
67
|
+
ip_extractor if ip_extractor is not None else self._settings.ip_source
|
|
68
|
+
)
|
|
69
|
+
if client is None:
|
|
70
|
+
if not self._settings.api_key:
|
|
71
|
+
raise MissingApiKeyError()
|
|
72
|
+
config = IpregistryConfig(
|
|
73
|
+
key=self._settings.api_key,
|
|
74
|
+
base_url=self._settings.resolved_base_url,
|
|
75
|
+
timeout=self._settings.timeout,
|
|
76
|
+
retry_max_attempts=self._settings.retries + 1,
|
|
77
|
+
retry_interval=self._settings.retry_interval,
|
|
78
|
+
retry_on_server_error=self._settings.retry_on_server_error,
|
|
79
|
+
retry_on_too_many_requests=self._settings.retry_on_too_many_requests,
|
|
80
|
+
user_agent=f"IpregistryFastAPI/{__version__}",
|
|
81
|
+
)
|
|
82
|
+
cache = (
|
|
83
|
+
InMemoryCache(maxsize=self._settings.cache_max_size, ttl=self._settings.cache_ttl)
|
|
84
|
+
if self._settings.cache_enabled
|
|
85
|
+
else NoCache()
|
|
86
|
+
)
|
|
87
|
+
client = AsyncIpregistryClient(config, cache=cache)
|
|
88
|
+
self._client = client
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def client(self) -> AsyncIpregistryClient:
|
|
92
|
+
"""The underlying asynchronous Ipregistry client, for advanced use cases
|
|
93
|
+
such as ASN lookups or credits/throttling introspection."""
|
|
94
|
+
return self._client
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def settings(self) -> IpregistrySettings:
|
|
98
|
+
return self._settings
|
|
99
|
+
|
|
100
|
+
async def aclose(self) -> None:
|
|
101
|
+
"""Release the underlying HTTP resources."""
|
|
102
|
+
await self._client.aclose()
|
|
103
|
+
|
|
104
|
+
async def __aenter__(self) -> "Ipregistry":
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
|
|
108
|
+
await self.aclose()
|
|
109
|
+
|
|
110
|
+
async def lookup(
|
|
111
|
+
self,
|
|
112
|
+
ip: str,
|
|
113
|
+
*,
|
|
114
|
+
fields: str | None = None,
|
|
115
|
+
hostname: bool | None = None,
|
|
116
|
+
**params: Any,
|
|
117
|
+
) -> IpInfo:
|
|
118
|
+
"""Look up IP intelligence for the given address. Raises ApiError or
|
|
119
|
+
ClientError on failure."""
|
|
120
|
+
response = await self._client.lookup_ip(ip, **self._options(fields, hostname, params))
|
|
121
|
+
return response.data
|
|
122
|
+
|
|
123
|
+
async def lookup_batch(
|
|
124
|
+
self,
|
|
125
|
+
ips: Iterable[str],
|
|
126
|
+
*,
|
|
127
|
+
fields: str | None = None,
|
|
128
|
+
hostname: bool | None = None,
|
|
129
|
+
**params: Any,
|
|
130
|
+
) -> list[Union[IpInfo, IpregistryLookupError]]:
|
|
131
|
+
"""Look up multiple addresses in one round trip. The result preserves
|
|
132
|
+
input order; failed entries are IpregistryLookupError instances."""
|
|
133
|
+
response = await self._client.batch_lookup_ips(list(ips), **self._options(fields, hostname, params))
|
|
134
|
+
return response.data
|
|
135
|
+
|
|
136
|
+
async def lookup_origin(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
fields: str | None = None,
|
|
140
|
+
hostname: bool | None = None,
|
|
141
|
+
**params: Any,
|
|
142
|
+
) -> RequesterIpInfo:
|
|
143
|
+
"""Look up IP intelligence for the machine executing this code."""
|
|
144
|
+
response = await self._client.origin_lookup_ip(**self._options(fields, hostname, params))
|
|
145
|
+
return response.data
|
|
146
|
+
|
|
147
|
+
async def parse_user_agent(self, user_agent: str, **params: Any) -> UserAgent:
|
|
148
|
+
"""Parse a User-Agent header value through the Ipregistry API."""
|
|
149
|
+
response = await self._client.parse_user_agent(user_agent, **params)
|
|
150
|
+
return response.data
|
|
151
|
+
|
|
152
|
+
def client_ip(self, connection: HTTPConnection) -> str | None:
|
|
153
|
+
"""Resolve the client IP for a request using the configured IP source.
|
|
154
|
+
|
|
155
|
+
Returns the configured development_ip when the extracted address is
|
|
156
|
+
missing or not publicly routable, and None when no usable address
|
|
157
|
+
remains (private and reserved addresses are never sent to the API).
|
|
158
|
+
"""
|
|
159
|
+
ip = self._ip_extractor(connection)
|
|
160
|
+
if ip is not None and is_public_ip(ip):
|
|
161
|
+
return ip
|
|
162
|
+
return self._settings.development_ip
|
|
163
|
+
|
|
164
|
+
async def for_request(
|
|
165
|
+
self,
|
|
166
|
+
connection: HTTPConnection,
|
|
167
|
+
*,
|
|
168
|
+
fields: str | None = None,
|
|
169
|
+
hostname: bool | None = None,
|
|
170
|
+
) -> IpInfo | None:
|
|
171
|
+
"""Look up IP intelligence for the request's client, memoized on
|
|
172
|
+
request.state.ipregistry. Never raises: on failure the request proceeds
|
|
173
|
+
without data and the error is stored on request.state.ipregistry_error."""
|
|
174
|
+
state = connection.state
|
|
175
|
+
if hasattr(state, STATE_KEY):
|
|
176
|
+
return getattr(state, STATE_KEY)
|
|
177
|
+
|
|
178
|
+
info: IpInfo | None = None
|
|
179
|
+
ip = self.client_ip(connection)
|
|
180
|
+
if ip is not None:
|
|
181
|
+
try:
|
|
182
|
+
info = await self.lookup(ip, fields=fields, hostname=hostname)
|
|
183
|
+
except IpregistryError as error:
|
|
184
|
+
setattr(state, ERROR_STATE_KEY, error)
|
|
185
|
+
logger.warning("Ipregistry lookup failed for %s: %s", anonymize_ip(ip), error)
|
|
186
|
+
setattr(state, STATE_KEY, info)
|
|
187
|
+
return info
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def request_error(connection: HTTPConnection) -> IpregistryError | None:
|
|
191
|
+
"""The error recorded by for_request for this request, if any."""
|
|
192
|
+
return getattr(connection.state, ERROR_STATE_KEY, None)
|
|
193
|
+
|
|
194
|
+
def _options(self, fields: str | None, hostname: bool | None, params: dict[str, Any]) -> dict[str, Any]:
|
|
195
|
+
options = dict(params)
|
|
196
|
+
resolved_fields = fields if fields is not None else self._settings.fields
|
|
197
|
+
if resolved_fields:
|
|
198
|
+
options["fields"] = resolved_fields
|
|
199
|
+
resolved_hostname = hostname if hostname is not None else self._settings.hostname
|
|
200
|
+
if resolved_hostname:
|
|
201
|
+
options["hostname"] = True
|
|
202
|
+
return options
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2026 Ipregistry (https://ipregistry.co).
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ipaddress
|
|
20
|
+
|
|
21
|
+
from pydantic import field_validator
|
|
22
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.ipregistry.co"
|
|
25
|
+
EU_BASE_URL = "https://eu.api.ipregistry.co"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IpregistrySettings(BaseSettings):
|
|
29
|
+
"""Ipregistry configuration, loaded from IPREGISTRY_* environment variables.
|
|
30
|
+
|
|
31
|
+
Every field can also be set programmatically:
|
|
32
|
+
IpregistrySettings(api_key="...", base_url="eu").
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
model_config = SettingsConfigDict(env_prefix="IPREGISTRY_", case_sensitive=False, extra="ignore")
|
|
36
|
+
|
|
37
|
+
api_key: str = ""
|
|
38
|
+
"""Your Ipregistry API key. Required unless a pre-configured client is injected."""
|
|
39
|
+
|
|
40
|
+
base_url: str | None = None
|
|
41
|
+
"""API base URL. None uses the default endpoint; the shorthand 'eu' selects the
|
|
42
|
+
EU-only endpoint (https://eu.api.ipregistry.co); any other value is used verbatim."""
|
|
43
|
+
|
|
44
|
+
fields: str | None = None
|
|
45
|
+
"""Default field selection applied to lookups (e.g. 'location,security'), reducing
|
|
46
|
+
payload size and latency. None returns the full response."""
|
|
47
|
+
|
|
48
|
+
hostname: bool = False
|
|
49
|
+
"""Whether to resolve the hostname associated with looked up IP addresses."""
|
|
50
|
+
|
|
51
|
+
timeout: float = 5.0
|
|
52
|
+
"""Timeout in seconds for API requests. Kept low by default since lookups run on
|
|
53
|
+
the request path."""
|
|
54
|
+
|
|
55
|
+
retries: int = 1
|
|
56
|
+
"""Number of retry attempts after a failed request (0 disables retries)."""
|
|
57
|
+
|
|
58
|
+
retry_interval: float = 0.5
|
|
59
|
+
"""Base delay in seconds between retries; grows exponentially with each attempt."""
|
|
60
|
+
|
|
61
|
+
retry_on_server_error: bool = True
|
|
62
|
+
"""Whether to retry requests failing with a 5xx status."""
|
|
63
|
+
|
|
64
|
+
retry_on_too_many_requests: bool = False
|
|
65
|
+
"""Whether to retry requests failing with a 429 status, honoring Retry-After."""
|
|
66
|
+
|
|
67
|
+
cache_enabled: bool = True
|
|
68
|
+
"""Whether to cache lookup responses in memory."""
|
|
69
|
+
|
|
70
|
+
cache_max_size: int = 2048
|
|
71
|
+
"""Maximum number of cached lookup responses."""
|
|
72
|
+
|
|
73
|
+
cache_ttl: int = 600
|
|
74
|
+
"""Time-to-live in seconds for cached lookup responses."""
|
|
75
|
+
|
|
76
|
+
development_ip: str | None = None
|
|
77
|
+
"""Fallback IP address used when the client IP is private or missing, e.g. when
|
|
78
|
+
developing on localhost. Must be a valid IP address."""
|
|
79
|
+
|
|
80
|
+
fail_open: bool = True
|
|
81
|
+
"""When True (default), requests proceed without IP data if a lookup fails.
|
|
82
|
+
When False, request-aware helpers respond with 503 on lookup failure."""
|
|
83
|
+
|
|
84
|
+
ip_source: str = "client"
|
|
85
|
+
"""How to determine the client IP: 'client' (the ASGI client address, honoring
|
|
86
|
+
your server's proxy-headers configuration), a trusted-proxy preset ('auto',
|
|
87
|
+
'cloudflare', 'nginx', 'forwarded-for'), or 'header:<name>' for a custom header."""
|
|
88
|
+
|
|
89
|
+
@field_validator("ip_source")
|
|
90
|
+
@classmethod
|
|
91
|
+
def _validate_ip_source(cls, value: str) -> str:
|
|
92
|
+
from .ip import build_ip_extractor
|
|
93
|
+
|
|
94
|
+
build_ip_extractor(value) # raises ValueError on unknown sources
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
@field_validator("development_ip")
|
|
98
|
+
@classmethod
|
|
99
|
+
def _validate_development_ip(cls, value: str | None) -> str | None:
|
|
100
|
+
if value is None or value == "":
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
ipaddress.ip_address(value)
|
|
104
|
+
except ValueError as error:
|
|
105
|
+
raise ValueError(f"development_ip is not a valid IP address: {value!r}") from error
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
@field_validator("timeout", "retry_interval")
|
|
109
|
+
@classmethod
|
|
110
|
+
def _validate_positive(cls, value: float) -> float:
|
|
111
|
+
if value <= 0:
|
|
112
|
+
raise ValueError("must be greater than 0")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
@field_validator("retries", "cache_max_size", "cache_ttl")
|
|
116
|
+
@classmethod
|
|
117
|
+
def _validate_non_negative(cls, value: int) -> int:
|
|
118
|
+
if value < 0:
|
|
119
|
+
raise ValueError("must not be negative")
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def resolved_base_url(self) -> str:
|
|
124
|
+
if not self.base_url:
|
|
125
|
+
return DEFAULT_BASE_URL
|
|
126
|
+
if self.base_url.strip().lower() == "eu":
|
|
127
|
+
return EU_BASE_URL
|
|
128
|
+
return self.base_url
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2026 Ipregistry (https://ipregistry.co).
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, Iterable, Mapping, Union
|
|
20
|
+
|
|
21
|
+
from fastapi.requests import HTTPConnection
|
|
22
|
+
from ipregistry import ApiError, IpInfo, IpregistryError, IpregistryLookupError, RequesterIpInfo, UserAgent
|
|
23
|
+
|
|
24
|
+
from .ip import IpSource, is_valid_ip
|
|
25
|
+
from .service import Ipregistry
|
|
26
|
+
from .settings import IpregistrySettings
|
|
27
|
+
|
|
28
|
+
ResponseStub = Union[Mapping[str, Any], IpInfo, Exception]
|
|
29
|
+
|
|
30
|
+
WILDCARD = "*"
|
|
31
|
+
ORIGIN = "origin"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _UnusedClient:
|
|
35
|
+
"""Placeholder ensuring an IpregistryFake never reaches the network."""
|
|
36
|
+
|
|
37
|
+
def __getattr__(self, name: str):
|
|
38
|
+
raise AssertionError("IpregistryFake performs no real API calls")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class IpregistryFake(Ipregistry):
|
|
42
|
+
"""Drop-in Ipregistry service for tests: no network, canned responses.
|
|
43
|
+
|
|
44
|
+
Responses are keyed by IP address; '*' matches any address and 'origin'
|
|
45
|
+
serves origin lookups. A value may be an API-shaped dict (the 'ip' field is
|
|
46
|
+
filled in automatically), an IpInfo instance, or an exception to raise:
|
|
47
|
+
|
|
48
|
+
fake = IpregistryFake({
|
|
49
|
+
"66.165.2.7": {"location": {"country": {"code": "US"}}},
|
|
50
|
+
"*": {"security": {"is_threat": False}},
|
|
51
|
+
})
|
|
52
|
+
add_ipregistry(app, service=fake)
|
|
53
|
+
|
|
54
|
+
Unlike the real service, private and loopback client addresses are used
|
|
55
|
+
verbatim so requests made through TestClient resolve out of the box.
|
|
56
|
+
Lookups without a matching stub raise an ApiError (which request-aware
|
|
57
|
+
helpers turn into a fail-open None).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
responses: Mapping[str, ResponseStub] | None = None,
|
|
63
|
+
*,
|
|
64
|
+
settings: IpregistrySettings | None = None,
|
|
65
|
+
ip_extractor: IpSource | None = None,
|
|
66
|
+
):
|
|
67
|
+
super().__init__(
|
|
68
|
+
settings if settings is not None else IpregistrySettings(api_key="fake"),
|
|
69
|
+
client=_UnusedClient(), # type: ignore[arg-type]
|
|
70
|
+
ip_extractor=ip_extractor,
|
|
71
|
+
)
|
|
72
|
+
self._responses: dict[str, ResponseStub] = {}
|
|
73
|
+
self.lookups: list[str] = []
|
|
74
|
+
self.origin_lookups: int = 0
|
|
75
|
+
self.parsed_user_agents: list[str] = []
|
|
76
|
+
if responses:
|
|
77
|
+
self.stub(responses)
|
|
78
|
+
|
|
79
|
+
def stub(self, responses: Mapping[str, ResponseStub]) -> "IpregistryFake":
|
|
80
|
+
"""Register additional canned responses, keyed by IP ('*', 'origin')."""
|
|
81
|
+
self._responses.update(responses)
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def reset(self) -> "IpregistryFake":
|
|
85
|
+
"""Clear all recorded lookups."""
|
|
86
|
+
self.lookups.clear()
|
|
87
|
+
self.origin_lookups = 0
|
|
88
|
+
self.parsed_user_agents.clear()
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
async def aclose(self) -> None:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
async def lookup(self, ip: str, *, fields=None, hostname=None, **params: Any) -> IpInfo:
|
|
95
|
+
self.lookups.append(ip)
|
|
96
|
+
stub = self._resolve_stub(ip)
|
|
97
|
+
if isinstance(stub, Exception):
|
|
98
|
+
raise stub
|
|
99
|
+
if isinstance(stub, IpInfo):
|
|
100
|
+
return stub
|
|
101
|
+
data = dict(stub)
|
|
102
|
+
data.setdefault("ip", ip if is_valid_ip(ip) else None)
|
|
103
|
+
return IpInfo(**data)
|
|
104
|
+
|
|
105
|
+
async def lookup_batch(self, ips: Iterable[str], *, fields=None, hostname=None, **params: Any):
|
|
106
|
+
results: list[Union[IpInfo, IpregistryLookupError]] = []
|
|
107
|
+
for ip in ips:
|
|
108
|
+
try:
|
|
109
|
+
results.append(await self.lookup(ip, fields=fields, hostname=hostname, **params))
|
|
110
|
+
except IpregistryLookupError as error:
|
|
111
|
+
results.append(error)
|
|
112
|
+
except IpregistryError as error:
|
|
113
|
+
results.append(IpregistryLookupError({"code": "UNKNOWN", "message": str(error), "resolution": ""}))
|
|
114
|
+
return results
|
|
115
|
+
|
|
116
|
+
async def lookup_origin(self, *, fields=None, hostname=None, **params: Any) -> RequesterIpInfo:
|
|
117
|
+
self.origin_lookups += 1
|
|
118
|
+
stub = self._responses.get(ORIGIN)
|
|
119
|
+
if stub is None:
|
|
120
|
+
raise self._missing(ORIGIN)
|
|
121
|
+
if isinstance(stub, Exception):
|
|
122
|
+
raise stub
|
|
123
|
+
if isinstance(stub, RequesterIpInfo):
|
|
124
|
+
return stub
|
|
125
|
+
if isinstance(stub, IpInfo):
|
|
126
|
+
return RequesterIpInfo(**stub.model_dump())
|
|
127
|
+
return RequesterIpInfo(**dict(stub))
|
|
128
|
+
|
|
129
|
+
async def parse_user_agent(self, user_agent: str, **params: Any) -> UserAgent:
|
|
130
|
+
self.parsed_user_agents.append(user_agent)
|
|
131
|
+
stub = self._responses.get(user_agent)
|
|
132
|
+
if isinstance(stub, Exception):
|
|
133
|
+
raise stub
|
|
134
|
+
if isinstance(stub, Mapping):
|
|
135
|
+
data = dict(stub)
|
|
136
|
+
data.setdefault("header", user_agent)
|
|
137
|
+
return UserAgent(**data)
|
|
138
|
+
return UserAgent(header=user_agent)
|
|
139
|
+
|
|
140
|
+
def client_ip(self, connection: HTTPConnection) -> str | None:
|
|
141
|
+
ip = self._ip_extractor(connection)
|
|
142
|
+
if ip:
|
|
143
|
+
return ip
|
|
144
|
+
if connection.client is not None and connection.client.host:
|
|
145
|
+
return connection.client.host
|
|
146
|
+
return self.settings.development_ip
|
|
147
|
+
|
|
148
|
+
# Assertions
|
|
149
|
+
|
|
150
|
+
def assert_looked_up(self, ip: str) -> None:
|
|
151
|
+
assert ip in self.lookups, f"Expected a lookup for {ip!r}; recorded lookups: {self.lookups!r}"
|
|
152
|
+
|
|
153
|
+
def assert_not_looked_up(self, ip: str) -> None:
|
|
154
|
+
assert ip not in self.lookups, f"Did not expect a lookup for {ip!r}"
|
|
155
|
+
|
|
156
|
+
def assert_looked_up_times(self, ip: str, times: int) -> None:
|
|
157
|
+
count = self.lookups.count(ip)
|
|
158
|
+
assert count == times, f"Expected {times} lookup(s) for {ip!r}, recorded {count}"
|
|
159
|
+
|
|
160
|
+
def assert_nothing_looked_up(self) -> None:
|
|
161
|
+
assert not self.lookups and self.origin_lookups == 0, (
|
|
162
|
+
f"Expected no lookups; recorded lookups: {self.lookups!r}, origin lookups: {self.origin_lookups}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def assert_origin_looked_up(self) -> None:
|
|
166
|
+
assert self.origin_lookups > 0, "Expected at least one origin lookup"
|
|
167
|
+
|
|
168
|
+
def assert_user_agents_parsed(self, *user_agents: str) -> None:
|
|
169
|
+
for user_agent in user_agents:
|
|
170
|
+
assert user_agent in self.parsed_user_agents, (
|
|
171
|
+
f"Expected {user_agent!r} to be parsed; recorded: {self.parsed_user_agents!r}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def _resolve_stub(self, ip: str) -> ResponseStub:
|
|
175
|
+
stub = self._responses.get(ip, self._responses.get(WILDCARD))
|
|
176
|
+
if stub is None:
|
|
177
|
+
raise self._missing(ip)
|
|
178
|
+
return stub
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _missing(key: str) -> ApiError:
|
|
182
|
+
return ApiError(
|
|
183
|
+
"FAKE_RESPONSE_MISSING",
|
|
184
|
+
f"No fake response registered for {key!r}.",
|
|
185
|
+
"Register a response with IpregistryFake({...}) or a '*' wildcard entry.",
|
|
186
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2026 Ipregistry (https://ipregistry.co).
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__version__ = "1.0.0"
|