Metadata-Version: 2.5
Name: vercel-proxy
Version: 0.1.0
Summary: Proxy routing API for Vercel Python middleware
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.10
Requires-Dist: starlette<2,>=0.33
Description-Content-Type: text/markdown

# 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 urllib.parse import quote

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

proxy = Proxy()


@proxy.route("/old-blog/{slug}")
def old_blog(request: Request) -> Response:
    slug = quote(request.path_params["slug"])
    return RedirectResponse(f"/blog/{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"]
    path = quote(request.path_params["path"])
    return RewriteResponse(f"https://{tenant}.api.example.com/{path}")
```

## Setup

Create a proxy module, such as `proxy.py`, and set the proxy in `vercel.json` as
`module:attribute`. A module in a package works too, such as `app.proxy:proxy`.

```json
{
  "proxy": { "entrypoint": "proxy:proxy" }
}
```

By default the proxy runs for every request. To run it only for some paths, add
a `matcher` with one path or a list of paths.

```json
{
  "proxy": { "entrypoint": "proxy:proxy", "matcher": ["/api/:path*", "/admin"] }
}
```

The proxy runs separately from your app and gets only the packages in the
`vercel.proxy` dependency group of `pyproject.toml`.

```toml
[dependency-groups]
"vercel.proxy" = ["vercel-proxy"]
```

## 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`. They are decoded, so quote them
with `urllib.parse.quote` before building a URL from them, and check any value
that chooses the destination, such as a host or a path containing `..`.

```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. Each request is first matched against
every route as sent, and only then with the trailing slash added or removed. So
with `/users/{id}` registered before `/{path:path}`, a request for `/users/42/`
goes to `/{path:path}`, which matches it exactly. 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

Every handler returns a `Response`. Responses are based on Starlette's
[responses](https://starlette.dev/responses/), so `headers`, `set_cookie` and
`delete_cookie` work as in Starlette.

```python
response = ContinueResponse()
response.set_cookie("bucket", "b", max_age=86400)
return response
```

Header names starting with `x-middleware-` are reserved. Background tasks are
not supported.

### Continuing the request

`ContinueResponse()` lets the request continue to the original URL.
`RewriteResponse(url)` serves it 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})
```

### Answering the request

`Response`, `JSONResponse`, `PlainTextResponse`, `HTMLResponse` and
`RedirectResponse` answer the request directly, like the Starlette classes of
the same name. The request does not reach your app.
