fastapi-ipware 0.2.0__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fastapi-ipware
3
- Version: 0.2.0
3
+ Version: 1.0.0
4
4
  Summary: FastAPI/Starlette-native wrapper for python-ipware to get client IP addresses
5
5
  Keywords: fastapi,starlette,ipware,ip-address,client-ip,proxy,x-forwarded-for
6
6
  Author: Michael Bianco
@@ -45,10 +45,6 @@ uv add fastapi-ipware
45
45
 
46
46
  ## Quick Start
47
47
 
48
- `trusted` is true only when the request came through the proxies configured with `proxy_count` or `proxy_list`.
49
-
50
- `proxy_count=N` returns the address just left of the N rightmost proxies, instead of the first public address, and rejects a shorter chain. `X-Forwarded-For: 203.0.113.10, 10.0.0.1, 10.0.0.2` returns `203.0.113.10` with no `proxy_count`, and `10.0.0.1` with `proxy_count=1`.
51
-
52
48
  ### Using FastAPI Dependency Injection
53
49
 
54
50
  ```python
@@ -62,6 +58,8 @@ ipware = FastAPIIpWare()
62
58
 
63
59
  @app.get("/")
64
60
  async def get_ip(client: Annotated[ClientIpResult, Depends(ipware)]):
61
+ # trusted is true only when the request came through the proxies
62
+ # configured with proxy_count or proxy_list
65
63
  ip, trusted = client
66
64
  return {
67
65
  "ip": str(ip) if ip else None,
@@ -150,6 +148,10 @@ Validate that requests pass through the expected number of proxies:
150
148
 
151
149
  ```python
152
150
  # Expect exactly 1 proxy (e.g., AWS ALB).
151
+ # proxy_count=N returns the address just left of the N rightmost proxies,
152
+ # instead of the first public address, and rejects a shorter chain.
153
+ # X-Forwarded-For: 203.0.113.10, 10.0.0.1, 10.0.0.2 returns 203.0.113.10
154
+ # with no proxy_count, and 10.0.0.1 with proxy_count=1.
153
155
  # default_strict applies to Depends(ipware), dependency(), and IpWareMiddleware.
154
156
  ipware = FastAPIIpWare(proxy_count=1, default_strict=True)
155
157
 
@@ -27,10 +27,6 @@ uv add fastapi-ipware
27
27
 
28
28
  ## Quick Start
29
29
 
30
- `trusted` is true only when the request came through the proxies configured with `proxy_count` or `proxy_list`.
31
-
32
- `proxy_count=N` returns the address just left of the N rightmost proxies, instead of the first public address, and rejects a shorter chain. `X-Forwarded-For: 203.0.113.10, 10.0.0.1, 10.0.0.2` returns `203.0.113.10` with no `proxy_count`, and `10.0.0.1` with `proxy_count=1`.
33
-
34
30
  ### Using FastAPI Dependency Injection
35
31
 
36
32
  ```python
@@ -44,6 +40,8 @@ ipware = FastAPIIpWare()
44
40
 
45
41
  @app.get("/")
46
42
  async def get_ip(client: Annotated[ClientIpResult, Depends(ipware)]):
43
+ # trusted is true only when the request came through the proxies
44
+ # configured with proxy_count or proxy_list
47
45
  ip, trusted = client
48
46
  return {
49
47
  "ip": str(ip) if ip else None,
@@ -132,6 +130,10 @@ Validate that requests pass through the expected number of proxies:
132
130
 
133
131
  ```python
134
132
  # Expect exactly 1 proxy (e.g., AWS ALB).
133
+ # proxy_count=N returns the address just left of the N rightmost proxies,
134
+ # instead of the first public address, and rejects a shorter chain.
135
+ # X-Forwarded-For: 203.0.113.10, 10.0.0.1, 10.0.0.2 returns 203.0.113.10
136
+ # with no proxy_count, and 10.0.0.1 with proxy_count=1.
135
137
  # default_strict applies to Depends(ipware), dependency(), and IpWareMiddleware.
136
138
  ipware = FastAPIIpWare(proxy_count=1, default_strict=True)
137
139
 
@@ -13,27 +13,84 @@ IpAddressType = ipaddress.IPv4Address | ipaddress.IPv6Address
13
13
  ClientIpResult = tuple[IpAddressType | None, bool]
14
14
 
