From f110771fdfa299f98ef3380982d553dde493820c Mon Sep 17 00:00:00 2001 From: Ricardo Gonzalez Date: Thu, 23 Jul 2026 17:20:53 -0700 Subject: [PATCH] proxy --- README.md | 6 +- .../python-routing-middleware.feature.md | 2 + pyproject.toml | 1 + scripts/bundle_release.py | 4 + src/vercel-proxy/CHANGELOG.md | 1 + src/vercel-proxy/LICENSE | 21 ++ src/vercel-proxy/README.md | 119 ++++++++++ src/vercel-proxy/hatch_build.py | 29 +++ src/vercel-proxy/pyproject.toml | 64 +++++ src/vercel-proxy/tests/__init__.py | 1 + src/vercel-proxy/tests/conftest.py | 63 +++++ src/vercel-proxy/tests/test_proxy.py | 219 ++++++++++++++++++ src/vercel-proxy/tests/test_responses.py | 98 ++++++++ src/vercel-proxy/tests/test_routes.py | 180 ++++++++++++++ src/vercel-proxy/vercel/proxy/__init__.py | 27 +++ src/vercel-proxy/vercel/proxy/_app.py | 109 +++++++++ src/vercel-proxy/vercel/proxy/_responses.py | 135 +++++++++++ src/vercel-proxy/vercel/proxy/_routing.py | 183 +++++++++++++++ src/vercel-proxy/vercel/proxy/matchers.py | 102 ++++++++ src/vercel-proxy/vercel/proxy/py.typed | 1 + src/vercel-proxy/vercel/proxy/version.py | 1 + src/vercel/README.md | 1 + src/vercel/pyproject.toml | 2 + tests/unit/test_release_system.py | 25 ++ uv.lock | 13 ++ 25 files changed, 1404 insertions(+), 3 deletions(-) create mode 100644 changes/vercel-proxy/python-routing-middleware.feature.md create mode 100644 src/vercel-proxy/CHANGELOG.md create mode 100644 src/vercel-proxy/LICENSE create mode 100644 src/vercel-proxy/README.md create mode 100644 src/vercel-proxy/hatch_build.py create mode 100644 src/vercel-proxy/pyproject.toml create mode 100644 src/vercel-proxy/tests/__init__.py create mode 100644 src/vercel-proxy/tests/conftest.py create mode 100644 src/vercel-proxy/tests/test_proxy.py create mode 100644 src/vercel-proxy/tests/test_responses.py create mode 100644 src/vercel-proxy/tests/test_routes.py create mode 100644 src/vercel-proxy/vercel/proxy/__init__.py create mode 100644 src/vercel-proxy/vercel/proxy/_app.py create mode 100644 src/vercel-proxy/vercel/proxy/_responses.py create mode 100644 src/vercel-proxy/vercel/proxy/_routing.py create mode 100644 src/vercel-proxy/vercel/proxy/matchers.py create mode 100644 src/vercel-proxy/vercel/proxy/py.typed create mode 100644 src/vercel-proxy/vercel/proxy/version.py diff --git a/README.md b/README.md index dc2695c7..61c51653 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,9 @@ asyncio.run(main()) ## Public API Vs Internals Import from public modules under `vercel.*`, such as `vercel.blob`, -`vercel.cache`, `vercel.headers`, `vercel.oidc`, `vercel.projects`, and -`vercel.sandbox`. Modules under `vercel._internal.*` are implementation details -and may change without public API guarantees. +`vercel.cache`, `vercel.headers`, `vercel.oidc`, `vercel.projects`, +`vercel.proxy`, and `vercel.sandbox`. Modules under `vercel._internal.*` are +implementation details and may change without public API guarantees. Sync counterparts are available for the main client classes and module-level helpers when you are not running an async application. diff --git a/changes/vercel-proxy/python-routing-middleware.feature.md b/changes/vercel-proxy/python-routing-middleware.feature.md new file mode 100644 index 00000000..415fc877 --- /dev/null +++ b/changes/vercel-proxy/python-routing-middleware.feature.md @@ -0,0 +1,2 @@ +Add Starlette-compatible Python routing middleware, route matchers, rewrites, +redirects, and request continuation helpers. diff --git a/pyproject.toml b/pyproject.toml index 5ac766d5..d202b332 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ mypy_path = [ "src/vercel-headers", "src/vercel-queue", "src/vercel-oidc", + "src/vercel-proxy", "src/vercel-internal-telemetry", "integrations/vercel-celery", "integrations/vercel-dramatiq", diff --git a/scripts/bundle_release.py b/scripts/bundle_release.py index 02e3bb75..474f4bf5 100644 --- a/scripts/bundle_release.py +++ b/scripts/bundle_release.py @@ -68,6 +68,10 @@ PEER_DEPENDENCIES = { "vercel-celery": {"celery"}, "vercel-dramatiq": {"dramatiq"}, + # Starlette classes are part of vercel.proxy's public interoperability + # contract. Vendoring them would make application Response objects fail + # identity checks against a second, private Starlette installation. + "vercel-proxy": {"starlette"}, } COMMON_DROP_TRANSFORMATIONS = ( "*.so", diff --git a/src/vercel-proxy/CHANGELOG.md b/src/vercel-proxy/CHANGELOG.md new file mode 100644 index 00000000..825c32f0 --- /dev/null +++ b/src/vercel-proxy/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/src/vercel-proxy/LICENSE b/src/vercel-proxy/LICENSE new file mode 100644 index 00000000..eabe3008 --- /dev/null +++ b/src/vercel-proxy/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vercel, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/vercel-proxy/README.md b/src/vercel-proxy/README.md new file mode 100644 index 00000000..f8a2a387 --- /dev/null +++ b/src/vercel-proxy/README.md @@ -0,0 +1,119 @@ +# Proxy + +`vercel.proxy` provides Python routing middleware that runs before Vercel's +cache and application routing. + +## Configuration + +Point Vercel at an exported `Proxy` object: + +`vercel.json` +```json +{ + "proxy": "proxy.py" +} +``` + +The proxy function has no dependencies installed by default. To add dependencies +to your proxy, add a `proxy` dependency group in `pyproject.toml` + +```toml +[dependency-groups] +proxy = ["vercel-proxy"] +``` + + +`proxy.py`: + +```python +from vercel.proxy import Proxy, Request, redirect, rewrite + +proxy = Proxy() + + +@proxy.middleware("http") +async def authenticate(request: Request, call_next): + if request.cookies.get("session") is None: + return redirect("/login") + + if request.url.path == "/about": + return rewrite("/about-2") + + response = await call_next(request) + response.headers["x-authenticated"] = "true" + return response +``` + +`call_next()` advances through the remaining Python proxy middleware and +routes. It returns a synthetic routing response; it does not invoke, await, or +contain the eventual CDN or application response. + +Returning `None` continues Vercel routing unchanged. Use +`continue_routing()` when the continuation needs response headers or a +complete replacement set of request headers: + +```python +from vercel.proxy import continue_routing + +return continue_routing( + headers={"x-authenticated": "true"}, + request_headers={ + **request.headers, + "x-user-id": "user_123", + }, +) +``` + +`request_headers` is the complete set forwarded after the proxy, not a patch. + +## Route selected logic + +Route paths use template syntax where `{name}` captures a single path segment +and `{name:path}` captures the remainder of the path. The first route whose +path, method, and conditions all match is selected. With no `methods` +argument, a route matches every HTTP method. + +```python +from vercel.proxy import Proxy, Request, Route, redirect, rewrite +from vercel.proxy.matchers import cookie, header, host, query + + +async def dashboard(request: Request): + if request.cookies.get("session") is None: + return redirect("/login") + return None + + +proxy = Proxy( + routes=[ + Route( + "/dashboard/{path:path}", + dashboard, + has=[host("{tenant}.example.com"), cookie("session")], + missing=[header("x-blocked")], + ), + Route.rewrite( + "/legacy/{path:path}", + "/new/{path}", + has=[query("migrate", "1")], + ), + Route.redirect("/docs", "/documentation", status_code=308), + ] +) +``` + +`header()`, `cookie()`, and `query()` match presence when no value is supplied +and use exact value matching otherwise. `host()` supports path-style captures; +captured hostname values are added to `request.path_params`. + +For a rewrite that needs arbitrary Python logic, use a normal route handler: + +```python +Route( + "/legacy/{path:path}", + lambda request: rewrite(f"/new/{request.path_params['path']}"), +) +``` + +Purely static rewrites are generally more efficient in Vercel routing +configuration because they do not need to start Python. diff --git a/src/vercel-proxy/hatch_build.py b/src/vercel-proxy/hatch_build.py new file mode 100644 index 00000000..7e98cd22 --- /dev/null +++ b/src/vercel-proxy/hatch_build.py @@ -0,0 +1,29 @@ +"""Load the shared Vercel Hatch metadata hook.""" + +from __future__ import annotations + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +from types import ModuleType + +from hatchling.metadata.plugin.interface import MetadataHookInterface + + +def get_metadata_hook() -> type[MetadataHookInterface]: + """Return the shared workspace dependency metadata hook.""" + return _load_shared_hook().get_metadata_hook() + + +def _load_shared_hook() -> ModuleType: + root = Path(__file__).resolve().parent + candidates = [root / "../../scripts/hatch_build.py", root / "_vercel_hatch_build.py"] + for candidate in candidates: + path = candidate.resolve() + if path.exists(): + spec = spec_from_file_location("_vercel_hatch_build", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not load Hatch hook from {path}") + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + raise RuntimeError("could not find shared Vercel Hatch metadata hook") diff --git a/src/vercel-proxy/pyproject.toml b/src/vercel-proxy/pyproject.toml new file mode 100644 index 00000000..ca05a92f --- /dev/null +++ b/src/vercel-proxy/pyproject.toml @@ -0,0 +1,64 @@ +[build-system] +requires = ["hatchling>=1.27.0,<2"] +build-backend = "hatchling.build" + +[project] +name = "vercel-proxy" +dynamic = ["version", "dependencies"] +description = "Python routing middleware for Vercel" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +license-files = ["LICENSE", "LICENSE.*"] + +[tool.hatch.metadata.hooks.custom] +path = "hatch_build.py" + +[tool.vercel.release.dependencies] +dependencies = [ + "starlette>=0.46.0,<2", +] + +[tool.hatch.version] +path = "vercel/proxy/version.py" + +[tool.hatch.build.targets.sdist] +force-include = { "../../scripts/hatch_build.py" = "/_vercel_hatch_build.py" } +include = [ + "/vercel/proxy/**/*.py", + "/vercel/proxy/py.typed", + "/README.md", + "/pyproject.toml", + "/hatch_build.py", + "/LICENSE", +] +exclude = [ + "/**/__pycache__", +] + +[tool.hatch.build.targets.wheel] +dev-mode-dirs = ["."] +only-include = [ + "/vercel/proxy", +] +exclude = [ + "/**/__pycache__", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = "--no-header --capture=tee-sys" +asyncio_mode = "auto" + +[tool.poe] +include = "../../scripts/poe/poe.toml" +verbosity = -1 + +[tool.mypy] +cache_dir = "../../.mypy_cache/vercel-proxy" +explicit_package_bases = true +packages = ["vercel.proxy"] + +[tool.poe.tasks.typecheck] +cmd = "$MYPY" diff --git a/src/vercel-proxy/tests/__init__.py b/src/vercel-proxy/tests/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/vercel-proxy/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/vercel-proxy/tests/conftest.py b/src/vercel-proxy/tests/conftest.py new file mode 100644 index 00000000..b6662c2c --- /dev/null +++ b/src/vercel-proxy/tests/conftest.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any + +from starlette.types import ASGIApp, Message + + +def make_scope( + path: str = "/", + *, + method: str = "GET", + headers: Mapping[str, str] | Iterable[tuple[str, str]] = (), + query_string: str = "", +) -> dict[str, Any]: + header_items = headers.items() if isinstance(headers, Mapping) else headers + raw_headers = [(name.lower().encode(), value.encode()) for name, value in header_items] + if not any(name == b"host" for name, _ in raw_headers): + raw_headers.append((b"host", b"example.com")) + return { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": method, + "scheme": "https", + "path": path, + "raw_path": path.encode(), + "query_string": query_string.encode(), + "root_path": "", + "headers": raw_headers, + "client": ("127.0.0.1", 1234), + "server": ("example.com", 443), + } + + +async def invoke( + app: ASGIApp, + scope: dict[str, Any] | None = None, + *, + body: bytes = b"", +) -> list[Message]: + messages: list[Message] = [] + request_sent = False + + async def receive() -> Message: + nonlocal request_sent + if request_sent: + return {"type": "http.disconnect"} + request_sent = True + return {"type": "http.request", "body": body, "more_body": False} + + async def send(message: Message) -> None: + messages.append(message) + + await app(scope or make_scope(), receive, send) + return messages + + +def response_headers(messages: list[Message]) -> dict[str, str]: + start = next(message for message in messages if message["type"] == "http.response.start") + return { + name.decode("latin-1"): value.decode("latin-1") for name, value in start.get("headers", []) + } diff --git a/src/vercel-proxy/tests/test_proxy.py b/src/vercel-proxy/tests/test_proxy.py new file mode 100644 index 00000000..b3f078fb --- /dev/null +++ b/src/vercel-proxy/tests/test_proxy.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import asyncio +import inspect +from typing import Any + +import pytest +from starlette.types import Message + +from vercel.proxy import Proxy, Request, Route, RoutingResponse, rewrite + +from .conftest import invoke, make_scope, response_headers + + +async def test_middleware_composes_last_registered_as_outermost() -> None: + events: list[str] = [] + proxy = Proxy(routes=[Route.rewrite("/", "/destination")]) + + @proxy.middleware("http") + async def inner(request: Request, call_next): + events.append("inner:request") + response = await call_next(request) + events.append("inner:response") + response.headers["x-inner"] = "true" + return response + + @proxy.middleware("http") + async def outer(request: Request, call_next): + events.append("outer:request") + response = await call_next(request) + events.append("outer:response") + response.headers["x-outer"] = "true" + return response + + headers = response_headers(await invoke(proxy)) + + assert events == [ + "outer:request", + "inner:request", + "inner:response", + "outer:response", + ] + assert headers["x-middleware-rewrite"] == "/destination" + assert headers["x-inner"] == "true" + assert headers["x-outer"] == "true" + + +async def test_call_next_returns_a_synthetic_routing_response() -> None: + proxy = Proxy() + + @proxy.middleware("http") + async def authenticate(request: Request, call_next): + response = await call_next(request) + assert isinstance(response, RoutingResponse) + response.headers["x-authenticated"] = "true" + response.request_headers = dict(request.headers) + assert response.request_headers is not None + response.request_headers["x-user-id"] = "user_123" + return response + + headers = response_headers( + await invoke(proxy, make_scope(headers={"authorization": "Bearer token"})) + ) + + assert headers["x-middleware-next"] == "1" + assert headers["x-authenticated"] == "true" + assert headers["x-middleware-override-headers"] == "authorization,host,x-user-id" + assert headers["x-middleware-request-x-user-id"] == "user_123" + + +async def test_middleware_can_short_circuit_or_return_none() -> None: + called = False + short_circuit = Proxy(routes=[Route.rewrite("/", "/route")]) + + @short_circuit.middleware("http") + async def stop(request: Request, call_next): + return rewrite("/middleware") + + headers = response_headers(await invoke(short_circuit)) + assert headers["x-middleware-rewrite"] == "/middleware" + + continue_proxy = Proxy(routes=[Route("/", lambda request: _mark_called())]) + + def _mark_called(): + nonlocal called + called = True + return rewrite("/route") + + @continue_proxy.middleware("http") + async def return_none(request: Request, call_next): + return None + + headers = response_headers(await invoke(continue_proxy)) + assert headers["x-middleware-next"] == "1" + assert called is False + + +async def test_request_body_is_cached_across_middleware_and_route() -> None: + seen: list[bytes] = [] + + async def endpoint(request: Request): + seen.append(await request.body()) + return None + + proxy = Proxy(routes=[Route("/", endpoint)]) + + @proxy.middleware("http") + async def read_body(request: Request, call_next): + seen.append(await request.body()) + return await call_next(request) + + await invoke(proxy, body=b"payload") + + assert seen == [b"payload", b"payload"] + + +async def test_concurrent_requests_do_not_share_path_parameters() -> None: + async def endpoint(request: Request): + value = request.path_params["value"] + await asyncio.sleep(0) + return rewrite(f"/seen/{value}") + + proxy = Proxy(routes=[Route("/{value}", endpoint)]) + + first, second = await asyncio.gather( + invoke(proxy, make_scope("/one")), + invoke(proxy, make_scope("/two")), + ) + + assert response_headers(first)["x-middleware-rewrite"] == "/seen/one" + assert response_headers(second)["x-middleware-rewrite"] == "/seen/two" + + +def test_proxy_has_an_unambiguous_raw_asgi_signature() -> None: + proxy = Proxy() + parameters = list(inspect.signature(proxy).parameters.values()) + + assert [parameter.name for parameter in parameters] == ["scope", "receive", "send"] + assert all(parameter.default is inspect.Parameter.empty for parameter in parameters) + assert not hasattr(proxy, "user_middleware") + assert proxy.__vercel_proxy__ is True + + +def test_only_http_middleware_is_supported() -> None: + proxy = Proxy() + + with pytest.raises(ValueError, match="only supports HTTP"): + proxy.middleware("websocket") + + +async def test_invalid_middleware_return_is_rejected() -> None: + proxy = Proxy() + + @proxy.middleware("http") + async def invalid(request: Request, call_next): + return False + + with pytest.raises(TypeError, match="proxy middleware"): + await invoke(proxy) + + +async def test_middleware_registration_freezes_after_first_request() -> None: + proxy = Proxy() + await invoke(proxy) + + with pytest.raises(RuntimeError, match="cannot be registered"): + + @proxy.middleware("http") + async def too_late(request: Request, call_next): + return await call_next(request) + + +async def test_lifespan_completes_startup_and_shutdown() -> None: + proxy = Proxy() + incoming: asyncio.Queue[Message] = asyncio.Queue() + await incoming.put({"type": "lifespan.startup"}) + await incoming.put({"type": "lifespan.shutdown"}) + outgoing: list[Message] = [] + + async def receive() -> Message: + return await incoming.get() + + async def send(message: Message) -> None: + outgoing.append(message) + + await proxy({"type": "lifespan", "asgi": {"version": "3.0"}}, receive, send) + + assert outgoing == [ + {"type": "lifespan.startup.complete"}, + {"type": "lifespan.shutdown.complete"}, + ] + + +async def test_websocket_is_explicitly_rejected_in_v1() -> None: + proxy = Proxy() + outgoing: list[Message] = [] + + async def receive() -> Message: + return {"type": "websocket.connect"} + + async def send(message: Message) -> None: + outgoing.append(message) + + scope: dict[str, Any] = { + "type": "websocket", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "scheme": "wss", + "path": "/socket", + "raw_path": b"/socket", + "query_string": b"", + "root_path": "", + "headers": [], + "client": ("127.0.0.1", 1234), + "server": ("example.com", 443), + "subprotocols": [], + } + await proxy(scope, receive, send) + + assert outgoing == [{"type": "websocket.close", "code": 1008}] diff --git a/src/vercel-proxy/tests/test_responses.py b/src/vercel-proxy/tests/test_responses.py new file mode 100644 index 00000000..48fcdae0 --- /dev/null +++ b/src/vercel-proxy/tests/test_responses.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import pytest +from starlette.responses import RedirectResponse + +from vercel.proxy import RoutingResponse, continue_routing, redirect, rewrite + +from .conftest import invoke, response_headers + + +async def test_continue_routing_emits_only_the_control_protocol_at_send_time() -> None: + response = continue_routing(headers={"x-visible": "yes"}) + + assert isinstance(response, RoutingResponse) + assert response.action == "continue" + assert response.destination is None + assert dict(response.headers) == {"x-visible": "yes"} + + messages = await invoke(response) + + assert response_headers(messages) == { + "x-visible": "yes", + "x-middleware-next": "1", + } + assert dict(response.headers) == {"x-visible": "yes"} + assert messages[-1] == {"type": "http.response.body", "body": b""} + + +async def test_rewrite_supports_relative_and_absolute_destinations() -> None: + relative = rewrite("/new/path?source=proxy") + absolute = rewrite("https://other.example/path") + + assert relative.action == "rewrite" + assert relative.destination == "/new/path?source=proxy" + assert response_headers(await invoke(relative))["x-middleware-rewrite"] == ( + "/new/path?source=proxy" + ) + assert response_headers(await invoke(absolute))["x-middleware-rewrite"] == ( + "https://other.example/path" + ) + + +async def test_rewrite_encodes_destination_for_an_http_header() -> None: + response = rewrite("/hello world/❤?from=two words") + + assert response.destination == "/hello%20world/%E2%9D%A4?from=two%20words" + assert response_headers(await invoke(response))["x-middleware-rewrite"] == ( + "/hello%20world/%E2%9D%A4?from=two%20words" + ) + + +async def test_request_headers_are_a_complete_mutable_replacement() -> None: + response = continue_routing(request_headers={"Authorization": "Bearer original"}) + assert response.request_headers is not None + response.request_headers["authorization"] = "Bearer updated" + response.request_headers["x-user-id"] = "user_123" + + headers = response_headers(await invoke(response)) + + assert headers["x-middleware-override-headers"] == "authorization,x-user-id" + assert headers["x-middleware-request-authorization"] == "Bearer updated" + assert headers["x-middleware-request-x-user-id"] == "user_123" + + +async def test_empty_request_headers_emit_an_explicit_empty_replacement() -> None: + headers = response_headers(await invoke(continue_routing(request_headers={}))) + + assert "x-middleware-override-headers" in headers + assert headers["x-middleware-override-headers"] == "" + + +async def test_routing_response_does_not_generate_content_length() -> None: + assert "content-length" not in response_headers(await invoke(continue_routing())) + + explicit = continue_routing(headers={"content-length": "0"}) + assert response_headers(await invoke(explicit))["content-length"] == "0" + + +async def test_middleware_protocol_headers_are_reserved() -> None: + response = continue_routing() + response.headers["x-middleware-next"] = "user-value" + + with pytest.raises(ValueError, match="reserved"): + await invoke(response) + + +def test_redirect_returns_a_native_starlette_response() -> None: + response = redirect("/login", status_code=308, headers={"x-reason": "auth"}) + + assert isinstance(response, RedirectResponse) + assert response.status_code == 308 + assert response.headers["location"] == "/login" + assert response.headers["x-reason"] == "auth" + + +def test_redirect_rejects_non_redirect_status() -> None: + with pytest.raises(ValueError, match="between 300 and 399"): + redirect("/login", status_code=200) diff --git a/src/vercel-proxy/tests/test_routes.py b/src/vercel-proxy/tests/test_routes.py new file mode 100644 index 00000000..e9fed03f --- /dev/null +++ b/src/vercel-proxy/tests/test_routes.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import threading + +import pytest + +from vercel.proxy import PlainTextResponse, Proxy, Request, Route, rewrite +from vercel.proxy.matchers import cookie, header, host, query + +from .conftest import invoke, make_scope, response_headers + + +async def test_unmatched_route_continues_routing() -> None: + proxy = Proxy(routes=[Route("/dashboard", lambda request: None)]) + + headers = response_headers(await invoke(proxy, make_scope("/other"))) + + assert headers["x-middleware-next"] == "1" + + +async def test_matched_none_continues_routing() -> None: + async def dashboard(request: Request) -> None: + assert request.path_params["path"] == "settings" + return None + + proxy = Proxy(routes=[Route("/dashboard/{path:path}", dashboard)]) + + headers = response_headers(await invoke(proxy, make_scope("/dashboard/settings"))) + + assert headers["x-middleware-next"] == "1" + + +async def test_dynamic_route_can_rewrite() -> None: + async def legacy(request: Request): + return rewrite(f"/new/{request.path_params['path']}") + + proxy = Proxy(routes=[Route("/legacy/{path:path}", legacy)]) + + headers = response_headers(await invoke(proxy, make_scope("/legacy/docs/start"))) + + assert headers["x-middleware-rewrite"] == "/new/docs/start" + + +async def test_route_can_end_routing_with_a_normal_response() -> None: + proxy = Proxy( + routes=[Route("/health", lambda request: PlainTextResponse("healthy", status_code=202))] + ) + + messages = await invoke(proxy, make_scope("/health")) + + assert messages[0]["status"] == 202 + assert "x-middleware-next" not in response_headers(messages) + assert messages[-1]["body"] == b"healthy" + + +async def test_declarative_rewrite_and_redirect_interpolate_route_parameters() -> None: + rewrite_proxy = Proxy(routes=[Route.rewrite("/legacy/{path:path}", "/new/{path}")]) + redirect_proxy = Proxy(routes=[Route.redirect("/old/{slug}", "/docs/{slug}", status_code=308)]) + + rewrite_headers = response_headers( + await invoke(rewrite_proxy, make_scope("/legacy/guides/start")) + ) + redirect_messages = await invoke(redirect_proxy, make_scope("/old/python")) + redirect_headers = response_headers(redirect_messages) + + assert rewrite_headers["x-middleware-rewrite"] == "/new/guides/start" + assert redirect_messages[0]["status"] == 308 + assert redirect_headers["location"] == "/docs/python" + + +def test_declarative_destination_rejects_unknown_parameter() -> None: + with pytest.raises(ValueError, match="unknown route parameter: missing"): + Route.rewrite("/legacy/{path:path}", "/new/{missing}") + + +async def test_methods_default_to_all_and_explicit_get_includes_head() -> None: + all_methods = Proxy(routes=[Route.rewrite("/resource", "/matched")]) + get_only = Proxy(routes=[Route.rewrite("/resource", "/matched", methods=["GET"])]) + + assert ( + response_headers(await invoke(all_methods, make_scope("/resource", method="POST")))[ + "x-middleware-rewrite" + ] + == "/matched" + ) + assert ( + response_headers(await invoke(get_only, make_scope("/resource", method="HEAD")))[ + "x-middleware-rewrite" + ] + == "/matched" + ) + assert ( + response_headers(await invoke(get_only, make_scope("/resource", method="POST")))[ + "x-middleware-next" + ] + == "1" + ) + + +async def test_conditions_match_headers_cookies_queries_and_hostname() -> None: + async def tenant(request: Request): + return rewrite(f"/tenants/{request.path_params['subdomain']}/{request.path_params['path']}") + + proxy = Proxy( + routes=[ + Route( + "/dashboard/{path:path}", + tenant, + has=[ + host("{subdomain}.example.com"), + header("x-plan", "pro"), + cookie("session"), + query("preview", "1"), + ], + missing=[header("x-blocked")], + ) + ] + ) + scope = make_scope( + "/dashboard/settings", + headers={ + "host": "ACME.EXAMPLE.COM:443", + "x-plan": "pro", + "cookie": "session=signed", + }, + query_string="preview=1", + ) + + headers = response_headers(await invoke(proxy, scope)) + + assert headers["x-middleware-rewrite"] == "/tenants/acme/settings" + + +async def test_duplicate_header_values_match_when_any_value_matches() -> None: + proxy = Proxy(routes=[Route.rewrite("/", "/pro", has=[header("x-plan", "pro")])]) + scope = make_scope("/", headers=[("x-plan", "free"), ("x-plan", "pro")]) + + assert response_headers(await invoke(proxy, scope))["x-middleware-rewrite"] == "/pro" + + +async def test_failed_conditions_fall_through_to_the_next_route() -> None: + proxy = Proxy( + routes=[ + Route.rewrite("/dashboard", "/pro", has=[header("x-plan", "pro")]), + Route.rewrite("/dashboard", "/free"), + ] + ) + + headers = response_headers(await invoke(proxy, make_scope("/dashboard"))) + + assert headers["x-middleware-rewrite"] == "/free" + + +def test_host_and_path_parameters_cannot_overlap() -> None: + with pytest.raises(ValueError, match="overlap: tenant"): + Route("/{tenant}", lambda request: None, has=[host("{tenant}.example.com")]) + + +async def test_sync_endpoints_run_outside_the_event_loop_thread() -> None: + event_loop_thread = threading.get_ident() + endpoint_thread: int | None = None + + def endpoint(request: Request): + nonlocal endpoint_thread + endpoint_thread = threading.get_ident() + return rewrite("/sync") + + proxy = Proxy(routes=[Route("/", endpoint)]) + headers = response_headers(await invoke(proxy)) + + assert headers["x-middleware-rewrite"] == "/sync" + assert endpoint_thread is not None + assert endpoint_thread != event_loop_thread + + +async def test_route_must_return_response_or_none() -> None: + proxy = Proxy(routes=[Route("/", lambda request: False)]) # type: ignore[arg-type] + + with pytest.raises(TypeError, match="proxy route"): + await invoke(proxy) diff --git a/src/vercel-proxy/vercel/proxy/__init__.py b/src/vercel-proxy/vercel/proxy/__init__.py new file mode 100644 index 00000000..fbe24ca0 --- /dev/null +++ b/src/vercel-proxy/vercel/proxy/__init__.py @@ -0,0 +1,27 @@ +"""Python routing middleware for Vercel.""" + +from __future__ import annotations + +from starlette.datastructures import URL +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response + +from ._app import CallNext, Proxy +from ._responses import RoutingResponse, continue_routing, redirect, rewrite +from ._routing import Route + +__all__ = [ + "CallNext", + "HTMLResponse", + "JSONResponse", + "PlainTextResponse", + "Proxy", + "Request", + "Response", + "Route", + "RoutingResponse", + "URL", + "continue_routing", + "redirect", + "rewrite", +] diff --git a/src/vercel-proxy/vercel/proxy/_app.py b/src/vercel-proxy/vercel/proxy/_app.py new file mode 100644 index 00000000..8ced1372 --- /dev/null +++ b/src/vercel-proxy/vercel/proxy/_app.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable, Sequence +from typing import Any, TypeAlias, TypeVar + +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import Receive, Scope, Send + +from ._responses import continue_routing +from ._routing import ProxyResult, Route + +CallNext: TypeAlias = Callable[[Request], Awaitable[Response]] +HTTPMiddleware: TypeAlias = Callable[ + [Request, CallNext], + ProxyResult | Awaitable[ProxyResult], +] +MiddlewareFunction = TypeVar("MiddlewareFunction", bound=HTTPMiddleware) + + +def _normalize_result(result: Any, source: str) -> Response: + if result is None: + return continue_routing() + if not isinstance(result, Response): + raise TypeError(f"{source} must return a Starlette Response or None") + return result + + +class Proxy: + """An HTTP routing middleware application for Vercel.""" + + __vercel_proxy__ = True + + def __init__(self, *, routes: Sequence[Route] = ()) -> None: + if not all(isinstance(route, Route) for route in routes): + raise TypeError("Proxy routes must be vercel.proxy.Route instances") + self.routes = tuple(routes) + self._middleware: list[HTTPMiddleware] = [] + self._frozen = False + + def middleware( + self, middleware_type: str + ) -> Callable[[MiddlewareFunction], MiddlewareFunction]: + """Register HTTP middleware. The last registered function runs first.""" + if middleware_type != "http": + raise ValueError("vercel.proxy only supports HTTP middleware") + + def decorator(function: MiddlewareFunction) -> MiddlewareFunction: + if self._frozen: + raise RuntimeError("proxy middleware cannot be registered after the proxy starts") + self._middleware.append(function) + return function + + return decorator + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + self._frozen = True + scope_type = scope["type"] + if scope_type == "lifespan": + await self._handle_lifespan(receive, send) + return + if scope_type == "websocket": + await send({"type": "websocket.close", "code": 1008}) + return + if scope_type != "http": + raise RuntimeError(f"unsupported ASGI scope type: {scope_type!r}") + + request = Request(scope, receive=receive) + middleware = tuple(reversed(self._middleware)) + + async def dispatch(index: int, current_request: Request) -> Response: + if index == len(middleware): + return await self._dispatch_route(current_request) + + async def call_next(next_request: Request) -> Response: + if not isinstance(next_request, Request): + raise TypeError("call_next expects a Starlette Request") + return await dispatch(index + 1, next_request) + + result = middleware[index](current_request, call_next) + if inspect.isawaitable(result): + result = await result + return _normalize_result(result, "proxy middleware") + + response = await dispatch(0, request) + await response(scope, receive, send) + + async def _dispatch_route(self, request: Request) -> Response: + for route in self.routes: + child_scope = route.match(request) + if child_scope is None: + continue + request.scope.update(child_scope) + result = await route.handle(request) + return _normalize_result(result, "proxy route") + return continue_routing() + + async def _handle_lifespan(self, receive: Receive, send: Send) -> None: + while True: + message = await receive() + message_type = message["type"] + if message_type == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message_type == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + else: + raise RuntimeError(f"unsupported lifespan message: {message_type!r}") diff --git a/src/vercel-proxy/vercel/proxy/_responses.py b/src/vercel-proxy/vercel/proxy/_responses.py new file mode 100644 index 00000000..06c52904 --- /dev/null +++ b/src/vercel-proxy/vercel/proxy/_responses.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Literal +from urllib.parse import quote + +from starlette.datastructures import URL, MutableHeaders +from starlette.responses import RedirectResponse, Response +from starlette.types import Receive, Scope, Send + +_CONTINUE_HEADER = b"x-middleware-next" +_REWRITE_HEADER = b"x-middleware-rewrite" +_OVERRIDE_HEADERS = b"x-middleware-override-headers" +_REQUEST_HEADER_PREFIX = b"x-middleware-request-" +_INTERNAL_HEADER_PREFIX = b"x-middleware-" + +RoutingAction = Literal["continue", "rewrite"] + + +class RoutingResponse(Response): + """A synthetic response that tells Vercel how to continue routing. + + Routing responses never contain the eventual CDN or origin response. Their + ordinary headers are merged into that eventual response by Vercel. + """ + + media_type = None + + def __init__( + self, + *, + headers: Mapping[str, str] | None = None, + request_headers: Mapping[str, str] | None = None, + _destination: str | URL | None = None, + ) -> None: + explicit_content_length = headers is not None and any( + name.lower() == "content-length" for name in headers + ) + super().__init__(content=b"", status_code=200, headers=headers) + if not explicit_content_length: + self.raw_headers[:] = [ + (name, value) for name, value in self.raw_headers if name != b"content-length" + ] + + self._destination = ( + quote(str(_destination), safe=":/%#?=@[]!$&'()*+,;") + if _destination is not None + else None + ) + self._request_headers: MutableHeaders | None = None + self.request_headers = request_headers + + @property + def action(self) -> RoutingAction: + return "rewrite" if self._destination is not None else "continue" + + @property + def destination(self) -> str | None: + return self._destination + + @property + def request_headers(self) -> MutableHeaders | None: + """The complete request header set to forward after this proxy.""" + return self._request_headers + + @request_headers.setter + def request_headers(self, value: Mapping[str, str] | None) -> None: + self._request_headers = MutableHeaders(headers=value) if value is not None else None + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + headers = list(self.raw_headers) + if any(name.startswith(_INTERNAL_HEADER_PREFIX) for name, _ in headers): + raise ValueError("x-middleware-* response headers are reserved by vercel.proxy") + + if self._destination is None: + headers.append((_CONTINUE_HEADER, b"1")) + else: + headers.append((_REWRITE_HEADER, self._destination.encode("latin-1"))) + + if self._request_headers is not None: + request_header_names = list(dict.fromkeys(self._request_headers.keys())) + headers.append((_OVERRIDE_HEADERS, ",".join(request_header_names).encode("latin-1"))) + for name in request_header_names: + headers.append( + ( + _REQUEST_HEADER_PREFIX + name.encode("latin-1"), + self._request_headers[name].encode("latin-1"), + ) + ) + + await send( + { + "type": "http.response.start", + "status": self.status_code, + "headers": headers, + } + ) + await send({"type": "http.response.body", "body": b""}) + if self.background is not None: + await self.background() + + +def continue_routing( + *, + headers: Mapping[str, str] | None = None, + request_headers: Mapping[str, str] | None = None, +) -> RoutingResponse: + """Continue through Vercel routing without changing the destination.""" + return RoutingResponse(headers=headers, request_headers=request_headers) + + +def rewrite( + destination: str | URL, + *, + headers: Mapping[str, str] | None = None, + request_headers: Mapping[str, str] | None = None, +) -> RoutingResponse: + """Continue Vercel routing using a different URL.""" + return RoutingResponse( + headers=headers, + request_headers=request_headers, + _destination=destination, + ) + + +def redirect( + destination: str | URL, + *, + status_code: int = 307, + headers: Mapping[str, str] | None = None, +) -> RedirectResponse: + """End routing with an HTTP redirect response.""" + if not 300 <= status_code < 400: + raise ValueError("redirect status_code must be between 300 and 399") + return RedirectResponse(str(destination), status_code=status_code, headers=headers) diff --git a/src/vercel-proxy/vercel/proxy/_routing.py b/src/vercel-proxy/vercel/proxy/_routing.py new file mode 100644 index 00000000..a1164375 --- /dev/null +++ b/src/vercel-proxy/vercel/proxy/_routing.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import inspect +import re +from collections.abc import Awaitable, Callable, Collection, Mapping, Sequence +from functools import partial +from typing import Any, TypeAlias, cast + +from starlette.concurrency import run_in_threadpool +from starlette.datastructures import URL +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Match, Route as StarletteRoute +from starlette.types import Receive, Scope, Send + +from ._responses import redirect, rewrite +from .matchers import Condition + +ProxyResult: TypeAlias = Response | None +Endpoint: TypeAlias = Callable[[Request], ProxyResult | Awaitable[ProxyResult]] + +_PARAMETER_PATTERN = re.compile(r"{([a-zA-Z_][a-zA-Z0-9_]*)(?::[^}]+)?}") +_DESTINATION_PARAMETER_PATTERN = re.compile(r"{([a-zA-Z_][a-zA-Z0-9_]*)}") + + +class _MatcherEndpoint: + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + raise RuntimeError("route matcher was invoked as an ASGI application") + + +def _is_async_callable(function: Callable[..., Any]) -> bool: + candidate: Any = function + while isinstance(candidate, partial): + candidate = candidate.func + if inspect.iscoroutinefunction(candidate): + return True + return callable(candidate) and inspect.iscoroutinefunction(candidate.__call__) + + +def _render_destination(destination: str, path_params: Mapping[str, Any]) -> str: + def replace(match: re.Match[str]) -> str: + name = match.group(1) + if name not in path_params: + raise ValueError(f"rewrite destination references unknown route parameter {name!r}") + return str(path_params[name]) + + return _DESTINATION_PARAMETER_PATTERN.sub(replace, destination) + + +class Route: + """A path route that can select a Python routing middleware handler.""" + + def __init__( + self, + path: str, + endpoint: Endpoint, + *, + methods: Collection[str] | None = None, + has: Sequence[Condition] = (), + missing: Sequence[Condition] = (), + ) -> None: + if not callable(endpoint): + raise TypeError("route endpoint must be callable") + self.path = path + self.endpoint = endpoint + self.has = tuple(has) + self.missing = tuple(missing) + if not all(isinstance(condition, Condition) for condition in (*self.has, *self.missing)): + raise TypeError("route conditions must be vercel.proxy.matchers.Condition instances") + + path_parameters = frozenset(_PARAMETER_PATTERN.findall(path)) + condition_parameters: set[str] = set() + for condition in self.has: + duplicate = condition_parameters.intersection(condition.parameter_names) + if duplicate: + names = ", ".join(sorted(duplicate)) + raise ValueError(f"duplicate condition route parameter: {names}") + condition_parameters.update(condition.parameter_names) + + path_duplicate = path_parameters.intersection(condition_parameters) + if path_duplicate: + names = ", ".join(sorted(path_duplicate)) + raise ValueError(f"host and path route parameters overlap: {names}") + self.parameter_names = path_parameters.union(condition_parameters) + + self._matcher = StarletteRoute( + path, + _MatcherEndpoint(), + methods=methods, + ) + + @classmethod + def rewrite( + cls, + path: str, + destination: str | URL, + *, + methods: Collection[str] | None = None, + has: Sequence[Condition] = (), + missing: Sequence[Condition] = (), + headers: Mapping[str, str] | None = None, + request_headers: Mapping[str, str] | None = None, + ) -> Route: + """Create a route that rewrites to a destination template.""" + destination_template = str(destination) + + async def endpoint(request: Request) -> Response: + return rewrite( + _render_destination(destination_template, request.path_params), + headers=headers, + request_headers=request_headers, + ) + + route = cls(path, endpoint, methods=methods, has=has, missing=missing) + unknown = set(_DESTINATION_PARAMETER_PATTERN.findall(destination_template)).difference( + route.parameter_names + ) + if unknown: + names = ", ".join(sorted(unknown)) + raise ValueError(f"rewrite destination references unknown route parameter: {names}") + return route + + @classmethod + def redirect( + cls, + path: str, + destination: str | URL, + *, + status_code: int = 307, + methods: Collection[str] | None = None, + has: Sequence[Condition] = (), + missing: Sequence[Condition] = (), + headers: Mapping[str, str] | None = None, + ) -> Route: + """Create a route that redirects to a destination template.""" + destination_template = str(destination) + + async def endpoint(request: Request) -> Response: + return redirect( + _render_destination(destination_template, request.path_params), + status_code=status_code, + headers=headers, + ) + + route = cls(path, endpoint, methods=methods, has=has, missing=missing) + unknown = set(_DESTINATION_PARAMETER_PATTERN.findall(destination_template)).difference( + route.parameter_names + ) + if unknown: + names = ", ".join(sorted(unknown)) + raise ValueError(f"redirect destination references unknown route parameter: {names}") + return route + + def match(self, request: Request) -> Mapping[str, Any] | None: + result, child_scope = self._matcher.matches(request.scope) + if result is not Match.FULL: + return None + + path_params = dict(child_scope.get("path_params", {})) + for condition in self.has: + matches, captured = condition.match(request) + if not matches: + return None + for name, value in captured.items(): + if name in path_params: + raise ValueError(f"duplicate route parameter {name!r}") + path_params[name] = value + + for condition in self.missing: + matches, _ = condition.match(request) + if matches: + return None + + return {**child_scope, "path_params": path_params} + + async def handle(self, request: Request) -> ProxyResult: + if _is_async_callable(self.endpoint): + result = self.endpoint(request) + else: + result = await run_in_threadpool(self.endpoint, request) + if inspect.isawaitable(result): + return cast(ProxyResult, await result) + return result diff --git a/src/vercel-proxy/vercel/proxy/matchers.py b/src/vercel-proxy/vercel/proxy/matchers.py new file mode 100644 index 00000000..e1b1291c --- /dev/null +++ b/src/vercel-proxy/vercel/proxy/matchers.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import re +from abc import ABC, abstractmethod +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +from starlette.datastructures import MutableHeaders +from starlette.requests import Request +from starlette.routing import Host, Match +from starlette.types import Receive, Scope, Send + +__all__ = ["Condition", "cookie", "header", "host", "query"] + +_PARAMETER_PATTERN = re.compile(r"{([a-zA-Z_][a-zA-Z0-9_]*)(?::[^}]+)?}") + + +async def _unreachable_app(scope: Scope, receive: Receive, send: Send) -> None: + raise RuntimeError("route matcher was invoked as an ASGI application") + + +class Condition(ABC): + """A request condition used by :class:`vercel.proxy.Route`.""" + + parameter_names: frozenset[str] = frozenset() + + @abstractmethod + def match(self, request: Request) -> tuple[bool, Mapping[str, Any]]: + """Return whether the request matches and any captured parameters.""" + + +@dataclass(frozen=True) +class _ValueCondition(Condition): + source: str + name: str + value: str | None + + def match(self, request: Request) -> tuple[bool, Mapping[str, Any]]: + if self.source == "header": + values = request.headers.getlist(self.name) + elif self.source == "query": + values = request.query_params.getlist(self.name) + else: + cookies = request.cookies + values = [cookies[self.name]] if self.name in cookies else [] + + matches = bool(values) if self.value is None else self.value in values + return matches, {} + + +@dataclass(frozen=True) +class _HostCondition(Condition): + pattern: str + parameter_names: frozenset[str] = field(init=False) + _matcher: Host = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + normalized_pattern = self.pattern.lower() + if not normalized_pattern: + raise ValueError("host pattern must not be empty") + object.__setattr__(self, "pattern", normalized_pattern) + object.__setattr__( + self, + "parameter_names", + frozenset(_PARAMETER_PATTERN.findall(normalized_pattern)), + ) + object.__setattr__(self, "_matcher", Host(normalized_pattern, _unreachable_app)) + + def match(self, request: Request) -> tuple[bool, Mapping[str, Any]]: + scope = dict(request.scope) + scope["headers"] = list(request.scope.get("headers", [])) + normalized_headers = MutableHeaders(scope=scope) + normalized_headers["host"] = request.url.hostname or "" + result, child_scope = self._matcher.matches(scope) + return result is Match.FULL, child_scope.get("path_params", {}) + + +def _value_condition(source: str, name: str, value: str | None) -> Condition: + if not name: + raise ValueError(f"{source} name must not be empty") + return _ValueCondition(source, name, value) + + +def header(name: str, value: str | None = None) -> Condition: + """Match a request header by presence or exact value.""" + return _value_condition("header", name, value) + + +def cookie(name: str, value: str | None = None) -> Condition: + """Match a cookie by presence or exact value.""" + return _value_condition("cookie", name, value) + + +def query(name: str, value: str | None = None) -> Condition: + """Match a query parameter by presence or exact value.""" + return _value_condition("query", name, value) + + +def host(pattern: str) -> Condition: + """Match a hostname, optionally capturing ``{parameters}``.""" + return _HostCondition(pattern) diff --git a/src/vercel-proxy/vercel/proxy/py.typed b/src/vercel-proxy/vercel/proxy/py.typed new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/vercel-proxy/vercel/proxy/py.typed @@ -0,0 +1 @@ + diff --git a/src/vercel-proxy/vercel/proxy/version.py b/src/vercel-proxy/vercel/proxy/version.py new file mode 100644 index 00000000..6c8e6b97 --- /dev/null +++ b/src/vercel-proxy/vercel/proxy/version.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/src/vercel/README.md b/src/vercel/README.md index 2987a84e..0ce5b6b6 100644 --- a/src/vercel/README.md +++ b/src/vercel/README.md @@ -12,6 +12,7 @@ helpers. - `vercel.functions` - convenience exports for function code - `vercel.oidc` - OIDC token lookup, refresh, credentials, and payload decoding - `vercel.projects` - project list, create, update, and delete helpers +- `vercel.proxy` - pre-cache Python routing middleware, rewrites, and redirects - `vercel.sandbox` - Sandbox creation, commands, files, snapshots, and PTY support - `vercel.workflow` - Workflows, steps, sleeps, hooks, and run startup - `vercel.client` - `AsyncVercel` and `Vercel` grouped clients diff --git a/src/vercel/pyproject.toml b/src/vercel/pyproject.toml index 14f083fb..149a2e0f 100644 --- a/src/vercel/pyproject.toml +++ b/src/vercel/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "vercel-cache>=0.6.0", "vercel-headers>=0.6.0", "vercel-oidc>=0.6.0", + "vercel-proxy>=0.1.0", "vercel-internal-telemetry>=0.6.0", ] @@ -40,6 +41,7 @@ vercel-cache = { workspace = true } vercel-headers = { workspace = true } vercel-internal-telemetry = { workspace = true } vercel-oidc = { workspace = true } +vercel-proxy = { workspace = true } [tool.hatch.version] path = "version.py" diff --git a/tests/unit/test_release_system.py b/tests/unit/test_release_system.py index f444babc..fedfcb92 100644 --- a/tests/unit/test_release_system.py +++ b/tests/unit/test_release_system.py @@ -1104,6 +1104,31 @@ def test_dramatiq_bundle_keeps_dramatiq_peer_dependency( ) +def test_proxy_bundle_keeps_starlette_as_a_peer_dependency( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(workspace, "packages", lambda: {}) + data = { + "tool": { + "vercel": { + "release": { + "dependencies": ["starlette>=0.46.0,<2"], + } + } + } + } + + assert ( + bundle_release._derive_vendor_requirements( # noqa: SLF001 + "vercel-proxy", data + ) + == () + ) + assert bundle_release._external_dependencies( # noqa: SLF001 + "vercel-proxy", data, () + ) == ("starlette>=0.46.0,<2",) + + def test_vendored_requirements_are_derived_from_release_deps_and_lock( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/uv.lock b/uv.lock index 5c4161b4..143dfc00 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ members = [ "vercel-headers", "vercel-internal-telemetry", "vercel-oidc", + "vercel-proxy", "vercel-queue", ] @@ -2402,6 +2403,7 @@ dependencies = [ { name = "vercel-headers" }, { name = "vercel-internal-telemetry" }, { name = "vercel-oidc" }, + { name = "vercel-proxy" }, { name = "vercel-workers", marker = "python_full_version >= '3.12'" }, { name = "websockets" }, ] @@ -2418,6 +2420,7 @@ requires-dist = [ { name = "vercel-headers", editable = "src/vercel-headers" }, { name = "vercel-internal-telemetry", editable = "src/vercel-internal-telemetry" }, { name = "vercel-oidc", editable = "src/vercel-oidc" }, + { name = "vercel-proxy", editable = "src/vercel-proxy" }, { name = "vercel-workers", marker = "python_full_version >= '3.12'", specifier = ">=0.0.16,<1" }, { name = "websockets", specifier = ">=12.0,<17" }, ] @@ -2502,6 +2505,16 @@ requires-dist = [ { name = "vercel-headers", editable = "src/vercel-headers" }, ] +[[package]] +name = "vercel-proxy" +source = { editable = "src/vercel-proxy" } +dependencies = [ + { name = "starlette" }, +] + +[package.metadata] +requires-dist = [{ name = "starlette", specifier = ">=0.46.0,<2" }] + [[package]] name = "vercel-queue" source = { editable = "src/vercel-queue" }