From 4d7462cebaa809ae1b46bfc72bae74ee8e5b9f96 Mon Sep 17 00:00:00 2001 From: pedroimpulcetto Date: Thu, 2 Oct 2025 11:30:23 -0300 Subject: [PATCH] add support to dynamic url docs based on the FastAPI docs_url param --- src/fastapi_cli/cli.py | 34 ++- .../single_file_app_with_url_docs_none.py | 8 + .../single_file_app_with_url_docs_set.py | 8 + ...ile_app_with_url_docs_set_and_root_path.py | 8 + tests/test_cli.py | 268 ++++++++++++++++++ 5 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 tests/assets/single_file_app_with_url_docs_none.py create mode 100644 tests/assets/single_file_app_with_url_docs_set.py create mode 100644 tests/assets/single_file_app_with_url_docs_set_and_root_path.py diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 28afa29..3bbc5dc 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -1,13 +1,19 @@ +import importlib import logging from pathlib import Path from typing import Any, List, Union import typer +from fastapi import FastAPI from rich import print from rich.tree import Tree from typing_extensions import Annotated -from fastapi_cli.discover import get_import_data, get_import_data_from_import_string +from fastapi_cli.discover import ( + ImportData, + get_import_data, + get_import_data_from_import_string, +) from fastapi_cli.exceptions import FastAPICLIException from . import __version__ @@ -66,6 +72,22 @@ def callback( setup_logging(level=log_level) +def _get_url_docs(import_data: ImportData) -> Union[str, None]: + """ + Get the FastAPI docs URL from the Uvicorn path. + + Args: + import_data: The ImportData object. + + Returns: + The FastAPI docs URL. + """ + module = importlib.import_module(import_data.module_data.module_import_str) + app_name = import_data.app_name + fastapi_app: FastAPI = getattr(module, app_name) + return fastapi_app.docs_url + + def _get_module_tree(module_paths: List[Path]) -> Tree: root = module_paths[0] name = f"🐍 {root.name}" if root.is_file() else f"📁 {root.name}" @@ -152,15 +174,21 @@ def _run( ) url = f"http://{host}:{port}" - url_docs = f"{url}/docs" + docs_path = _get_url_docs(import_data) + url_docs = f"{url}{docs_path}" if docs_path else None toolkit.print_line() toolkit.print( f"Server started at [link={url}]{url}[/]", - f"Documentation at [link={url_docs}]{url_docs}[/]", tag="server", ) + if docs_path: + toolkit.print( + f"Documentation at [link={url_docs}]{url_docs}[/]", + tag="server", + ) + if command == "dev": toolkit.print_line() toolkit.print( diff --git a/tests/assets/single_file_app_with_url_docs_none.py b/tests/assets/single_file_app_with_url_docs_none.py new file mode 100644 index 0000000..6d88e02 --- /dev/null +++ b/tests/assets/single_file_app_with_url_docs_none.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI(docs_url=None) + + +@app.get("/") +def api_root(): + return {"message": "my FastAPI app with no docs path"} diff --git a/tests/assets/single_file_app_with_url_docs_set.py b/tests/assets/single_file_app_with_url_docs_set.py new file mode 100644 index 0000000..13274b7 --- /dev/null +++ b/tests/assets/single_file_app_with_url_docs_set.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI(docs_url="/my-custom-docs-path") + + +@app.get("/") +def api_root(): + return {"message": "my FastAPI app with a custom docs path"} diff --git a/tests/assets/single_file_app_with_url_docs_set_and_root_path.py b/tests/assets/single_file_app_with_url_docs_set_and_root_path.py new file mode 100644 index 0000000..8ca564b --- /dev/null +++ b/tests/assets/single_file_app_with_url_docs_set_and_root_path.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI(docs_url="/my-custom-docs-path", root_path="/api/v1") + + +@app.get("/") +def api_root(): + return {"message": "my FastAPI app with a custom docs path and root path"} diff --git a/tests/test_cli.py b/tests/test_cli.py index b87a811..424cd38 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -460,3 +460,271 @@ def test_script() -> None: encoding="utf-8", ) assert "Usage" in result.stdout + + +def test_dev_and_fastapi_app_with_url_docs_set_should_show_correctly_url_in_stdout() -> ( + None +): + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke(app, ["dev", "single_file_app_with_url_docs_set.py"]) + 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_with_url_docs_set:app", + "forwarded_allow_ips": None, + "host": "127.0.0.1", + "port": 8000, + "reload": True, + "workers": None, + "root_path": "", + "proxy_headers": True, + "log_config": get_uvicorn_log_config(), + } + assert ( + "Using import string: single_file_app_with_url_docs_set:app" + in result.output + ) + assert "Starting development server 🚀" in result.output + assert "Server started at http://127.0.0.1:8000" in result.output + assert ( + "Documentation at http://127.0.0.1:8000/my-custom-docs-path" + in result.output + ) + + +def test_dev_and_fastapi_app_without_docs_url_set_should_show_default_url_in_stdout() -> ( + None +): + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke(app, ["dev", "single_file_app.py"]) + 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", + "forwarded_allow_ips": None, + "host": "127.0.0.1", + "port": 8000, + "reload": True, + "workers": None, + "root_path": "", + "proxy_headers": True, + "log_config": get_uvicorn_log_config(), + } + assert "Using import string: single_file_app:app" in result.output + assert "Starting development server 🚀" in result.output + assert "Server started at http://127.0.0.1:8000" in result.output + assert "Documentation at http://127.0.0.1:8000/docs" in result.output + + +def test_run_and_fastapi_app_with_url_docs_set_should_show_correctly_url_in_stdout() -> ( + None +): + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke(app, ["run", "single_file_app_with_url_docs_set.py"]) + 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_with_url_docs_set:app", + "forwarded_allow_ips": None, + "host": "0.0.0.0", + "port": 8000, + "reload": False, + "workers": None, + "root_path": "", + "proxy_headers": True, + "log_config": get_uvicorn_log_config(), + } + assert ( + "Using import string: single_file_app_with_url_docs_set:app" + in result.output + ) + assert "Starting production server 🚀" in result.output + assert "Server started at http://0.0.0.0:8000" in result.output + assert ( + "Documentation at http://0.0.0.0:8000/my-custom-docs-path" in result.output + ) + + +def test_run_and_fastapi_app_without_docs_url_set_should_show_default_url_in_stdout() -> ( + None +): + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke(app, ["run", "single_file_app.py"]) + 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", + "forwarded_allow_ips": None, + "host": "0.0.0.0", + "port": 8000, + "reload": False, + "workers": None, + "root_path": "", + "proxy_headers": True, + "log_config": get_uvicorn_log_config(), + } + assert "Using import string: single_file_app:app" in result.output + assert "Starting production server 🚀" in result.output + assert "Server started at http://0.0.0.0:8000" in result.output + assert "Documentation at http://0.0.0.0:8000/docs" in result.output + + +def test_run_and_fastapi_app_docs_url_set_to_none_should_not_show_api_docs_section() -> ( + None +): + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["run", "single_file_app_with_url_docs_none.py"] + ) + 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_with_url_docs_none:app", + "forwarded_allow_ips": None, + "host": "0.0.0.0", + "port": 8000, + "reload": False, + "workers": None, + "root_path": "", + "proxy_headers": True, + "log_config": get_uvicorn_log_config(), + } + assert ( + "Using import string: single_file_app_with_url_docs_none:app" + in result.output + ) + assert "Starting production server 🚀" in result.output + assert "Server started at http://0.0.0.0:8000" in result.output + assert "Documentation at " not in result.output + + +def test_dev_and_fastapi_app_docs_url_set_to_none_should_not_show_api_docs_section() -> ( + None +): + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_app_with_url_docs_none.py"] + ) + 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_with_url_docs_none:app", + "forwarded_allow_ips": None, + "host": "127.0.0.1", + "port": 8000, + "reload": True, + "workers": None, + "root_path": "", + "proxy_headers": True, + "log_config": get_uvicorn_log_config(), + } + assert ( + "Using import string: single_file_app_with_url_docs_none:app" + in result.output + ) + assert "Starting development server 🚀" in result.output + assert "Server started at http://127.0.0.1:8000" in result.output + assert "Documentation at " not in result.output + + +def test_dev_and_fastapi_app_docs_url_with_root_path_should_show_correctly_url_in_stdout() -> ( + None +): + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, + [ + "dev", + "single_file_app_with_url_docs_set_and_root_path.py", + "--host", + "192.168.0.2", + "--port", + "8080", + "--no-reload", + "--root-path", + "/api/v1", + "--app", + "app", + "--no-proxy-headers", + ], + ) + 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_with_url_docs_set_and_root_path:app", + "forwarded_allow_ips": None, + "host": "192.168.0.2", + "port": 8080, + "reload": False, + "workers": None, + "root_path": "/api/v1", + "proxy_headers": False, + "log_config": get_uvicorn_log_config(), + } + assert "Using import string: " in result.output + assert "single_file_app_with_url_docs_set_and_root_path:app" 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/my-custom-docs-path" + in result.output + ) + + +def test_run_and_fastapi_app_docs_url_with_root_path_should_show_correctly_url_in_stdout() -> ( + None +): + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, + [ + "run", + "single_file_app_with_url_docs_set_and_root_path.py", + "--host", + "0.0.0.0", + "--port", + "8080", + "--no-reload", + "--root-path", + "/api/v1", + "--app", + "app", + "--no-proxy-headers", + ], + ) + 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_with_url_docs_set_and_root_path:app", + "forwarded_allow_ips": None, + "host": "0.0.0.0", + "port": 8080, + "reload": False, + "workers": None, + "root_path": "/api/v1", + "proxy_headers": False, + "log_config": get_uvicorn_log_config(), + } + assert "Using import string: " in result.output + assert "single_file_app_with_url_docs_set_and_root_path:app" in result.output + assert "Starting production server 🚀" in result.output + assert "Server started at http://0.0.0.0:8080" in result.output + assert ( + "Documentation at http://0.0.0.0:8080/my-custom-docs-path" in result.output + )