15
15
  DEFAULT_PRECEDENCE: tuple[str, ...] = (
16
- # Provider-specific headers (highest reliability)
17
- "CF-Connecting-IP", # Cloudflare
18
- "True-Client-IP", # Cloudflare Enterprise, Akamai
19
- "Fastly-Client-IP", # Fastly, Firebase
20
- "Fly-Client-IP", # Fly.io
21
- "X-Client-IP", # Microsoft Azure
22
- "X-Azure-ClientIP", # Azure Front Door
23
- "DO-Connecting-IP", # DigitalOcean App Platform
24
- "X-Cluster-Client-IP", # Rackspace Cloud Load Balancers
25
- "X-Appengine-User-IP", # Google App Engine
26
- "X-Envoy-External-Address", # Envoy / Istio
27
- # Generic headers (fallback)
28
- "X-Forwarded-For", # Generic, used by AWS ELB, nginx, etc.
29
- "X-Real-IP", # NGINX
30
- "Forwarded-For", # Plain IP list variant
31
- "Forwarded", # RFC 7239 (for=...;proto=...)
32
- "Client-IP", # Akamai, Cloudflare fallback
33
- "REMOTE_ADDR", # Direct connection fallback
16
+ # provider-specific headers (highest reliability)
17
+ # cloudflare
18
+ "CF-Connecting-IP",
19
+ # cloudflare enterprise, akamai
20
+ "True-Client-IP",
21
+ # fastly, firebase
22
+ "Fastly-Client-IP",
23
+ # fly.io
24
+ "Fly-Client-IP",
25
+ # microsoft azure
26
+ "X-Client-IP",
27
+ # azure front door
28
+ "X-Azure-ClientIP",
29
+ # digitalocean app platform
30
+ "DO-Connecting-IP",
31
+ # rackspace cloud load balancers
32
+ "X-Cluster-Client-IP",
33
+ # google app engine
34
+ "X-Appengine-User-IP",
35
+ # envoy / istio
36
+ "X-Envoy-External-Address",
37
+ # generic headers (fallback)
38
+ # generic, used by aws elb, nginx, etc
39
+ "X-Forwarded-For",
40
+ # nginx
41
+ "X-Real-IP",
42
+ # plain ip list variant
43
+ "Forwarded-For",
44
+ # rfc 7239 (for=...;proto=...)
45
+ "Forwarded",
46
+ # akamai, cloudflare fallback
47
+ "Client-IP",
48
+ # direct connection fallback
49
+ "REMOTE_ADDR",
34
50
  )
35
51
 
36
52
 
53
+ def _expand_header_variants(header: str) -> tuple[str, ...]:
54
+ "Expand a single header name into its natural and WSGI variants"
55
+ if header == "REMOTE_ADDR":
56
+ return ("REMOTE_ADDR",)
57
+
58
+ if header.startswith("HTTP_"):
59
+ return (header, header[5:].replace("_", "-"))
60
+
61
+ return (header, f"HTTP_{header.upper().replace('-', '_')}")
62
+
63
+
64
+ def _expand_header_precedence(precedence: tuple[str, ...]) -> tuple[str, ...]:
65
+ "Expand header precedence tuple to include natural and WSGI formats"
66
+ expanded: list[str] = []
67
+ for header in precedence:
68
+ for variant in _expand_header_variants(header):
69
+ if variant in expanded:
70
+ continue
71
+
72
+ expanded.append(variant)
73
+
74
+ return tuple(expanded)
75
+
76
+
77
+ def _build_request_meta(request: HTTPConnection) -> dict[str, str]:
78
+ "Build a metadata dictionary containing WSGI and natural headers from request"
79
+ # populate both wsgi-style headers and original starlette headers
80
+ meta = {
81
+ f"HTTP_{name.upper().replace('-', '_')}": value
82
+ for name, value in request.headers.items()
83
+ }
84
+ for name, value in request.headers.items():
85
+ meta[name] = value
86
+
87
+ if request.client:
88
+ # map starlette's connection info to remote_addr fallback
89
+ meta["REMOTE_ADDR"] = request.client.host
90
+
91
+ return meta
92
+
93
+
37
94
  class FastAPIIpWare(IpWare):
