diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 4348e599..8602d269 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -8,7 +8,12 @@ from rich.tree import Tree from fastapi_cli.config import FastAPIConfig -from fastapi_cli.discover import get_import_data, get_import_data_from_import_string +from fastapi_cli.discover import ( + get_docs_urls, + get_import_data, + get_import_data_from_import_string, + get_root_path, +) from fastapi_cli.exceptions import FastAPICLIException from . import __version__ @@ -190,15 +195,27 @@ def _run( ) url = f"http://{host}:{port}" - url_docs = f"{url}/docs" + + use_root_path = root_path or get_root_path(import_data) + if use_root_path: + url += use_root_path toolkit.print_line() toolkit.print( f"Server started at [link={url}]{url}[/]", - f"Documentation at [link={url_docs}]{url_docs}[/]", tag="server", ) + docs_urls = get_docs_urls(import_data) + docs_links = [ + f"[link={url}{docs_url}]{url}{docs_url}[/]" for docs_url in docs_urls + ] + if docs_links: + toolkit.print( + f"Documentation at {docs_links[0]}", + tag="server", + ) + if command == "dev": toolkit.print_line() toolkit.print( diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 3d49f72d..6fbb8d95 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -152,3 +152,33 @@ def get_import_data_from_import_string(import_string: str) -> ImportData: ), import_string=import_string, ) + + +def get_docs_urls(import_data: ImportData) -> list[str]: + module = importlib.import_module(import_data.module_data.module_import_str) + app_name = import_data.app_name + app = getattr(module, app_name) + + # Just for type checking, it's already checked in get_app_name + assert FastAPI is not None + assert isinstance(app, FastAPI) + + docs_urls: list[str] = [] + if app.openapi_url and app.docs_url: + docs_urls.append(app.docs_url) + if app.openapi_url and app.redoc_url: + docs_urls.append(app.redoc_url) + + return docs_urls + + +def get_root_path(import_data: ImportData) -> str: + module = importlib.import_module(import_data.module_data.module_import_str) + app_name = import_data.app_name + app = getattr(module, app_name) + + # Just for type checking, it's already checked in get_app_name + assert FastAPI is not None + assert isinstance(app, FastAPI) + + return app.root_path diff --git a/tests/assets/single_file_docs.py b/tests/assets/single_file_docs.py new file mode 100644 index 00000000..6686b8cd --- /dev/null +++ b/tests/assets/single_file_docs.py @@ -0,0 +1,99 @@ +from fastapi import FastAPI + +# App 1: API documentation disabled via `openapi_url=None` +# ------------------------------------------------------------------------------------ + +openapi_none = FastAPI(openapi_url=None) + + +@openapi_none.get("/") +def openapi_none_root(): + return {"message": "single file openapi_none"} + + +# App 2: Both docs and redoc disabled via `docs_url=None` and `redoc_url=None` +# ------------------------------------------------------------------------------------ + +docs_none_redoc_none = FastAPI(docs_url=None, redoc_url=None) + + +@docs_none_redoc_none.get("/") +def docs_none_redoc_none_root(): + return {"message": "single file docs_none_redoc_none"} + + +# App 3: Only ReDoc. Swagger docs disabled via `docs_url=None` +# ------------------------------------------------------------------------------------ + +only_redoc = FastAPI(docs_url=None) + + +@only_redoc.get("/") +def only_redoc_root(): + return {"message": "single file only_redoc"} + + +# App 4: Only Swagger docs. ReDoc disabled via `redoc_url=None` +# ------------------------------------------------------------------------------------ + +only_docs = FastAPI(redoc_url=None) + + +@only_docs.get("/") +def only_docs_root(): + return {"message": "single file only_docs"} + + +# App 5: Both docs and redoc enabled with default URLs +# ------------------------------------------------------------------------------------ + +full_docs = FastAPI() + + +@full_docs.get("/") +def full_docs_root(): + return {"message": "single file full_docs"} + + +# App 6: Swagger docs with custom URL. ReDoc with default URL. +# ------------------------------------------------------------------------------------ + +custom_docs = FastAPI(docs_url="/custom-docs-url") + + +@custom_docs.get("/") +def custom_docs_root(): + return {"message": "single file custom_docs"} + + +# App 7: ReDoc with custom URL. Swagger docs with default URL. +# ------------------------------------------------------------------------------------ + +custom_redoc = FastAPI(docs_url=None, redoc_url="/custom-redoc-url") + + +@custom_redoc.get("/") +def custom_redoc_root(): + return {"message": "single file custom_redoc"} + + +# App 8: Swagger docs enabled, root_path set to "/api". ReDoc disabled. +# ------------------------------------------------------------------------------------ + +docs_root_path = FastAPI(redoc_url=None, root_path="/api") + + +@docs_root_path.get("/") +def docs_root_path_root(): + return {"message": "single file docs_root_path"} + + +# App 9: ReDoc enabled, root_path set to "/api". Swagger docs disabled. +# ------------------------------------------------------------------------------------ + +redoc_root_path = FastAPI(docs_url=None, root_path="/api") + + +@redoc_root_path.get("/") +def redoc_root_path_root(): + return {"message": "single file redoc_root_path"} diff --git a/tests/test_cli.py b/tests/test_cli.py index ddfb808b..d710a3dd 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 @@ -132,8 +133,8 @@ def test_dev_args() -> None: } assert "Using import string: single_file_app:api" in result.output assert "Starting development server 🚀" in result.output - assert "Server started at http://192.168.0.2:8080" in result.output - assert "Documentation at http://192.168.0.2:8080/docs" in result.output + assert "Server started at http://192.168.0.2:8080/api" in result.output + assert "Documentation at http://192.168.0.2:8080/api/docs" in result.output assert ( "Running in development mode, for production use: fastapi run" in result.output @@ -322,8 +323,8 @@ def test_run_args() -> None: assert "Using import string: single_file_app:api" in result.output assert "Starting production server 🚀" in result.output - assert "Server started at http://192.168.0.2:8080" in result.output - assert "Documentation at http://192.168.0.2:8080/docs" in result.output + assert "Server started at http://192.168.0.2:8080/api" in result.output + assert "Documentation at http://192.168.0.2:8080/api/docs" in result.output assert ( "Running in development mode, for production use: fastapi run" not in result.output @@ -391,6 +392,141 @@ def test_run_env_vars_and_args() -> None: assert "Documentation at http://0.0.0.0:8080/docs" in result.output +@pytest.mark.parametrize( + "app_name", + [ + "openapi_none", + "docs_none_redoc_none", + ], +) +def test_docs_urls_disabled(app_name: str) -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_docs.py", "--app", app_name] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert "http://127.0.0.1:8000/docs" not in result.output + assert "http://127.0.0.1:8000/redoc" not in result.output + + +def test_docs_urls_only_docs() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_docs.py", "--app", "only_docs"] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert "http://127.0.0.1:8000/docs" in result.output + assert "http://127.0.0.1:8000/redoc" not in result.output + + +def test_docs_urls_only_redoc() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_docs.py", "--app", "only_redoc"] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert "http://127.0.0.1:8000/redoc" in result.output + assert "http://127.0.0.1:8000/docs" not in result.output + + +def test_docs_urls_full_docs() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_docs.py", "--app", "full_docs"] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert "http://127.0.0.1:8000/docs" in result.output + assert "http://127.0.0.1:8000/redoc" not in result.output # docs has precedence + + +def test_docs_urls_custom_docs() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_docs.py", "--app", "custom_docs"] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert "http://127.0.0.1:8000/custom-docs-url" in result.output + + +def test_docs_urls_custom_redoc() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_docs.py", "--app", "custom_redoc"] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert "http://127.0.0.1:8000/custom-redoc-url" in result.output + + +@pytest.mark.parametrize( + ("app_name", "expected_url"), + [ + ("only_docs", "http://127.0.0.1:8000/api/docs"), + ("only_redoc", "http://127.0.0.1:8000/api/redoc"), + ], +) +def test_docs_urls_root_path_option(app_name: str, expected_url: str) -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, + [ + "dev", + "single_file_docs.py", + "--app", + app_name, + "--root-path", + "/api", + ], + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert expected_url in result.output + + +@pytest.mark.parametrize( + ("app_name", "expected_url"), + [ + ("docs_root_path", "http://127.0.0.1:8000/api/docs"), + ("redoc_root_path", "http://127.0.0.1:8000/api/redoc"), + ], +) +def test_docs_urls_root_path_param(app_name: str, expected_url: str) -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, + [ + "dev", + "single_file_docs.py", + "--app", + app_name, + ], + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert expected_url in result.output + + def test_run_error() -> None: with changing_dir(assets_path): result = runner.invoke(app, ["run", "non_existing_file.py"]) diff --git a/tests/utils.py b/tests/utils.py index c15ed32f..a8d4274f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,11 +2,10 @@ from collections.abc import Generator from contextlib import contextmanager from pathlib import Path -from typing import Union @contextmanager -def changing_dir(directory: Union[str, Path]) -> Generator[None, None, None]: +def changing_dir(directory: str | Path) -> Generator[None, None, None]: initial_dir = os.getcwd() os.chdir(directory) try: