xtr-http-foundation 4.0.2__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.
- xtr_http_foundation-4.0.2/LICENSE +22 -0
- xtr_http_foundation-4.0.2/PKG-INFO +239 -0
- xtr_http_foundation-4.0.2/README.md +216 -0
- xtr_http_foundation-4.0.2/pyproject.toml +152 -0
- xtr_http_foundation-4.0.2/pyproject.toml.orig +155 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/.agents/skills/xtr-http-foundation/SKILL.md +204 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/__init__.py +44 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/chain_request_matcher.py +55 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/exception/__init__.py +12 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/exception/http_foundation_error.py +9 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/exception/invalid_argument_error.py +18 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/ip_utils.py +245 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/py.typed +0 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/__init__.py +27 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/_comma_separated.py +32 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/_compiled_regexp.py +37 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/attributes_request_matcher.py +62 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/header_request_matcher.py +50 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/host_request_matcher.py +49 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/ips_request_matcher.py +72 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/is_json_request_matcher.py +53 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/method_request_matcher.py +51 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/path_request_matcher.py +75 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/port_request_matcher.py +57 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/query_parameter_request_matcher.py +59 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher/scheme_request_matcher.py +52 -0
- xtr_http_foundation-4.0.2/src/xtr_http_foundation/request_matcher_interface.py +36 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
Copyright (c) 2004-present Fabien Potencier
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-http-foundation
|
|
3
|
+
Version: 4.0.2
|
|
4
|
+
Summary: Matchers that answer one question each about a request that arrived — method, path, host, port, scheme, headers, query, client address, JSON body — and the helpers that place an IP in a network.
|
|
5
|
+
Keywords: http,request-matcher,ip,subnet,asyncio
|
|
6
|
+
Author: Xterr
|
|
7
|
+
Author-email: Xterr <me@xterr.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: typing-extensions>=4.4
|
|
20
|
+
Requires-Dist: xtr-http-message-contracts>=4.0,<5
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
<div align="center">
|
|
25
|
+
|
|
26
|
+
# xtr-http-foundation
|
|
27
|
+
|
|
28
|
+
**One question each about a request that arrived, composed into one matcher — and the helpers that place an IP in a network.**
|
|
29
|
+
|
|
30
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
31
|
+
<img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
|
|
32
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
33
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
34
|
+
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Why?
|
|
40
|
+
|
|
41
|
+
"Is this request one of mine?" is asked by a firewall, a webhook endpoint, a rate limiter and a
|
|
42
|
+
router, and each one answers it with its own tangle of `if`s over the method, the path and a
|
|
43
|
+
header or two. Written that way the answer is untestable on its own and subtly different
|
|
44
|
+
everywhere.
|
|
45
|
+
|
|
46
|
+
A matcher answers one such question, and a chain of them answers the whole of it. Alongside
|
|
47
|
+
them live the address helpers the questions about *who* sent a request are built on: whether an
|
|
48
|
+
IP falls in a network, and whether it is one only this network can reach.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
uv add xtr-http-foundation
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires Python 3.11+. The one runtime dependency beyond `typing-extensions` is
|
|
57
|
+
[xtr-http-message-contracts](../xtr-http-message-contracts), because a matcher reads a request
|
|
58
|
+
that arrived and nothing else; the address helpers are built on the standard library's
|
|
59
|
+
`ipaddress`.
|
|
60
|
+
|
|
61
|
+
## Quick start
|
|
62
|
+
|
|
63
|
+
A matcher answers one question, and `ChainRequestMatcher` answers the whole of it:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from xtr_http_foundation import (
|
|
67
|
+
ChainRequestMatcher,
|
|
68
|
+
IsJsonRequestMatcher,
|
|
69
|
+
MethodRequestMatcher,
|
|
70
|
+
PathRequestMatcher,
|
|
71
|
+
)
|
|
72
|
+
from xtr_http_message import ServerRequest
|
|
73
|
+
|
|
74
|
+
ours = ChainRequestMatcher(
|
|
75
|
+
[MethodRequestMatcher("POST"), PathRequestMatcher("^/webhook/"), IsJsonRequestMatcher()]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
arrived = ServerRequest("POST", "/webhook/acme", {"Content-Type": "text/plain"}, b'{"id": 1}')
|
|
79
|
+
|
|
80
|
+
assert await ours.matches(arrived)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`matches()` is awaited on every matcher, including those that only read the head: a rule may be
|
|
84
|
+
about the body, and one matcher has to be swappable for another. The chain answers on the first
|
|
85
|
+
refusal, so a matcher that reads the body is never asked about a request an earlier one already
|
|
86
|
+
turned away — and a chain of nothing claims every request, because nothing constrains it.
|
|
87
|
+
|
|
88
|
+
## The matchers
|
|
89
|
+
|
|
90
|
+
| Matcher | Takes | Claims a request |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `MethodRequestMatcher(methods)` | one method or several | whose method is one of them. The names are read in upper case; the request's own is compared as it arrived |
|
|
93
|
+
| `PathRequestMatcher(regexp)` | one expression | whose path matches it. Searched for rather than describing the whole path, so `/admin` claims anything under it and `^/admin/[0-9]+$` one shape of it |
|
|
94
|
+
| `HostRequestMatcher(regexp)` | one expression | whose host matches it, without regard to case: `\.example\.com$` is a subdomain rule, `^admin\.example\.com$` one host |
|
|
95
|
+
| `PortRequestMatcher(port)` | one port | asked of that port, the scheme's standard one counting where the URI is silent |
|
|
96
|
+
| `SchemeRequestMatcher(schemes)` | one scheme or several | made over one of them — `SchemeRequestMatcher("https")` is how a credential is kept off the clear |
|
|
97
|
+
| `HeaderRequestMatcher(headers)` | header names | carrying every one of them, whatever each holds. Names match the way HTTP matches them |
|
|
98
|
+
| `QueryParameterRequestMatcher(parameters)` | parameter names | whose query *opens* with them, in that order; further parameters after them are fine |
|
|
99
|
+
| `IpsRequestMatcher(ips)` | addresses or CIDR networks | that came from one of them |
|
|
100
|
+
| `AttributesRequestMatcher(regexps)` | each attribute name mapped to an expression | whose every named attribute matches its own |
|
|
101
|
+
| `IsJsonRequestMatcher()` | nothing | whose body reads as JSON |
|
|
102
|
+
| `ChainRequestMatcher(matchers)` | matchers | every one of them claims |
|
|
103
|
+
|
|
104
|
+
Wherever a matcher takes several names, one string is as good as a sequence and any of them may
|
|
105
|
+
itself hold a comma-separated list — `MethodRequestMatcher("POST,PUT")` is
|
|
106
|
+
`MethodRequestMatcher(["POST", "PUT"])`. A matcher naming nothing at all claims every request:
|
|
107
|
+
it constrains nothing.
|
|
108
|
+
|
|
109
|
+
Expressions are read when the matcher is built, so one that does not read as an expression is
|
|
110
|
+
`InvalidArgumentError` there rather than on the first request. They are *searched for* in the
|
|
111
|
+
value, never anchored for you.
|
|
112
|
+
|
|
113
|
+
Three of them are worth a sentence of their own.
|
|
114
|
+
|
|
115
|
+
- **`IsJsonRequestMatcher` reads the body, not the `Content-Type` header.** A sender that
|
|
116
|
+
announces nothing still sent JSON, and one that announces JSON and sent something else did
|
|
117
|
+
not. Any JSON value counts — an object, an array, a number, a string — so this is the rule for
|
|
118
|
+
"a body worth decoding" rather than for a shape. The body is read from its first byte, a
|
|
119
|
+
stream another matcher already read being rewound first, so the order of a chain changes
|
|
120
|
+
nothing; a body nobody can read is simply not claimed.
|
|
121
|
+
- **`IpsRequestMatcher` reads the address the server accepted the connection from**, reported
|
|
122
|
+
among the request's server parameters under `client`. A request that travelled through a proxy
|
|
123
|
+
therefore comes from the proxy: a rule about the original caller belongs where the forwarded
|
|
124
|
+
headers are trusted and resolved.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from xtr_http_foundation import IpsRequestMatcher
|
|
128
|
+
from xtr_http_message import ServerRequest
|
|
129
|
+
|
|
130
|
+
internal = IpsRequestMatcher("10.0.0.0/8,192.168.1.7")
|
|
131
|
+
arrived = ServerRequest("GET", "/metrics", server_params={"client": ("10.1.2.3", 51234)})
|
|
132
|
+
|
|
133
|
+
assert await internal.matches(arrived)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- **`PathRequestMatcher` matches the path the application itself sees**, free of per cent
|
|
137
|
+
escapes: an application mounted under a path is reached at that path plus its own, the mount
|
|
138
|
+
is reported as the `root_path` server parameter, and a rule is written about the application's
|
|
139
|
+
own path — so moving the mount rewrites no matcher. `/admin/fo o` therefore matches
|
|
140
|
+
`/admin/fo%20o`, and nothing in the expression is a path parameter: `^/{_locale}/login$`
|
|
141
|
+
matches a path holding those very braces.
|
|
142
|
+
|
|
143
|
+
`AttributesRequestMatcher` is the one that asks about a finding rather than about the request as
|
|
144
|
+
it arrived — the named parameters a path matched, the user an authorization header resolved to:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from xtr_http_foundation import AttributesRequestMatcher
|
|
148
|
+
from xtr_http_message import ServerRequest
|
|
149
|
+
|
|
150
|
+
owners = AttributesRequestMatcher({"role": "^owner$"})
|
|
151
|
+
arrived = ServerRequest("GET", "/orders").with_attribute("role", "owner")
|
|
152
|
+
|
|
153
|
+
assert await owners.matches(arrived)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
An attribute the request does not carry, or carries as something that is not text, is never
|
|
157
|
+
claimed: there is nothing to match an expression against.
|
|
158
|
+
|
|
159
|
+
## Writing one
|
|
160
|
+
|
|
161
|
+
`RequestMatcherInterface` is a `@runtime_checkable` `Protocol` with one member, so a rule of
|
|
162
|
+
your own needs no base class:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from typing import final
|
|
166
|
+
|
|
167
|
+
from typing_extensions import override
|
|
168
|
+
from xtr_http_foundation import RequestMatcherInterface
|
|
169
|
+
from xtr_http_message_contracts import ServerRequestInterface
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@final
|
|
173
|
+
class SignedRequestMatcher(RequestMatcherInterface):
|
|
174
|
+
__slots__ = ()
|
|
175
|
+
|
|
176
|
+
@override
|
|
177
|
+
async def matches(self, request: ServerRequestInterface) -> bool:
|
|
178
|
+
return request.has_header("Webhook-Signature")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Addresses
|
|
182
|
+
|
|
183
|
+
`IpUtils` is a namespace rather than something to build: every answer is a function of its
|
|
184
|
+
arguments.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from xtr_http_foundation import IpUtils
|
|
188
|
+
|
|
189
|
+
IpUtils.check_ip("10.1.2.3", ["192.168.0.0/16", "10.0.0.0/8"]) # True
|
|
190
|
+
IpUtils.check_ip4("10.1.2.3", "10.0.0.0/8") # True
|
|
191
|
+
IpUtils.check_ip6("::1", "::1/128") # True
|
|
192
|
+
IpUtils.is_private_ip("10.1.2.3") # True
|
|
193
|
+
IpUtils.anonymize("192.168.1.42") # "192.168.1.0"
|
|
194
|
+
IpUtils.anonymize("2001:db8::1") # "2001:db8::"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- `check_ip(request_ip, ips)` takes a single address, a network in CIDR notation, or several of
|
|
198
|
+
either, and picks the IPv4 or the IPv6 check by how the address is written.
|
|
199
|
+
- **Text that is no address is no error for the checks**: they answer about a request from a
|
|
200
|
+
stranger, whose address is theirs to misspell, and an address nobody can read is one no
|
|
201
|
+
network holds. A network written wrongly is read as no network at all rather than as a wider
|
|
202
|
+
one: `" 8"`, `"+8"` and `"8.5"` name no prefix length, and an IPv6 zone — the `%eth0` of
|
|
203
|
+
`fe80::1%eth0`, which names an interface of whichever host reads it — is refused rather than
|
|
204
|
+
quietly dropped.
|
|
205
|
+
- `is_private_ip(ip)` is the one that *refuses* unreadable text, with `InvalidArgumentError`:
|
|
206
|
+
its caller is asking whether an address may be reached out to, and text nobody can read is not
|
|
207
|
+
an address that failed the question. It places the address in `IpUtils.PRIVATE_SUBNETS` —
|
|
208
|
+
loopback, private use, link-local, carrier-grade NAT, unique-local, multicast, reserved,
|
|
209
|
+
unspecified, the documentation and benchmarking ranges, and the forms an IPv6 address writes
|
|
210
|
+
an IPv4 one in. That tuple is what
|
|
211
|
+
[xtr-http-client](../xtr-http-client)'s `NoPrivateNetworkHttpClient` guards by default.
|
|
212
|
+
- `anonymize(ip, v4_bytes=1, v6_bytes=8)` zeroes the last bytes, so what is left no longer names
|
|
213
|
+
one machine — for a log or a counter that must not keep an address. It gives the address back
|
|
214
|
+
as it arrived, bracketed if it was and dotted where it carries an IPv4 one, and never gives a
|
|
215
|
+
zone back: that names an interface, which is as much a trace of who was asked as the bytes
|
|
216
|
+
just dropped. Asking for fewer than no bytes, or for more than the address has, is
|
|
217
|
+
`InvalidArgumentError`.
|
|
218
|
+
|
|
219
|
+
## Errors
|
|
220
|
+
|
|
221
|
+
Everything derives from `HttpFoundationError`. There is one: `InvalidArgumentError`, also a
|
|
222
|
+
`ValueError`, raised for an expression that does not read as one, and by `is_private_ip` and
|
|
223
|
+
`anonymize` for text that is no address.
|
|
224
|
+
|
|
225
|
+
## Development
|
|
226
|
+
|
|
227
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
228
|
+
`packages/xtr-http-foundation`; run the commands below from there. The
|
|
229
|
+
`python-xtr-http-foundation` repository is a read-only copy, so send issues and pull requests
|
|
230
|
+
to the monorepo.
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
uv sync --all-extras
|
|
234
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# xtr-http-foundation
|
|
4
|
+
|
|
5
|
+
**One question each about a request that arrived, composed into one matcher — and the helpers that place an IP in a network.**
|
|
6
|
+
|
|
7
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
8
|
+
<img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
|
|
9
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
10
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Why?
|
|
17
|
+
|
|
18
|
+
"Is this request one of mine?" is asked by a firewall, a webhook endpoint, a rate limiter and a
|
|
19
|
+
router, and each one answers it with its own tangle of `if`s over the method, the path and a
|
|
20
|
+
header or two. Written that way the answer is untestable on its own and subtly different
|
|
21
|
+
everywhere.
|
|
22
|
+
|
|
23
|
+
A matcher answers one such question, and a chain of them answers the whole of it. Alongside
|
|
24
|
+
them live the address helpers the questions about *who* sent a request are built on: whether an
|
|
25
|
+
IP falls in a network, and whether it is one only this network can reach.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
uv add xtr-http-foundation
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Requires Python 3.11+. The one runtime dependency beyond `typing-extensions` is
|
|
34
|
+
[xtr-http-message-contracts](../xtr-http-message-contracts), because a matcher reads a request
|
|
35
|
+
that arrived and nothing else; the address helpers are built on the standard library's
|
|
36
|
+
`ipaddress`.
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
A matcher answers one question, and `ChainRequestMatcher` answers the whole of it:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from xtr_http_foundation import (
|
|
44
|
+
ChainRequestMatcher,
|
|
45
|
+
IsJsonRequestMatcher,
|
|
46
|
+
MethodRequestMatcher,
|
|
47
|
+
PathRequestMatcher,
|
|
48
|
+
)
|
|
49
|
+
from xtr_http_message import ServerRequest
|
|
50
|
+
|
|
51
|
+
ours = ChainRequestMatcher(
|
|
52
|
+
[MethodRequestMatcher("POST"), PathRequestMatcher("^/webhook/"), IsJsonRequestMatcher()]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
arrived = ServerRequest("POST", "/webhook/acme", {"Content-Type": "text/plain"}, b'{"id": 1}')
|
|
56
|
+
|
|
57
|
+
assert await ours.matches(arrived)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`matches()` is awaited on every matcher, including those that only read the head: a rule may be
|
|
61
|
+
about the body, and one matcher has to be swappable for another. The chain answers on the first
|
|
62
|
+
refusal, so a matcher that reads the body is never asked about a request an earlier one already
|
|
63
|
+
turned away — and a chain of nothing claims every request, because nothing constrains it.
|
|
64
|
+
|
|
65
|
+
## The matchers
|
|
66
|
+
|
|
67
|
+
| Matcher | Takes | Claims a request |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `MethodRequestMatcher(methods)` | one method or several | whose method is one of them. The names are read in upper case; the request's own is compared as it arrived |
|
|
70
|
+
| `PathRequestMatcher(regexp)` | one expression | whose path matches it. Searched for rather than describing the whole path, so `/admin` claims anything under it and `^/admin/[0-9]+$` one shape of it |
|
|
71
|
+
| `HostRequestMatcher(regexp)` | one expression | whose host matches it, without regard to case: `\.example\.com$` is a subdomain rule, `^admin\.example\.com$` one host |
|
|
72
|
+
| `PortRequestMatcher(port)` | one port | asked of that port, the scheme's standard one counting where the URI is silent |
|
|
73
|
+
| `SchemeRequestMatcher(schemes)` | one scheme or several | made over one of them — `SchemeRequestMatcher("https")` is how a credential is kept off the clear |
|
|
74
|
+
| `HeaderRequestMatcher(headers)` | header names | carrying every one of them, whatever each holds. Names match the way HTTP matches them |
|
|
75
|
+
| `QueryParameterRequestMatcher(parameters)` | parameter names | whose query *opens* with them, in that order; further parameters after them are fine |
|
|
76
|
+
| `IpsRequestMatcher(ips)` | addresses or CIDR networks | that came from one of them |
|
|
77
|
+
| `AttributesRequestMatcher(regexps)` | each attribute name mapped to an expression | whose every named attribute matches its own |
|
|
78
|
+
| `IsJsonRequestMatcher()` | nothing | whose body reads as JSON |
|
|
79
|
+
| `ChainRequestMatcher(matchers)` | matchers | every one of them claims |
|
|
80
|
+
|
|
81
|
+
Wherever a matcher takes several names, one string is as good as a sequence and any of them may
|
|
82
|
+
itself hold a comma-separated list — `MethodRequestMatcher("POST,PUT")` is
|
|
83
|
+
`MethodRequestMatcher(["POST", "PUT"])`. A matcher naming nothing at all claims every request:
|
|
84
|
+
it constrains nothing.
|
|
85
|
+
|
|
86
|
+
Expressions are read when the matcher is built, so one that does not read as an expression is
|
|
87
|
+
`InvalidArgumentError` there rather than on the first request. They are *searched for* in the
|
|
88
|
+
value, never anchored for you.
|
|
89
|
+
|
|
90
|
+
Three of them are worth a sentence of their own.
|
|
91
|
+
|
|
92
|
+
- **`IsJsonRequestMatcher` reads the body, not the `Content-Type` header.** A sender that
|
|
93
|
+
announces nothing still sent JSON, and one that announces JSON and sent something else did
|
|
94
|
+
not. Any JSON value counts — an object, an array, a number, a string — so this is the rule for
|
|
95
|
+
"a body worth decoding" rather than for a shape. The body is read from its first byte, a
|
|
96
|
+
stream another matcher already read being rewound first, so the order of a chain changes
|
|
97
|
+
nothing; a body nobody can read is simply not claimed.
|
|
98
|
+
- **`IpsRequestMatcher` reads the address the server accepted the connection from**, reported
|
|
99
|
+
among the request's server parameters under `client`. A request that travelled through a proxy
|
|
100
|
+
therefore comes from the proxy: a rule about the original caller belongs where the forwarded
|
|
101
|
+
headers are trusted and resolved.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from xtr_http_foundation import IpsRequestMatcher
|
|
105
|
+
from xtr_http_message import ServerRequest
|
|
106
|
+
|
|
107
|
+
internal = IpsRequestMatcher("10.0.0.0/8,192.168.1.7")
|
|
108
|
+
arrived = ServerRequest("GET", "/metrics", server_params={"client": ("10.1.2.3", 51234)})
|
|
109
|
+
|
|
110
|
+
assert await internal.matches(arrived)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- **`PathRequestMatcher` matches the path the application itself sees**, free of per cent
|
|
114
|
+
escapes: an application mounted under a path is reached at that path plus its own, the mount
|
|
115
|
+
is reported as the `root_path` server parameter, and a rule is written about the application's
|
|
116
|
+
own path — so moving the mount rewrites no matcher. `/admin/fo o` therefore matches
|
|
117
|
+
`/admin/fo%20o`, and nothing in the expression is a path parameter: `^/{_locale}/login$`
|
|
118
|
+
matches a path holding those very braces.
|
|
119
|
+
|
|
120
|
+
`AttributesRequestMatcher` is the one that asks about a finding rather than about the request as
|
|
121
|
+
it arrived — the named parameters a path matched, the user an authorization header resolved to:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from xtr_http_foundation import AttributesRequestMatcher
|
|
125
|
+
from xtr_http_message import ServerRequest
|
|
126
|
+
|
|
127
|
+
owners = AttributesRequestMatcher({"role": "^owner$"})
|
|
128
|
+
arrived = ServerRequest("GET", "/orders").with_attribute("role", "owner")
|
|
129
|
+
|
|
130
|
+
assert await owners.matches(arrived)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
An attribute the request does not carry, or carries as something that is not text, is never
|
|
134
|
+
claimed: there is nothing to match an expression against.
|
|
135
|
+
|
|
136
|
+
## Writing one
|
|
137
|
+
|
|
138
|
+
`RequestMatcherInterface` is a `@runtime_checkable` `Protocol` with one member, so a rule of
|
|
139
|
+
your own needs no base class:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from typing import final
|
|
143
|
+
|
|
144
|
+
from typing_extensions import override
|
|
145
|
+
from xtr_http_foundation import RequestMatcherInterface
|
|
146
|
+
from xtr_http_message_contracts import ServerRequestInterface
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@final
|
|
150
|
+
class SignedRequestMatcher(RequestMatcherInterface):
|
|
151
|
+
__slots__ = ()
|
|
152
|
+
|
|
153
|
+
@override
|
|
154
|
+
async def matches(self, request: ServerRequestInterface) -> bool:
|
|
155
|
+
return request.has_header("Webhook-Signature")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Addresses
|
|
159
|
+
|
|
160
|
+
`IpUtils` is a namespace rather than something to build: every answer is a function of its
|
|
161
|
+
arguments.
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from xtr_http_foundation import IpUtils
|
|
165
|
+
|
|
166
|
+
IpUtils.check_ip("10.1.2.3", ["192.168.0.0/16", "10.0.0.0/8"]) # True
|
|
167
|
+
IpUtils.check_ip4("10.1.2.3", "10.0.0.0/8") # True
|
|
168
|
+
IpUtils.check_ip6("::1", "::1/128") # True
|
|
169
|
+
IpUtils.is_private_ip("10.1.2.3") # True
|
|
170
|
+
IpUtils.anonymize("192.168.1.42") # "192.168.1.0"
|
|
171
|
+
IpUtils.anonymize("2001:db8::1") # "2001:db8::"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- `check_ip(request_ip, ips)` takes a single address, a network in CIDR notation, or several of
|
|
175
|
+
either, and picks the IPv4 or the IPv6 check by how the address is written.
|
|
176
|
+
- **Text that is no address is no error for the checks**: they answer about a request from a
|
|
177
|
+
stranger, whose address is theirs to misspell, and an address nobody can read is one no
|
|
178
|
+
network holds. A network written wrongly is read as no network at all rather than as a wider
|
|
179
|
+
one: `" 8"`, `"+8"` and `"8.5"` name no prefix length, and an IPv6 zone — the `%eth0` of
|
|
180
|
+
`fe80::1%eth0`, which names an interface of whichever host reads it — is refused rather than
|
|
181
|
+
quietly dropped.
|
|
182
|
+
- `is_private_ip(ip)` is the one that *refuses* unreadable text, with `InvalidArgumentError`:
|
|
183
|
+
its caller is asking whether an address may be reached out to, and text nobody can read is not
|
|
184
|
+
an address that failed the question. It places the address in `IpUtils.PRIVATE_SUBNETS` —
|
|
185
|
+
loopback, private use, link-local, carrier-grade NAT, unique-local, multicast, reserved,
|
|
186
|
+
unspecified, the documentation and benchmarking ranges, and the forms an IPv6 address writes
|
|
187
|
+
an IPv4 one in. That tuple is what
|
|
188
|
+
[xtr-http-client](../xtr-http-client)'s `NoPrivateNetworkHttpClient` guards by default.
|
|
189
|
+
- `anonymize(ip, v4_bytes=1, v6_bytes=8)` zeroes the last bytes, so what is left no longer names
|
|
190
|
+
one machine — for a log or a counter that must not keep an address. It gives the address back
|
|
191
|
+
as it arrived, bracketed if it was and dotted where it carries an IPv4 one, and never gives a
|
|
192
|
+
zone back: that names an interface, which is as much a trace of who was asked as the bytes
|
|
193
|
+
just dropped. Asking for fewer than no bytes, or for more than the address has, is
|
|
194
|
+
`InvalidArgumentError`.
|
|
195
|
+
|
|
196
|
+
## Errors
|
|
197
|
+
|
|
198
|
+
Everything derives from `HttpFoundationError`. There is one: `InvalidArgumentError`, also a
|
|
199
|
+
`ValueError`, raised for an expression that does not read as one, and by `is_private_ip` and
|
|
200
|
+
`anonymize` for text that is no address.
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
205
|
+
`packages/xtr-http-foundation`; run the commands below from there. The
|
|
206
|
+
`python-xtr-http-foundation` repository is a read-only copy, so send issues and pull requests
|
|
207
|
+
to the monorepo.
|
|
208
|
+
|
|
209
|
+
```sh
|
|
210
|
+
uv sync --all-extras
|
|
211
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "xtr-http-foundation"
|
|
3
|
+
version = "4.0.2"
|
|
4
|
+
description = "Matchers that answer one question each about a request that arrived — method, path, host, port, scheme, headers, query, client address, JSON body — and the helpers that place an IP in a network."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"http",
|
|
11
|
+
"request-matcher",
|
|
12
|
+
"ip",
|
|
13
|
+
"subnet",
|
|
14
|
+
"asyncio",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Framework :: AsyncIO",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"typing-extensions>=4.4",
|
|
29
|
+
"xtr-http-message-contracts>=4.0,<5",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[[project.authors]]
|
|
33
|
+
name = "Xterr"
|
|
34
|
+
email = "me@xterr.dev"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = [
|
|
38
|
+
"basedpyright>=1.21",
|
|
39
|
+
"ruff>=0.8",
|
|
40
|
+
"pytest>=8",
|
|
41
|
+
"pytest-cov>=5",
|
|
42
|
+
"anyio>=4.0",
|
|
43
|
+
"ty>=0.0.83",
|
|
44
|
+
"xtr-http-message>=4.0,<5",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
49
|
+
build-backend = "uv_build"
|
|
50
|
+
|
|
51
|
+
[tool.basedpyright]
|
|
52
|
+
typeCheckingMode = "all"
|
|
53
|
+
pythonVersion = "3.11"
|
|
54
|
+
pythonPlatform = "All"
|
|
55
|
+
include = [
|
|
56
|
+
"src",
|
|
57
|
+
"tests",
|
|
58
|
+
]
|
|
59
|
+
exclude = [
|
|
60
|
+
"**/__pycache__",
|
|
61
|
+
"**/.venv",
|
|
62
|
+
"**/build",
|
|
63
|
+
"**/dist",
|
|
64
|
+
".tmp",
|
|
65
|
+
]
|
|
66
|
+
reportUnusedCallResult = "warning"
|
|
67
|
+
reportUnnecessaryTypeIgnoreComment = "error"
|
|
68
|
+
reportUnusedVariable = "error"
|
|
69
|
+
reportMissingParameterType = "error"
|
|
70
|
+
reportPrivateUsage = "error"
|
|
71
|
+
reportImplicitStringConcatenation = "none"
|
|
72
|
+
|
|
73
|
+
[[tool.basedpyright.executionEnvironments]]
|
|
74
|
+
root = "tests"
|
|
75
|
+
extraPaths = ["."]
|
|
76
|
+
reportPrivateUsage = "none"
|
|
77
|
+
|
|
78
|
+
[tool.ruff]
|
|
79
|
+
target-version = "py311"
|
|
80
|
+
line-length = 100
|
|
81
|
+
src = [
|
|
82
|
+
"src",
|
|
83
|
+
"tests",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
[tool.ruff.lint]
|
|
87
|
+
select = ["ALL"]
|
|
88
|
+
ignore = [
|
|
89
|
+
"COM812",
|
|
90
|
+
"ISC001",
|
|
91
|
+
"D203",
|
|
92
|
+
"D213",
|
|
93
|
+
"CPY001",
|
|
94
|
+
"FBT001",
|
|
95
|
+
"FBT002",
|
|
96
|
+
"TD002",
|
|
97
|
+
"TD003",
|
|
98
|
+
"FIX002",
|
|
99
|
+
"TRY003",
|
|
100
|
+
"EM101",
|
|
101
|
+
"EM102",
|
|
102
|
+
]
|
|
103
|
+
fixable = ["ALL"]
|
|
104
|
+
unfixable = []
|
|
105
|
+
|
|
106
|
+
[tool.ruff.lint.per-file-ignores]
|
|
107
|
+
"tests/**/*.py" = [
|
|
108
|
+
"S101",
|
|
109
|
+
"ARG",
|
|
110
|
+
"PLR2004",
|
|
111
|
+
"SLF001",
|
|
112
|
+
"D",
|
|
113
|
+
"S104",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
[tool.ruff.lint.pydocstyle]
|
|
117
|
+
convention = "google"
|
|
118
|
+
|
|
119
|
+
[tool.ruff.format]
|
|
120
|
+
quote-style = "double"
|
|
121
|
+
indent-style = "space"
|
|
122
|
+
docstring-code-format = true
|
|
123
|
+
docstring-code-line-length = "dynamic"
|
|
124
|
+
|
|
125
|
+
[tool.ty.src]
|
|
126
|
+
include = [
|
|
127
|
+
"src",
|
|
128
|
+
"tests",
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
[tool.pytest.ini_options]
|
|
132
|
+
minversion = "8.0"
|
|
133
|
+
testpaths = ["tests"]
|
|
134
|
+
addopts = [
|
|
135
|
+
"-ra",
|
|
136
|
+
"--strict-config",
|
|
137
|
+
"--strict-markers",
|
|
138
|
+
]
|
|
139
|
+
filterwarnings = ["error"]
|
|
140
|
+
|
|
141
|
+
[tool.coverage.run]
|
|
142
|
+
source = ["src"]
|
|
143
|
+
branch = true
|
|
144
|
+
|
|
145
|
+
[tool.coverage.report]
|
|
146
|
+
fail_under = 95
|
|
147
|
+
exclude_lines = [
|
|
148
|
+
"pragma: no cover",
|
|
149
|
+
"if TYPE_CHECKING:",
|
|
150
|
+
"raise NotImplementedError",
|
|
151
|
+
'^\s*\.\.\.$',
|
|
152
|
+
]
|