38
95
  """
39
96
  A FastAPI/Starlette-native wrapper around python-ipware that eliminates
@@ -84,31 +141,15 @@ class FastAPIIpWare(IpWare):
84
141
  if precedence is None:
85
142
  precedence = DEFAULT_PRECEDENCE
86
143
 
87
- # Store FastAPI-style precedence for reference
144
+ # store fastapi-style precedence for reference
88
145
  self._fastapi_precedence = precedence
89
146
  self.default_strict = default_strict
90
147
 
91
- # Expand precedence to support both natural and WSGI formats
92
- expanded_precedence: list[str] = []
93
- for header in precedence:
94
- if header == "REMOTE_ADDR":
95
- if "REMOTE_ADDR" not in expanded_precedence:
96
- expanded_precedence.append("REMOTE_ADDR")
97
- elif header.startswith("HTTP_"):
98
- if header not in expanded_precedence:
99
- expanded_precedence.append(header)
100
- non_http = header[5:].replace("_", "-")
101
- if non_http not in expanded_precedence:
102
- expanded_precedence.append(non_http)
103
- else:
104
- if header not in expanded_precedence:
105
- expanded_precedence.append(header)
106
- wsgi_form = f"HTTP_{header.upper().replace('-', '_')}"
107
- if wsgi_form not in expanded_precedence:
108
- expanded_precedence.append(wsgi_form)
148
+ # expand precedence to support both natural and wsgi formats
149
+ expanded_precedence = _expand_header_precedence(precedence)
109
150
 
110
151
  super().__init__(
111
- tuple(expanded_precedence),
152
+ expanded_precedence,
112
153
  leftmost=leftmost,
113
154
  proxy_count=proxy_count,
114
155
  proxy_list=proxy_list,
@@ -117,32 +158,32 @@ class FastAPIIpWare(IpWare):
117
158
 
118
159
  @property
119
160
  def precedence(self) -> tuple[str, ...]:
120
- """Header precedence tuple from the active engine."""
161
+ "Header precedence tuple from the active engine"
121
162
  return getattr(self.engine, "precedence", ())
122
163
 
123
164
  @property
124
165
  def leftmost(self) -> bool:
125
- """Leftmost setting from the active engine."""
166
+ "Leftmost setting from the active engine"
126
167
  return getattr(self.engine, "leftmost", True)
127
168
 
128
169
  @property
129
170
  def proxy_count(self) -> int | None:
130
- """Proxy count setting from the active engine."""
171
+ "Proxy count setting from the active engine"
131
172
  return getattr(self.engine, "proxy_count", None)
132
173
 
133
174
  @property
134
175
  def proxy_list(self) -> list[str]:
135
- """Proxy list setting from the active engine."""
176
+ "Proxy list setting from the active engine"
136
177
  return getattr(self.engine, "proxy_list", [])
137
178
 
138
179
  def __call__(self, request: HTTPConnection) -> ClientIpResult:
139
- """FastAPI route dependency injection entrypoint."""
180
+ "FastAPI route dependency injection entrypoint"
140
181
  return self.get_client_ip_from_request(request, strict=self.default_strict)
141
182
 
142
183
  def dependency(
143
184
  self, strict: bool | None = None
144
185
  ) -> Callable[[HTTPConnection], ClientIpResult]:
145
- """Return a FastAPI dependency. An omitted strict flag uses default_strict."""
186
+ "Return a FastAPI dependency. An omitted strict flag uses default_strict"
146
187
 
147
188
  def _dep(request: HTTPConnection) -> ClientIpResult:
148
189
  return self.get_client_ip_from_request(request, strict=strict)
@@ -150,14 +191,18 @@ class FastAPIIpWare(IpWare):
150
191
  return _dep
151
192
 
152
193
  def get_ip(self, request: HTTPConnection) -> IpAddressType | None:
153
- """Return only the client IP address (or None if unresolvable)."""
194
+ "Return only the client IP address (or None if unresolvable)"
154
195
  ip, _ = self.get_client_ip_from_request(request, strict=self.default_strict)
196
+
155
197
  return ip
156
198
 
157
199
  def get_ip_str(self, request: HTTPConnection) -> str | None:
158
- """Return the client IP address as a string (or None if unresolvable)."""
200
+ "Return the client IP address as a string (or None if unresolvable)"
159
201
  ip = self.get_ip(request)
160
- return str(ip) if ip is not None else None
202
+ if ip is None:
203
+ return None
204
+
205
+ return str(ip)
161
206
 
162
207
  def get_client_ip_from_request(
163
208
  self, request: HTTPConnection, strict: bool | None = None
@@ -176,22 +221,10 @@ class FastAPIIpWare(IpWare):
176
221
  - ip_address: IPv4Address or IPv6Address object (or None if not found)
177
222
  - trusted_route: True if request came through trusted proxies, False otherwise
178
223
  """
