diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 4348e599..f42d3664 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -112,6 +112,7 @@ def _run( entrypoint: str | None = None, proxy_headers: bool = False, forwarded_allow_ips: str | None = None, + public_url: str | None = None, ) -> None: with get_rich_toolkit() as toolkit: server_type = "development" if command == "dev" else "production" @@ -189,7 +190,7 @@ def _run( tag="app", ) - url = f"http://{host}:{port}" + url = public_url.rstrip("/") if public_url else f"http://{host}:{port}" url_docs = f"{url}/docs" toolkit.print_line() @@ -299,6 +300,13 @@ def dev( help="Comma separated list of IP Addresses to trust with proxy headers. The literal '*' means trust everything." ), ] = None, + public_url: Annotated[ + str | None, + typer.Option( + help="The public URL where the server is accessible. Used for the URLs printed at startup. Defaults to [blue]http://HOST:PORT[/blue] from [bold]--host[/bold] and [bold]--port[/bold].", + envvar="FASTAPI_PUBLIC_URL", + ), + ] = None, ) -> Any: """ Run a [bold]FastAPI[/bold] app in [yellow]development[/yellow] mode. ๐Ÿงช @@ -337,6 +345,7 @@ def dev( command="dev", proxy_headers=proxy_headers, forwarded_allow_ips=forwarded_allow_ips, + public_url=public_url, ) @@ -406,6 +415,13 @@ def run( help="Comma separated list of IP Addresses to trust with proxy headers. The literal '*' means trust everything." ), ] = None, + public_url: Annotated[ + str | None, + typer.Option( + help="The public URL where the server is accessible. Used for the URLs printed at startup. By default, the printed URLs use the configured host and port.", + envvar="FASTAPI_PUBLIC_URL", + ), + ] = None, ) -> Any: """ Run a [bold]FastAPI[/bold] app in [green]production[/green] mode. ๐Ÿš€ @@ -444,6 +460,7 @@ def run( command="run", proxy_headers=proxy_headers, forwarded_allow_ips=forwarded_allow_ips, + public_url=public_url, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index ddfb808b..7db61d66 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest.mock import patch +import pytest import uvicorn from typer.testing import CliRunner @@ -391,6 +392,103 @@ def test_run_env_vars_and_args() -> None: assert "Documentation at http://0.0.0.0:8080/docs" in result.output +@pytest.mark.parametrize("command", ["dev", "run"]) +@pytest.mark.parametrize( + "public_url", + [ + "https://myapp.example.com", + "https://myapp.example.com/", + "https://myapp.example.com/subpath/", + ], +) +def test_public_url(command: str, public_url: str) -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, + [ + command, + "single_file_app.py", + "--host", + "0.0.0.0", + "--public-url", + public_url, + ], + ) + assert result.exit_code == 0, result.output + assert mock_run.called + assert mock_run.call_args + assert mock_run.call_args.kwargs == { + "app": "single_file_app:app", + "host": "0.0.0.0", + "port": 8000, + "reload": True if command == "dev" else False, + "reload_dirs": None, + "workers": None, + "root_path": "", + "proxy_headers": True, + "forwarded_allow_ips": None, + "log_config": get_uvicorn_log_config(), + } + + assert "Using import string: single_file_app:app" in result.output + assert ( + f"Starting {'development' if command == 'dev' else 'production'} server ๐Ÿš€" + in result.output + ) + expected_url_base = public_url.rstrip("/") + assert f"Server started at {expected_url_base}" in result.output + assert f"Documentation at {expected_url_base}/docs" in result.output + + +@pytest.mark.parametrize("command", ["dev", "run"]) +@pytest.mark.parametrize( + "public_url", + [ + "https://myapp.example.com", + "https://myapp.example.com/", + "https://myapp.example.com/subpath/", + ], +) +def test_public_url_env_var(command: str, public_url: str) -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, + [ + command, + "single_file_app.py", + "--host", + "0.0.0.0", + ], + env={"FASTAPI_PUBLIC_URL": public_url}, + ) + assert result.exit_code == 0, result.output + assert mock_run.called + assert mock_run.call_args + assert mock_run.call_args.kwargs == { + "app": "single_file_app:app", + "host": "0.0.0.0", + "port": 8000, + "reload": True if command == "dev" else False, + "reload_dirs": None, + "workers": None, + "root_path": "", + "proxy_headers": True, + "forwarded_allow_ips": None, + "log_config": get_uvicorn_log_config(), + } + + assert "Using import string: single_file_app:app" in result.output + assert ( + f"Starting {'development' if command == 'dev' else 'production'} server ๐Ÿš€" + in result.output + ) + expected_url_base = public_url.rstrip("/") + assert f"Server started at {expected_url_base}" in result.output + assert f"Documentation at {expected_url_base}/docs" in result.output + + def test_run_error() -> None: with changing_dir(assets_path): result = runner.invoke(app, ["run", "non_existing_file.py"]) @@ -417,6 +515,7 @@ def test_dev_help() -> None: assert "Set reload directories explicitly" in result.output assert "The root path is used to tell your app" in result.output assert "The name of the variable that contains the FastAPI app" in result.output + assert "The public URL where the server is accessible" in result.output assert "Use multiple worker processes." not in result.output @@ -438,6 +537,7 @@ def test_run_help() -> None: assert "Enable auto-reload of the server when (code) files change." in result.output assert "The root path is used to tell your app" in result.output assert "The name of the variable that contains the FastAPI app" in result.output + assert "The public URL where the server is accessible" in result.output assert "Use multiple worker processes." in result.output