diff --git a/docs/deployment.md b/docs/deployment.md index d69fcf88e..940be51a2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -99,8 +99,12 @@ Options: to the $FORWARDED_ALLOW_IPS environment variable if available, or '127.0.0.1'. The literal '*' means trust everything. - --root-path TEXT Set the ASGI 'root_path' for applications - submounted below a given URL path. + --root-path TEXT Serve the application under the provided + root path. + --asgi-root-path TEXT Set the ASGI 'root_path' for applications + submounted below a given URL path. This is + useful for applications served on a sub-URL, + such as behind a reverse proxy. --limit-concurrency INTEGER Maximum number of concurrent connections or tasks to allow, before issuing HTTP 503 responses. diff --git a/docs/index.md b/docs/index.md index bb6fc321a..e5a19c3a6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -169,8 +169,12 @@ Options: to the $FORWARDED_ALLOW_IPS environment variable if available, or '127.0.0.1'. The literal '*' means trust everything. - --root-path TEXT Set the ASGI 'root_path' for applications - submounted below a given URL path. + --root-path TEXT Serve the application under the provided + root path. + --asgi-root-path TEXT Set the ASGI 'root_path' for applications + submounted below a given URL path. This is + useful for applications served on a sub-URL, + such as behind a reverse proxy. --limit-concurrency INTEGER Maximum number of concurrent connections or tasks to allow, before issuing HTTP 503 responses. diff --git a/docs/settings.md b/docs/settings.md index a4439c3d0..0c063d146 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -87,7 +87,8 @@ Note that WSGI mode always disables WebSocket support, as it is not supported by ## HTTP -* `--root-path ` - Set the ASGI `root_path` for applications submounted below a given URL path. +* `--root-path ` - Serve the application under the provided root path (mount point). +* `--asgi-root-path ` - Set the ASGI 'root_path' for applications served behind a proxy in a subpath (mutually exclusive with `root-path`) * `--proxy-headers` / `--no-proxy-headers` - Enable/Disable X-Forwarded-Proto, X-Forwarded-For to populate remote address info. Defaults to enabled, but is restricted to only trusting connecting IPs in the `forwarded-allow-ips` configuration. * `--forwarded-allow-ips` Comma separated list of IP Addresses, IP Networks, or literals (e.g. UNIX Socket path) to trust with proxy headers. Defaults to the `$FORWARDED_ALLOW_IPS` environment variable if available, or '127.0.0.1'. The literal `'*'` means trust everything. diff --git a/tests/protocols/test_http.py b/tests/protocols/test_http.py index b3b060b0f..254445dbe 100644 --- a/tests/protocols/test_http.py +++ b/tests/protocols/test_http.py @@ -687,6 +687,29 @@ async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable assert b"root_path=/app path=/app/" in protocol.transport.buffer +async def test_asgi_root_path(http_protocol_cls: HTTPProtocol): + async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable): + assert scope["type"] == "http" + root_path = scope.get("root_path", "") + path = scope["path"] + response = Response(f"root_path={root_path} path={path}", media_type="text/plain") + await response(scope, receive, send) + + protocol = get_connected_protocol(app, http_protocol_cls, asgi_root_path="/one") + protocol.data_received(GET_REQUEST_WITH_RAW_PATH) + await protocol.loop.run_one() + assert b"HTTP/1.1 200 OK" in protocol.transport.buffer + assert b"root_path=/one path=/one/two" in protocol.transport.buffer + + # This is a misconfiguration, but it helps us confirm that asgi_root_path + # doesn't prefix the path like root_path does. + protocol = get_connected_protocol(app, http_protocol_cls, asgi_root_path="/unrelated") + protocol.data_received(GET_REQUEST_WITH_RAW_PATH) + await protocol.loop.run_one() + assert b"HTTP/1.1 200 OK" in protocol.transport.buffer + assert b"root_path=/unrelated path=/one/two" in protocol.transport.buffer + + async def test_raw_path(http_protocol_cls: HTTPProtocol): async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable): assert scope["type"] == "http" diff --git a/uvicorn/config.py b/uvicorn/config.py index 65dfe651e..bf5f6c103 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -206,6 +206,7 @@ def __init__( date_header: bool = True, forwarded_allow_ips: list[str] | str | None = None, root_path: str = "", + asgi_root_path: str = "", limit_concurrency: int | None = None, limit_max_requests: int | None = None, backlog: int = 2048, @@ -250,6 +251,7 @@ def __init__( self.server_header = server_header self.date_header = date_header self.root_path = root_path + self.asgi_root_path = asgi_root_path or root_path self.limit_concurrency = limit_concurrency self.limit_max_requests = limit_max_requests self.backlog = backlog @@ -277,6 +279,9 @@ def __init__( self.reload_includes: list[str] = [] self.reload_excludes: list[str] = [] + if root_path and asgi_root_path: + logger.error("Setting both 'root_path' and 'asgi_root_path' is not supported.") + if (reload_dirs or reload_includes or reload_excludes) and not self.should_reload: logger.warning( "Current configuration will not reload as not all conditions are met, " "please refer to documentation." diff --git a/uvicorn/main.py b/uvicorn/main.py index 96a10d538..3213cdaac 100644 --- a/uvicorn/main.py +++ b/uvicorn/main.py @@ -249,7 +249,14 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No "--root-path", type=str, default="", - help="Set the ASGI 'root_path' for applications submounted below a given URL path.", + help="Serve the application under the provided root path.", +) +@click.option( + "--asgi-root-path", + type=str, + default="", + help="Set the ASGI 'root_path' for applications submounted below a given URL path. " + "This is useful for applications served on a sub-URL, such as behind a reverse proxy.", ) @click.option( "--limit-concurrency", @@ -391,6 +398,7 @@ def main( date_header: bool, forwarded_allow_ips: str, root_path: str, + asgi_root_path: str, limit_concurrency: int, backlog: int, limit_max_requests: int, @@ -440,6 +448,7 @@ def main( date_header=date_header, forwarded_allow_ips=forwarded_allow_ips, root_path=root_path, + asgi_root_path=asgi_root_path, limit_concurrency=limit_concurrency, backlog=backlog, limit_max_requests=limit_max_requests, @@ -492,6 +501,7 @@ def run( date_header: bool = True, forwarded_allow_ips: list[str] | str | None = None, root_path: str = "", + asgi_root_path: str = "", limit_concurrency: int | None = None, backlog: int = 2048, limit_max_requests: int | None = None, @@ -544,6 +554,7 @@ def run( date_header=date_header, forwarded_allow_ips=forwarded_allow_ips, root_path=root_path, + asgi_root_path=asgi_root_path, limit_concurrency=limit_concurrency, backlog=backlog, limit_max_requests=limit_max_requests, @@ -568,6 +579,10 @@ def run( logger.warning("You must pass the application as an import string to enable 'reload' or " "'workers'.") sys.exit(1) + if root_path and asgi_root_path: + # Config did already log an error. Just quit. + sys.exit(1) + try: if config.should_reload: sock = config.bind_socket() diff --git a/uvicorn/protocols/http/h11_impl.py b/uvicorn/protocols/http/h11_impl.py index 932ddf62b..6e7a020d1 100644 --- a/uvicorn/protocols/http/h11_impl.py +++ b/uvicorn/protocols/http/h11_impl.py @@ -60,6 +60,7 @@ def __init__( ) self.ws_protocol_class = config.ws_protocol_class self.root_path = config.root_path + self.asgi_root_path = config.asgi_root_path self.limit_concurrency = config.limit_concurrency self.app_state = app_state @@ -209,7 +210,7 @@ def handle_events(self) -> None: "client": self.client, "scheme": self.scheme, # type: ignore[typeddict-item] "method": event.method.decode("ascii"), - "root_path": self.root_path, + "root_path": self.asgi_root_path, "path": full_path, "raw_path": full_raw_path, "query_string": query_string, diff --git a/uvicorn/protocols/http/httptools_impl.py b/uvicorn/protocols/http/httptools_impl.py index 00f1fb720..28ff9ed7e 100644 --- a/uvicorn/protocols/http/httptools_impl.py +++ b/uvicorn/protocols/http/httptools_impl.py @@ -60,6 +60,7 @@ def __init__( self.parser = httptools.HttpRequestParser(self) self.ws_protocol_class = config.ws_protocol_class self.root_path = config.root_path + self.asgi_root_path = config.asgi_root_path self.limit_concurrency = config.limit_concurrency self.app_state = app_state @@ -219,7 +220,7 @@ def on_message_begin(self) -> None: "server": self.server, "client": self.client, "scheme": self.scheme, # type: ignore[typeddict-item] - "root_path": self.root_path, + "root_path": self.asgi_root_path, "headers": self.headers, "state": self.app_state.copy(), }