Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/vercel-proxy/proxy.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `vercel.proxy`, a routing API for Vercel Python middleware.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ mypy_path = [
"src/vercel-oidc",
"src/vercel-internal-core",
"src/vercel-internal-telemetry",
"src/vercel-proxy",
"src/vercel-sandbox",
"src/vercel-workflow",
"integrations/vercel-apscheduler",
Expand Down
3 changes: 3 additions & 0 deletions scripts/bundle_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@
UNBUNDLED_PACKAGES = {
# Workflow is not vendored by anything yet, so we don't need its -bundle
"vercel-workflow",
# Proxy is only used as an explicit dependency of user code, so it has no
# need for a -bundle.
"vercel-proxy",
}
COMMON_DROP_TRANSFORMATIONS = (
"*.so",
Expand Down
1 change: 1 addition & 0 deletions src/vercel-proxy/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
21 changes: 21 additions & 0 deletions src/vercel-proxy/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 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.
134 changes: 134 additions & 0 deletions src/vercel-proxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# vercel-proxy

`vercel.proxy` lets you write Vercel middleware in Python. It runs before each
request reaches your app and decides whether to let it continue, rewrite it,
redirect it or answer it directly.

```python
from vercel.proxy import (
ContinueResponse,
Proxy,
RedirectResponse,
Request,
Response,
RewriteResponse,
)

proxy = Proxy()


@proxy.route("/old-blog/{slug}")
def old_blog(request: Request) -> Response:
return RedirectResponse(f"/blog/{request.path_params['slug']}", status_code=308)


@proxy.route("/dashboard/{path:path}")
def dashboard(request: Request) -> Response:
if "session" not in request.cookies:
return RedirectResponse("/login")
return ContinueResponse(request_headers={"x-user": request.cookies["session"]})


@proxy.route("/api/{path:path}", host="{tenant}.example.com")
async def tenant_api(request: Request) -> Response:
tenant = request.path_params["tenant"]
return RewriteResponse(f"https://{tenant}.api.example.com/{request.path_params['path']}")
```

## Routes

`@proxy.route(path)` registers a handler for requests whose path matches. Each
request goes to the first matching route, in the order the routes were
registered.

A path can capture parts of the URL with placeholders. The captured values are
passed to the handler in `request.path_params`.

```python
@proxy.route("/users/{user_id:int}")
def user(request: Request) -> Response:
return RewriteResponse(f"/profiles/{request.path_params['user_id']}")
```

The following placeholders are available:

- `{name}` captures one path segment as a string.
- `{name:type}` also converts the value. The type can be `str`, `int`, `float`
or `uuid`.
- `{name:path}` captures any part of the path, including `/`.

Trailing slashes are optional by default, but a route that matches the path
exactly is preferred. Pass `strict=True` to `Proxy()` to require exact matches.

A route can also be limited to certain methods or hosts.

```python
@proxy.route("/admin", methods=["GET", "POST"], host="admin.example.com")
def admin(request: Request) -> Response:
return RewriteResponse("/internal/admin")
```

- `methods` lists the HTTP methods the route accepts. Omit it to accept every
method.
- `host` is the hostname the route accepts. It can capture values like the
path. Omit it to accept every host.

Handlers can be sync or async. Sync handlers run in a worker thread.

## Fallback

Requests that match no route go to the fallback. By default they continue
unchanged.

Pass a fallback to the constructor to change it. It can be a response, which
is returned for every unmatched request, or a handler.

```python
proxy = Proxy(fallback=JSONResponse({"error": "not found"}, status_code=404))
```

The fallback can also be set with the `@proxy.fallback` decorator. It replaces
any fallback passed to the constructor.

```python
@proxy.fallback
def fallback(request: Request) -> Response:
return RewriteResponse("/404")
```

## Requests

`Request` is a Starlette [`Request`](https://starlette.dev/requests/). Use `method`, `url`, `headers`,
`query_params`, `cookies`, `path_params` and `client` to decide how to route
it.

WebSocket upgrades are routed like `GET` requests, and the destination accepts
the socket. Check `request.headers.get("upgrade")` to tell them apart.

## Responses

A handler returns a `ContinueResponse` or `RewriteResponse` to let the request
continue, or another response to answer it directly.

`ContinueResponse()` continues to the original URL. `RewriteResponse(url)`
serves the request from `url` without changing the URL the client sees.

For both, `request_headers` is a dictionary of headers to replace on the
forwarded request. A `None` value removes the header.

```python
return ContinueResponse(request_headers={"x-region": "eu", "cookie": None})
```

`Response`, `JSONResponse`, `PlainTextResponse`, `HTMLResponse` and
`RedirectResponse` answer the request directly. They are the Starlette
[responses](https://starlette.dev/responses/) of the same name.

Every response has `headers`, `set_cookie` and `delete_cookie`, which work like
Starlette's.

```python
response = ContinueResponse()
response.set_cookie("bucket", "b", max_age=86400)
return response
```
29 changes: 29 additions & 0 deletions src/vercel-proxy/hatch_build.py
Original file line number Diff line number Diff line change
@@ -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")
53 changes: 53 additions & 0 deletions src/vercel-proxy/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
[build-system]
requires = ["hatchling>=1.27.0,<2"]
build-backend = "hatchling.build"

[project]
name = "vercel-proxy"
dynamic = ["version", "dependencies"]
description = "Proxy routing API for Vercel Python middleware"
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.33,<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.poe.tasks.typecheck-mypy]
cmd = "$MYPY --strict vercel tests"
Loading
Loading