Skip to content

feat: restrict routes to hosts with hosts= - #12

Merged
klen merged 2 commits into
klen:developfrom
ericbn:feature/hosts
Oct 19, 2026
Merged

klen merged 2 commits into
klen:developfrom
ericbn:feature/hosts

Conversation

@ericbn

@ericbn ericbn commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Closes #11.

Adds a hosts= option to routes, mirroring methods=, and an optional host parameter when matching:

@router.get("/reports", hosts="admin.example.com")
def reports(): ...

router("/reports", method="GET", host="admin.example.com")  # → reports
router("/reports", method="GET", host="www.example.com")    # NotFoundError

Details

  • host is an optional third parameter of __call__ and match, so it's part of match()'s lru_cache key. A route with hosts doesn't match when no host is passed, and routes without hosts match any host, so existing code is unaffected.
  • Mounted routers accept hosts= too, checked before delegating, like the mount's methods, and Mount.match passes the host down so routes inside a mounted router can be restricted by host as well. Without this, __route__'s **__ would silently swallow hosts= on a mount.
  • The route classes take a keyword-only hosts, so positional calls like Route(path, methods, target) keep working. The .pxd declares the new attribute and cpdef RouteMatch match(self, str path, str method, str host=*).
  • Each route checks its hosts only once its path matches, so the routes that the loop tries and rejects don't pay for the host check.
  • Tests: tests/test_hosts.py covers matching and skipping, several hosts, verbatim comparison, falling through to later routes, NotFoundError vs. InvalidMethodError, positional host, caching per host, the reserved option, bind(), method shortcuts and mounts. test_parity.py gained host cases, which pass against the compiled build.

Benchmark

test_benchmark (which registers no host-restricted routes), alternating develop and this branch 5× each, Apple M3, Python 3.13.16, Cython 3.3.0:

develop median (range) this branch median (range)
Compiled 1.128 ms (1.121–1.137) 1.147 ms (1.124–1.160)
Pure Python 1.976 ms (1.885–1.990) 2.007 ms (1.942–2.018)

That's about +1.5% in both builds, with overlapping ranges. The remaining cost is the extra host argument on each route match.

Possible follow-up

A host mismatch is reported like a path mismatch (path=False). An additional host flag on RouteMatch, next to path and method, would let callers of match() tell "this path exists on another host" apart from "no such path", e.g. to log it or answer differently. It doesn't change any matching outcome and would make code more complex, so I left it out to keep this PR small. I'm happy to add it if you think it's useful.

Add a hosts= option to routes, mirroring methods=, and an optional host
parameter when matching. A route without hosts matches every host, so
existing code is unaffected. A route with hosts only matches the hosts
it lists, and is skipped as if its path hadn't matched otherwise, so a
route that doesn't exist on the given host raises NotFoundError.

hosts is normalized to a set, so a bare string is never matched as a
substring, and hosts are compared verbatim: callers strip the port and
lowercase the host before passing it. Mounted routers accept hosts=
too, and receive the host to match their own routes.

hosts becomes a reserved option in route()/bind(), so it's no longer
forwarded to the target like other extra keyword arguments.

Closes klen#11

@klen klen left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Checked it locally: builds clean (the str host=* in the pxd lines up with host: str | None), 78 tests pass compiled and pure, mypy/ruff clean.

Covers everything from #11: narrow hosts= form (no when=), reuses NotFoundError, hosts normalized to a set in bind()/__route__(), verbatim comparison documented, reserved hosts documented. Folding host into path (host mismatch → path=False) is fine, and skipping a separate host flag on RouteMatch was the right call.

DRY the methods/hosts normalization shared by bind() and __route__().
@klen
klen merged commit 63413e5 into klen:develop Oct 19, 2026
1 check passed
@klen

klen commented Oct 19, 2026

Copy link
Copy Markdown
Owner

Merged — thanks again for the PR, the benchmarks and the care with the review follow-ups!

@ericbn
ericbn deleted the feature/hosts branch October 19, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Host-based routing: one route table serving several hosts

2 participants