Skip to content

Commit a97e937

Browse files
add support to dynamic url docs based on the FastAPI docs_url param
1 parent 9aec08e commit a97e937

File tree

5 files changed

+323
-3
lines changed

5 files changed

+323
-3
lines changed

src/fastapi_cli/cli.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1+
import importlib
12
import logging
23
from pathlib import Path
34
from typing import Any, List, Union
45

56
import typer
67
from pydantic import ValidationError
8+
from fastapi import FastAPI
79
from rich import print
810
from rich.tree import Tree
911
from typing_extensions import Annotated
1012

1113
from fastapi_cli.config import FastAPIConfig
12-
from fastapi_cli.discover import get_import_data, get_import_data_from_import_string
14+
from fastapi_cli.discover import (
15+
ImportData,
16+
get_import_data,
17+
get_import_data_from_import_string,
18+
)
1319
from fastapi_cli.exceptions import FastAPICLIException
1420

1521
from . import __version__
@@ -68,6 +74,22 @@ def callback(
6874
setup_logging(level=log_level)
6975

7076

77+
def _get_url_docs(import_data: ImportData) -> Union[str, None]:
78+
"""
79+
Get the FastAPI docs URL from the Uvicorn path.
80+
81+
Args:
82+
import_data: The ImportData object.
83+
84+
Returns:
85+
The FastAPI docs URL.
86+
"""
87+
module = importlib.import_module(import_data.module_data.module_import_str)
88+
app_name = import_data.app_name
89+
fastapi_app: FastAPI = getattr(module, app_name)
90+
return fastapi_app.docs_url
91+
92+
7193
def _get_module_tree(module_paths: List[Path]) -> Tree:
7294
root = module_paths[0]
7395
name = f"🐍 {root.name}" if root.is_file() else f"📁 {root.name}"
@@ -180,15 +202,21 @@ def _run(
180202
)
181203

182204
url = f"http://{host}:{port}"
183-
url_docs = f"{url}/docs"
205+
docs_path = _get_url_docs(import_data)
206+
url_docs = f"{url}{docs_path}" if docs_path else None
184207

185208
toolkit.print_line()
186209
toolkit.print(
187210
f"Server started at [link={url}]{url}[/]",
188-
f"Documentation at [link={url_docs}]{url_docs}[/]",
189211
tag="server",
190212
)
191213

214+
if docs_path:
215+
toolkit.print(
216+
f"Documentation at [link={url_docs}]{url_docs}[/]",
217+
tag="server",
218+
)
219+
192220
if command == "dev":
193221
toolkit.print_line()
194222
toolkit.print(
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from fastapi import FastAPI
2+
3+
app = FastAPI(docs_url=None)
4+
5+
6+
@app.get("/")
7+
def api_root():
8+
return {"message": "my FastAPI app with no docs path"}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from fastapi import FastAPI
2+
3+
app = FastAPI(docs_url="/my-custom-docs-path")
4+
5+
6+
@app.get("/")
7+
def api_root():
8+
return {"message": "my FastAPI app with a custom docs path"}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from fastapi import FastAPI
2+
3+
app = FastAPI(docs_url="/my-custom-docs-path", root_path="/api/v1")
4+
5+
6+
@app.get("/")
7+
def api_root():
8+
return {"message": "my FastAPI app with a custom docs path and root path"}

tests/test_cli.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,3 +492,271 @@ def test_script() -> None:
492492
encoding="utf-8",
493493
)
494494
assert "Usage" in result.stdout
495+
496+
497+
def test_dev_and_fastapi_app_with_url_docs_set_should_show_correctly_url_in_stdout() -> (
498+
None
499+
):
500+
with changing_dir(assets_path):
501+
with patch.object(uvicorn, "run") as mock_run:
502+
result = runner.invoke(app, ["dev", "single_file_app_with_url_docs_set.py"])
503+
assert result.exit_code == 0, result.output
504+
assert mock_run.called
505+
assert mock_run.call_args
506+
assert mock_run.call_args.kwargs == {
507+
"app": "single_file_app_with_url_docs_set:app",
508+
"forwarded_allow_ips": None,
509+
"host": "127.0.0.1",
510+
"port": 8000,
511+
"reload": True,
512+
"workers": None,
513+
"root_path": "",
514+
"proxy_headers": True,
515+
"log_config": get_uvicorn_log_config(),
516+
}
517+
assert (
518+
"Using import string: single_file_app_with_url_docs_set:app"
519+
in result.output
520+
)
521+
assert "Starting development server 🚀" in result.output
522+
assert "Server started at http://127.0.0.1:8000" in result.output
523+
assert (
524+
"Documentation at http://127.0.0.1:8000/my-custom-docs-path"
525+
in result.output
526+
)
527+
528+
529+
def test_dev_and_fastapi_app_without_docs_url_set_should_show_default_url_in_stdout() -> (
530+
None
531+
):
532+
with changing_dir(assets_path):
533+
with patch.object(uvicorn, "run") as mock_run:
534+
result = runner.invoke(app, ["dev", "single_file_app.py"])
535+
assert result.exit_code == 0, result.output
536+
assert mock_run.called
537+
assert mock_run.call_args
538+
assert mock_run.call_args.kwargs == {
539+
"app": "single_file_app:app",
540+
"forwarded_allow_ips": None,
541+
"host": "127.0.0.1",
542+
"port": 8000,
543+
"reload": True,
544+
"workers": None,
545+
"root_path": "",
546+
"proxy_headers": True,
547+
"log_config": get_uvicorn_log_config(),
548+
}
549+
assert "Using import string: single_file_app:app" in result.output
550+
assert "Starting development server 🚀" in result.output
551+
assert "Server started at http://127.0.0.1:8000" in result.output
552+
assert "Documentation at http://127.0.0.1:8000/docs" in result.output
553+
554+
555+
def test_run_and_fastapi_app_with_url_docs_set_should_show_correctly_url_in_stdout() -> (
556+
None
557+
):
558+
with changing_dir(assets_path):
559+
with patch.object(uvicorn, "run") as mock_run:
560+
result = runner.invoke(app, ["run", "single_file_app_with_url_docs_set.py"])
561+
assert result.exit_code == 0, result.output
562+
assert mock_run.called
563+
assert mock_run.call_args
564+
assert mock_run.call_args.kwargs == {
565+
"app": "single_file_app_with_url_docs_set:app",
566+
"forwarded_allow_ips": None,
567+
"host": "0.0.0.0",
568+
"port": 8000,
569+
"reload": False,
570+
"workers": None,
571+
"root_path": "",
572+
"proxy_headers": True,
573+
"log_config": get_uvicorn_log_config(),
574+
}
575+
assert (
576+
"Using import string: single_file_app_with_url_docs_set:app"
577+
in result.output
578+
)
579+
assert "Starting production server 🚀" in result.output
580+
assert "Server started at http://0.0.0.0:8000" in result.output
581+
assert (
582+
"Documentation at http://0.0.0.0:8000/my-custom-docs-path" in result.output
583+
)
584+
585+
586+
def test_run_and_fastapi_app_without_docs_url_set_should_show_default_url_in_stdout() -> (
587+
None
588+
):
589+
with changing_dir(assets_path):
590+
with patch.object(uvicorn, "run") as mock_run:
591+
result = runner.invoke(app, ["run", "single_file_app.py"])
592+
assert result.exit_code == 0, result.output
593+
assert mock_run.called
594+
assert mock_run.call_args
595+
assert mock_run.call_args.kwargs == {
596+
"app": "single_file_app:app",
597+
"forwarded_allow_ips": None,
598+
"host": "0.0.0.0",
599+
"port": 8000,
600+
"reload": False,
601+
"workers": None,
602+
"root_path": "",
603+
"proxy_headers": True,
604+
"log_config": get_uvicorn_log_config(),
605+
}
606+
assert "Using import string: single_file_app:app" in result.output
607+
assert "Starting production server 🚀" in result.output
608+
assert "Server started at http://0.0.0.0:8000" in result.output
609+
assert "Documentation at http://0.0.0.0:8000/docs" in result.output
610+
611+
612+
def test_run_and_fastapi_app_docs_url_set_to_none_should_not_show_api_docs_section() -> (
613+
None
614+
):
615+
with changing_dir(assets_path):
616+
with patch.object(uvicorn, "run") as mock_run:
617+
result = runner.invoke(
618+
app, ["run", "single_file_app_with_url_docs_none.py"]
619+
)
620+
assert result.exit_code == 0, result.output
621+
assert mock_run.called
622+
assert mock_run.call_args
623+
assert mock_run.call_args.kwargs == {
624+
"app": "single_file_app_with_url_docs_none:app",
625+
"forwarded_allow_ips": None,
626+
"host": "0.0.0.0",
627+
"port": 8000,
628+
"reload": False,
629+
"workers": None,
630+
"root_path": "",
631+
"proxy_headers": True,
632+
"log_config": get_uvicorn_log_config(),
633+
}
634+
assert (
635+
"Using import string: single_file_app_with_url_docs_none:app"
636+
in result.output
637+
)
638+
assert "Starting production server 🚀" in result.output
639+
assert "Server started at http://0.0.0.0:8000" in result.output
640+
assert "Documentation at " not in result.output
641+
642+
643+
def test_dev_and_fastapi_app_docs_url_set_to_none_should_not_show_api_docs_section() -> (
644+
None
645+
):
646+
with changing_dir(assets_path):
647+
with patch.object(uvicorn, "run") as mock_run:
648+
result = runner.invoke(
649+
app, ["dev", "single_file_app_with_url_docs_none.py"]
650+
)
651+
assert result.exit_code == 0, result.output
652+
assert mock_run.called
653+
assert mock_run.call_args
654+
assert mock_run.call_args.kwargs == {
655+
"app": "single_file_app_with_url_docs_none:app",
656+
"forwarded_allow_ips": None,
657+
"host": "127.0.0.1",
658+
"port": 8000,
659+
"reload": True,
660+
"workers": None,
661+
"root_path": "",
662+
"proxy_headers": True,
663+
"log_config": get_uvicorn_log_config(),
664+
}
665+
assert (
666+
"Using import string: single_file_app_with_url_docs_none:app"
667+
in result.output
668+
)
669+
assert "Starting development server 🚀" in result.output
670+
assert "Server started at http://127.0.0.1:8000" in result.output
671+
assert "Documentation at " not in result.output
672+
673+
674+
def test_dev_and_fastapi_app_docs_url_with_root_path_should_show_correctly_url_in_stdout() -> (
675+
None
676+
):
677+
with changing_dir(assets_path):
678+
with patch.object(uvicorn, "run") as mock_run:
679+
result = runner.invoke(
680+
app,
681+
[
682+
"dev",
683+
"single_file_app_with_url_docs_set_and_root_path.py",
684+
"--host",
685+
"192.168.0.2",
686+
"--port",
687+
"8080",
688+
"--no-reload",
689+
"--root-path",
690+
"/api/v1",
691+
"--app",
692+
"app",
693+
"--no-proxy-headers",
694+
],
695+
)
696+
assert result.exit_code == 0, result.output
697+
assert mock_run.called
698+
assert mock_run.call_args
699+
assert mock_run.call_args.kwargs == {
700+
"app": "single_file_app_with_url_docs_set_and_root_path:app",
701+
"forwarded_allow_ips": None,
702+
"host": "192.168.0.2",
703+
"port": 8080,
704+
"reload": False,
705+
"workers": None,
706+
"root_path": "/api/v1",
707+
"proxy_headers": False,
708+
"log_config": get_uvicorn_log_config(),
709+
}
710+
assert "Using import string: " in result.output
711+
assert "single_file_app_with_url_docs_set_and_root_path:app" in result.output
712+
assert "Starting development server 🚀" in result.output
713+
assert "Server started at http://192.168.0.2:8080" in result.output
714+
assert (
715+
"Documentation at http://192.168.0.2:8080/my-custom-docs-path"
716+
in result.output
717+
)
718+
719+
720+
def test_run_and_fastapi_app_docs_url_with_root_path_should_show_correctly_url_in_stdout() -> (
721+
None
722+
):
723+
with changing_dir(assets_path):
724+
with patch.object(uvicorn, "run") as mock_run:
725+
result = runner.invoke(
726+
app,
727+
[
728+
"run",
729+
"single_file_app_with_url_docs_set_and_root_path.py",
730+
"--host",
731+
"0.0.0.0",
732+
"--port",
733+
"8080",
734+
"--no-reload",
735+
"--root-path",
736+
"/api/v1",
737+
"--app",
738+
"app",
739+
"--no-proxy-headers",
740+
],
741+
)
742+
assert result.exit_code == 0, result.output
743+
assert mock_run.called
744+
assert mock_run.call_args
745+
assert mock_run.call_args.kwargs == {
746+
"app": "single_file_app_with_url_docs_set_and_root_path:app",
747+
"forwarded_allow_ips": None,
748+
"host": "0.0.0.0",
749+
"port": 8080,
750+
"reload": False,
751+
"workers": None,
752+
"root_path": "/api/v1",
753+
"proxy_headers": False,
754+
"log_config": get_uvicorn_log_config(),
755+
}
756+
assert "Using import string: " in result.output
757+
assert "single_file_app_with_url_docs_set_and_root_path:app" in result.output
758+
assert "Starting production server 🚀" in result.output
759+
assert "Server started at http://0.0.0.0:8080" in result.output
760+
assert (
761+
"Documentation at http://0.0.0.0:8080/my-custom-docs-path" in result.output
762+
)

0 commit comments

Comments
 (0)