179
- if strict is None:
180
- strict = self.default_strict
224
+ effective_strict = self.default_strict if strict is None else strict
225
+ meta = _build_request_meta(request)
181
226
 
182
- # Populate both WSGI-style headers and original Starlette headers
183
- meta = {
184
- f"HTTP_{name.upper().replace('-', '_')}": value
185
- for name, value in request.headers.items()
186
- }
187
- for name, value in request.headers.items():
188
- meta[name] = value
189
-
190
- if request.client:
191
- # Map Starlette's connection info to REMOTE_ADDR fallback
192
- meta["REMOTE_ADDR"] = request.client.host
193
-
194
- return self.get_client_ip(meta, strict=strict)
227
+ return self.get_client_ip(meta, strict=effective_strict)
195
228
 
196
229
 
197
230
  class IpWareMiddleware:
@@ -216,15 +249,17 @@ class IpWareMiddleware:
216
249
  self.strict = strict
217
250
 
218
251
  async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
219
- if scope["type"] in ("http", "websocket"):
220
- conn = HTTPConnection(scope)
221
- ip, trusted = self.ipware.get_client_ip_from_request(
222
- conn, strict=self.strict
223
- )
224
- scope.setdefault("state", {})
225
- scope["state"]["client_ip"] = ip
226
- scope["state"]["ip_trusted"] = trusted
227
- scope["state"]["client_ip_str"] = str(ip) if ip is not None else None
252
+ if scope["type"] not in ("http", "websocket"):
253
+ await self.app(scope, receive, send)
254
+ return
255
+
256
+ conn = HTTPConnection(scope)
257
+ ip, trusted = self.ipware.get_client_ip_from_request(conn, strict=self.strict)
258
+
259
+ scope.setdefault("state", {})
260
+ scope["state"]["client_ip"] = ip
261
+ scope["state"]["ip_trusted"] = trusted
262
+ scope["state"]["client_ip_str"] = str(ip) if ip is not None else None
228
263
 
229
264
  await self.app(scope, receive, send)
230
265
 
@@ -1,26 +1,27 @@
1
- """Version handling for fastapi-ipware."""
1
+ "Version handling for fastapi-ipware"
2
2
 
3
3
  import importlib.metadata
4
4
  from pathlib import Path
5
5
 
6
6
 
7
7
  def is_local_source_checkout() -> bool:
8
- """Check if the code is running from a local source checkout."""
8
+ "Check if the code is running from a local source checkout"
9
9
  package_dir = Path(__file__).resolve().parent
10
- # Since this is a flat layout (module-root = ""), repo root is the parent of the package dir
10
+
11
+ # since this is a flat layout (module-root = ""), repo root is the parent of the package dir
11
12
  repo_root = package_dir.parent
12
13
 
13
14
  return (repo_root / ".git").exists() and (repo_root / "pyproject.toml").exists()
14
15
 
15
16
 
16
17
  def get_version() -> str:
17
- """Get the version string, appending .dev if running from source."""
18
+ "Get the version string, appending .dev if running from source"
18
19
  try:
19
- # Try to get the version of the installed package
20
+ # try to get the version of the installed package
20
21
  version = importlib.metadata.version("fastapi-ipware")
21
22
  except importlib.metadata.PackageNotFoundError:
22
- # Fallback for local development if not installed
23
- version = "0.1.0"
23
+ # fallback for local development if not installed
24
+ version = "1.0.0"
24
25
 
25
26
  if not is_local_source_checkout():
26
27
  return version
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-ipware"
3
- version = "0.2.0"
3
+ version = "1.0.0"
4
4
  description = "FastAPI/Starlette-native wrapper for python-ipware to get client IP addresses"
5
5
  keywords = [
6
6
  "fastapi",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-ipware"
3
- version = "0.2.0"
3
+ version = "1.0.0"
4
4
  description = "FastAPI/Starlette-native wrapper for python-ipware to get client IP addresses"
5
5
  keywords = [
6
6
  "fastapi",