Skip to content
23 changes: 20 additions & 3 deletions src/fastapi_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand Down Expand Up @@ -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]}",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you create a list of url and just print the first ? What about swagger and redoc urls ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to keep it closer to the current behavior (only Swagger docs link is shown).
But I was in doubts about whether we should limit it to only first link or show all of them.

I actually wanted to highlight this in the comments, but forgot. So, thanks for pointing to it!)

I personally think that showing only one link is OK, and there is no big difference where to drop others (I mean whether to return all links from get_docs_utls or to return the only one that should be shown).
Let's wait for Sebastian to take a look and decide

tag="server",
)

if command == "dev":
toolkit.print_line()
toolkit.print(
Expand Down
30 changes: 30 additions & 0 deletions src/fastapi_cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
YuriiMotov marked this conversation as resolved.

return app.root_path
99 changes: 99 additions & 0 deletions tests/assets/single_file_docs.py
Original file line number Diff line number Diff line change
@@ -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"}
144 changes: 140 additions & 4 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pathlib import Path
from unittest.mock import patch

import pytest
import uvicorn
from typer.testing import CliRunner

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Expand Down
3 changes: 1 addition & 2 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading