Skip to content

Commit 4d7462c

Browse files
add support to dynamic url docs based on the FastAPI docs_url param
1 parent 7d048df commit 4d7462c

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,13 +1,19 @@
1+
import importlib
12
import logging
23
from pathlib import Path
34
from typing import Any, List, Union
45

56
import typer
7+
from fastapi import FastAPI
68
from rich import print
79
from rich.tree import Tree
810
from typing_extensions import Annotated
911

10-
from fastapi_cli.discover import get_import_data, get_import_data_from_import_string
12+
from fastapi_cli.discover import (
13+
ImportData,
14+
get_import_data,
15+
get_import_data_from_import_string,
16+
)
1117
from fastapi_cli.exceptions import FastAPICLIException
1218

1319
from . import __version__
@@ -66,6 +72,22 @@ def callback(
6672
setup_logging(level=log_level)
6773

6874

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

154176
url = f"http://{host}:{port}"
155-
url_docs = f"{url}/docs"
177+
docs_path = _get_url_docs(import_data)
178+
url_docs = f"{url}{docs_path}" if docs_path else None
156179

157180
toolkit.print_line()
158181
toolkit.print(
159182
f"Server started at [link={url}]{url}[/]",
160-
f"Documentation at [link={url_docs}]{url_docs}[/]",
161183
tag="server",
162184
)
163185

186+
if docs_path:
187+
toolkit.print(
188+
f"Documentation at [link={url_docs}]{url_docs}[/]",
189+
tag="server",
190+
)
191+
164192
if command == "dev":
165193
toolkit.print_line()
166194
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
@@ -460,3 +460,271 @@ def test_script() -> None:
460460
encoding="utf-8",
461461
)
462462
assert "Usage" in result.stdout
463+
464+
465+
def test_dev_and_fastapi_app_with_url_docs_set_should_show_correctly_url_in_stdout() -> (
466+
None
467+
):
468+
with changing_dir(assets_path):
469+
with patch.object(uvicorn, "run") as mock_run:
470+
result = runner.invoke(app, ["dev", "single_file_app_with_url_docs_set.py"])
471+
assert result.exit_code == 0, result.output
472+
assert mock_run.called
473+
assert mock_run.call_args
474+
assert mock_run.call_args.kwargs == {
475+
"app": "single_file_app_with_url_docs_set:app",
476+
"forwarded_allow_ips": None,
477+
"host": "127.0.0.1",
478+
"port": 8000,
479+
"reload": True,
480+
"workers": None,
481+
"root_path": "",
482+
"proxy_headers": True,
483+
"log_config": get_uvicorn_log_config(),
484+
}
485+
assert (
486+
"Using import string: single_file_app_with_url_docs_set:app"
487+
in result.output
488+
)
489+
assert "Starting development server 🚀" in result.output
490+
assert "Server started at http://127.0.0.1:8000" in result.output
491+
assert (
492+
"Documentation at http://127.0.0.1:8000/my-custom-docs-path"
493+
in result.output
494+
)
495+
496+
497+
def test_dev_and_fastapi_app_without_docs_url_set_should_show_default_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.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: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 "Using import string: single_file_app:app" in result.output
518+
assert "Starting development server 🚀" in result.output
519+
assert "Server started at http://127.0.0.1:8000" in result.output
520+
assert "Documentation at http://127.0.0.1:8000/docs" in result.output
521+
522+
523+
def test_run_and_fastapi_app_with_url_docs_set_should_show_correctly_url_in_stdout() -> (
524+
None
525+
):
526+
with changing_dir(assets_path):
527+
with patch.object(uvicorn, "run") as mock_run:
528+
result = runner.invoke(app, ["run", "single_file_app_with_url_docs_set.py"])
529+
assert result.exit_code == 0, result.output
530+
assert mock_run.called
531+
assert mock_run.call_args
532+
assert mock_run.call_args.kwargs == {
533+
"app": "single_file_app_with_url_docs_set:app",
534+
"forwarded_allow_ips": None,
535+
"host": "0.0.0.0",
536+
"port": 8000,
537+
"reload": False,
538+
"workers": None,
539+
"root_path": "",
540+
"proxy_headers": True,
541+
"log_config": get_uvicorn_log_config(),
542+
}
543+
assert (
544+
"Using import string: single_file_app_with_url_docs_set:app"
545+
in result.output
546+
)
547+
assert "Starting production server 🚀" in result.output
548+
assert "Server started at http://0.0.0.0:8000" in result.output
549+
assert (
550+
"Documentation at http://0.0.0.0:8000/my-custom-docs-path" in result.output
551+
)
552+
553+
554+
def test_run_and_fastapi_app_without_docs_url_set_should_show_default_url_in_stdout() -> (
555+
None
556+
):
557+
with changing_dir(assets_path):
558+
with patch.object(uvicorn, "run") as mock_run:
559+
result = runner.invoke(app, ["run", "single_file_app.py"])
560+
assert result.exit_code == 0, result.output
561+
assert mock_run.called
562+
assert mock_run.call_args
563+
assert mock_run.call_args.kwargs == {
564+
"app": "single_file_app:app",
565+
"forwarded_allow_ips": None,
566+
"host": "0.0.0.0",
567+
"port": 8000,
568+
"reload": False,
569+
"workers": None,
570+
"root_path": "",
571+
"proxy_headers": True,
572+
"log_config": get_uvicorn_log_config(),
573+
}
574+
assert "Using import string: single_file_app:app" in result.output
575+
assert "Starting production server 🚀" in result.output
576+
assert "Server started at http://0.0.0.0:8000" in result.output
577+
assert "Documentation at http://0.0.0.0:8000/docs" in result.output
578+
579+
580+
def test_run_and_fastapi_app_docs_url_set_to_none_should_not_show_api_docs_section() -> (
581+
None
582+
):
583+
with changing_dir(assets_path):
584+
with patch.object(uvicorn, "run") as mock_run:
585+
result = runner.invoke(
586+
app, ["run", "single_file_app_with_url_docs_none.py"]
587+
)
588+
assert result.exit_code == 0, result.output
589+
assert mock_run.called
590+
assert mock_run.call_args
591+
assert mock_run.call_args.kwargs == {
592+
"app": "single_file_app_with_url_docs_none:app",
593+
"forwarded_allow_ips": None,
594+
"host": "0.0.0.0",
595+
"port": 8000,
596+
"reload": False,
597+
"workers": None,
598+
"root_path": "",
599+
"proxy_headers": True,
600+
"log_config": get_uvicorn_log_config(),
601+
}
602+
assert (
603+
"Using import string: single_file_app_with_url_docs_none:app"
604+
in result.output
605+
)
606+
assert "Starting production server 🚀" in result.output
607+
assert "Server started at http://0.0.0.0:8000" in result.output
608+
assert "Documentation at " not in result.output
609+
610+
611+
def test_dev_and_fastapi_app_docs_url_set_to_none_should_not_show_api_docs_section() -> (
612+
None
613+
):
614+
with changing_dir(assets_path):
615+
with patch.object(uvicorn, "run") as mock_run:
616+
result = runner.invoke(
617+
app, ["dev", "single_file_app_with_url_docs_none.py"]
618+
)
619+
assert result.exit_code == 0, result.output
620+
assert mock_run.called
621+
assert mock_run.call_args
622+
assert mock_run.call_args.kwargs == {
623+
"app": "single_file_app_with_url_docs_none:app",
624+
"forwarded_allow_ips": None,
625+
"host": "127.0.0.1",
626+
"port": 8000,
627+
"reload": True,
628+
"workers": None,
629+
"root_path": "",
630+
"proxy_headers": True,
631+
"log_config": get_uvicorn_log_config(),
632+
}
633+
assert (
634+
"Using import string: single_file_app_with_url_docs_none:app"
635+
in result.output
636+
)
637+
assert "Starting development server 🚀" in result.output
638+
assert "Server started at http://127.0.0.1:8000" in result.output
639+
assert "Documentation at " not in result.output
640+
641+
642+
def test_dev_and_fastapi_app_docs_url_with_root_path_should_show_correctly_url_in_stdout() -> (
643+
None
644+
):
645+
with changing_dir(assets_path):
646+
with patch.object(uvicorn, "run") as mock_run:
647+
result = runner.invoke(
648+
app,
649+
[
650+
"dev",
651+
"single_file_app_with_url_docs_set_and_root_path.py",
652+
"--host",
653+
"192.168.0.2",
654+
"--port",
655+
"8080",
656+
"--no-reload",
657+
"--root-path",
658+
"/api/v1",
659+
"--app",
660+
"app",
661+
"--no-proxy-headers",
662+
],
663+
)
664+
assert result.exit_code == 0, result.output
665+
assert mock_run.called
666+
assert mock_run.call_args
667+
assert mock_run.call_args.kwargs == {
668+
"app": "single_file_app_with_url_docs_set_and_root_path:app",
669+
"forwarded_allow_ips": None,
670+
"host": "192.168.0.2",
671+
"port": 8080,
672+
"reload": False,
673+
"workers": None,
674+
"root_path": "/api/v1",
675+
"proxy_headers": False,
676+
"log_config": get_uvicorn_log_config(),
677+
}
678+
assert "Using import string: " in result.output
679+
assert "single_file_app_with_url_docs_set_and_root_path:app" in result.output
680+
assert "Starting development server 🚀" in result.output
681+
assert "Server started at http://192.168.0.2:8080" in result.output
682+
assert (
683+
"Documentation at http://192.168.0.2:8080/my-custom-docs-path"
684+
in result.output
685+
)
686+
687+
688+
def test_run_and_fastapi_app_docs_url_with_root_path_should_show_correctly_url_in_stdout() -> (
689+
None
690+
):
691+
with changing_dir(assets_path):
692+
with patch.object(uvicorn, "run") as mock_run:
693+
result = runner.invoke(
694+
app,
695+
[
696+
"run",
697+
"single_file_app_with_url_docs_set_and_root_path.py",
698+
"--host",
699+
"0.0.0.0",
700+
"--port",
701+
"8080",
702+
"--no-reload",
703+
"--root-path",
704+
"/api/v1",
705+
"--app",
706+
"app",
707+
"--no-proxy-headers",
708+
],
709+
)
710+
assert result.exit_code == 0, result.output
711+
assert mock_run.called
712+
assert mock_run.call_args
713+
assert mock_run.call_args.kwargs == {
714+
"app": "single_file_app_with_url_docs_set_and_root_path:app",
715+
"forwarded_allow_ips": None,
716+
"host": "0.0.0.0",
717+
"port": 8080,
718+
"reload": False,
719+
"workers": None,
720+
"root_path": "/api/v1",
721+
"proxy_headers": False,
722+
"log_config": get_uvicorn_log_config(),
723+
}
724+
assert "Using import string: " in result.output
725+
assert "single_file_app_with_url_docs_set_and_root_path:app" in result.output
726+
assert "Starting production server 🚀" in result.output
727+
assert "Server started at http://0.0.0.0:8080" in result.output
728+
assert (
729+
"Documentation at http://0.0.0.0:8080/my-custom-docs-path" in result.output
730+
)

0 commit comments

Comments
 (0)