xtr-http-foundation
One question each about a request that arrived, composed into one matcher — and the helpers that place an IP in a network.
Why?
"Is this request one of mine?" is asked by a firewall, a webhook endpoint, a rate limiter and a
router, and each one answers it with its own tangle of ifs over the method, the path and a
header or two. Written that way the answer is untestable on its own and subtly different
everywhere.
A matcher answers one such question, and a chain of them answers the whole of it. Alongside them live the address helpers the questions about who sent a request are built on: whether an IP falls in a network, and whether it is one only this network can reach.
Install
uv add xtr-http-foundation
Requires Python 3.11+. The one runtime dependency beyond typing-extensions is
xtr-http-message-contracts, because a matcher reads a request
that arrived and nothing else; the address helpers are built on the standard library's
ipaddress.
Quick start
A matcher answers one question, and ChainRequestMatcher answers the whole of it:
from xtr_http_foundation import (
ChainRequestMatcher,
IsJsonRequestMatcher,
MethodRequestMatcher,
PathRequestMatcher,
)
from xtr_http_message import ServerRequest
ours = ChainRequestMatcher(
[MethodRequestMatcher("POST"), PathRequestMatcher("^/webhook/"), IsJsonRequestMatcher()]
)
arrived = ServerRequest("POST", "/webhook/acme", {"Content-Type": "text/plain"}, b'{"id": 1}')
assert await ours.matches(arrived)
matches() is awaited on every matcher, including those that only read the head: a rule may be
about the body, and one matcher has to be swappable for another. The chain answers on the first
refusal, so a matcher that reads the body is never asked about a request an earlier one already
turned away — and a chain of nothing claims every request, because nothing constrains it.
The matchers
| Matcher | Takes | Claims a request |
|---|---|---|
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 |
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 |
HostRequestMatcher(regexp) |
one expression | whose host matches it, without regard to case: \.example\.com$ is a subdomain rule, ^admin\.example\.com$ one host |
PortRequestMatcher(port) |
one port | asked of that port, the scheme's standard one counting where the URI is silent |
SchemeRequestMatcher(schemes) |
one scheme or several | made over one of them — SchemeRequestMatcher("https") is how a credential is kept off the clear |
HeaderRequestMatcher(headers) |
header names | carrying every one of them, whatever each holds. Names match the way HTTP matches them |
QueryParameterRequestMatcher(parameters) |
parameter names | whose query opens with them, in that order; further parameters after them are fine |
IpsRequestMatcher(ips) |
addresses or CIDR networks | that came from one of them |
AttributesRequestMatcher(regexps) |
each attribute name mapped to an expression | whose every named attribute matches its own |
IsJsonRequestMatcher() |
nothing | whose body reads as JSON |
ChainRequestMatcher(matchers) |
matchers | every one of them claims |
Wherever a matcher takes several names, one string is as good as a sequence and any of them may
itself hold a comma-separated list — MethodRequestMatcher("POST,PUT") is
MethodRequestMatcher(["POST", "PUT"]). A matcher naming nothing at all claims every request:
it constrains nothing.
Expressions are read when the matcher is built, so one that does not read as an expression is
InvalidArgumentError there rather than on the first request. They are searched for in the
value, never anchored for you.
Three of them are worth a sentence of their own.
-
IsJsonRequestMatcherreads the body, not theContent-Typeheader. A sender that announces nothing still sent JSON, and one that announces JSON and sent something else did not. Any JSON value counts — an object, an array, a number, a string — so this is the rule for "a body worth decoding" rather than for a shape. The body is read from its first byte, a stream another matcher already read being rewound first, so the order of a chain changes nothing; a body nobody can read is simply not claimed. -
IpsRequestMatcherreads the address the server accepted the connection from, reported among the request's server parameters underclient. A request that travelled through a proxy therefore comes from the proxy: a rule about the original caller belongs where the forwarded headers are trusted and resolved.from xtr_http_foundation import IpsRequestMatcher from xtr_http_message import ServerRequest internal = IpsRequestMatcher("10.0.0.0/8,192.168.1.7") arrived = ServerRequest("GET", "/metrics", server_params={"client": ("10.1.2.3", 51234)}) assert await internal.matches(arrived)
-
PathRequestMatchermatches the path the application itself sees, free of per cent escapes: an application mounted under a path is reached at that path plus its own, the mount is reported as theroot_pathserver parameter, and a rule is written about the application's own path — so moving the mount rewrites no matcher./admin/fo otherefore matches/admin/fo%20o, and nothing in the expression is a path parameter:^/{_locale}/login$matches a path holding those very braces.
AttributesRequestMatcher is the one that asks about a finding rather than about the request as
it arrived — the named parameters a path matched, the user an authorization header resolved to:
from xtr_http_foundation import AttributesRequestMatcher
from xtr_http_message import ServerRequest
owners = AttributesRequestMatcher({"role": "^owner$"})
arrived = ServerRequest("GET", "/orders").with_attribute("role", "owner")
assert await owners.matches(arrived)
An attribute the request does not carry, or carries as something that is not text, is never claimed: there is nothing to match an expression against.
Writing one
RequestMatcherInterface is a @runtime_checkable Protocol with one member, so a rule of
your own needs no base class:
from typing import final
from typing_extensions import override
from xtr_http_foundation import RequestMatcherInterface
from xtr_http_message_contracts import ServerRequestInterface
@final
class SignedRequestMatcher(RequestMatcherInterface):
__slots__ = ()
@override
async def matches(self, request: ServerRequestInterface) -> bool:
return request.has_header("Webhook-Signature")
Addresses
IpUtils is a namespace rather than something to build: every answer is a function of its
arguments.
from xtr_http_foundation import IpUtils
IpUtils.check_ip("10.1.2.3", ["192.168.0.0/16", "10.0.0.0/8"]) # True
IpUtils.check_ip4("10.1.2.3", "10.0.0.0/8") # True
IpUtils.check_ip6("::1", "::1/128") # True
IpUtils.is_private_ip("10.1.2.3") # True
IpUtils.anonymize("192.168.1.42") # "192.168.1.0"
IpUtils.anonymize("2001:db8::1") # "2001:db8::"
check_ip(request_ip, ips)takes a single address, a network in CIDR notation, or several of either, and picks the IPv4 or the IPv6 check by how the address is written.- Text that is no address is no error for the checks: they answer about a request from a
stranger, whose address is theirs to misspell, and an address nobody can read is one no
network holds. A network written wrongly is read as no network at all rather than as a wider
one:
" 8","+8"and"8.5"name no prefix length, and an IPv6 zone — the%eth0offe80::1%eth0, which names an interface of whichever host reads it — is refused rather than quietly dropped. is_private_ip(ip)is the one that refuses unreadable text, withInvalidArgumentError: its caller is asking whether an address may be reached out to, and text nobody can read is not an address that failed the question. It places the address inIpUtils.PRIVATE_SUBNETS— loopback, private use, link-local, carrier-grade NAT, unique-local, multicast, reserved, unspecified, the documentation and benchmarking ranges, and the forms an IPv6 address writes an IPv4 one in. That tuple is what xtr-http-client'sNoPrivateNetworkHttpClientguards by default.anonymize(ip, v4_bytes=1, v6_bytes=8)zeroes the last bytes, so what is left no longer names one machine — for a log or a counter that must not keep an address. It gives the address back as it arrived, bracketed if it was and dotted where it carries an IPv4 one, and never gives a zone back: that names an interface, which is as much a trace of who was asked as the bytes just dropped. Asking for fewer than no bytes, or for more than the address has, isInvalidArgumentError.
Errors
Everything derives from HttpFoundationError. There is one: InvalidArgumentError, also a
ValueError, raised for an expression that does not read as one, and by is_private_ip and
anonymize for text that is no address.
Development
Developed in the python-xtr monorepo, under
packages/xtr-http-foundation; run the commands below from there. The
python-xtr-http-foundation repository is a read-only copy, so send issues and pull requests
to the monorepo.
uv sync
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
License
MIT — see LICENSE.
Metadata
Release files for xtr-http-foundation 4.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| xtr_http_foundation-4.1.0.tar.gz | 22.2 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xtr_http_foundation-4.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 54.6 kB
Release files / xtr_http_foundation-4.1.0.tar.gz
| Download URL | xtr_http_foundation-4.1.0.tar.gz |
|---|---|
| Size | 22.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
df132e8dff479c686e4f4949925cb279634924f0d79e067d8eda6cdba1e57cf5
|
|
BLAKE2b-256 checksum How to use checksums |
d7aac2b491e517e46aeeb36b5e5204614723c67ad047389dd4204a83af12e217
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logRelease files / xtr_http_foundation-4.1.0-py3-none-any.whl
| Download URL | xtr_http_foundation-4.1.0-py3-none-any.whl |
|---|---|
| Size | 32.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
680d18ca534ae5aae50560507a8f2ace1d4cd57489e0327fa673b1b201d2976e
|
|
BLAKE2b-256 checksum How to use checksums |
179428d7443a40806fceaad5098d5328841318a6e342a9fdf7e3ae018ec12825
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logSecurity for xtr-http-foundation 4.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for xtr_http_foundation-4.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.