diff --git a/fastapi_startkit/src/fastapi_startkit/environment/__init__.py b/fastapi_startkit/src/fastapi_startkit/environment/__init__.py
index 7f80943a..86f63577 100644
--- a/fastapi_startkit/src/fastapi_startkit/environment/__init__.py
+++ b/fastapi_startkit/src/fastapi_startkit/environment/__init__.py
@@ -1 +1 @@
-from .environment import env
+from .environment import env, value
diff --git a/fastapi_startkit/src/fastapi_startkit/environment/environment.py b/fastapi_startkit/src/fastapi_startkit/environment/environment.py
index 3cbd5721..6665d472 100644
--- a/fastapi_startkit/src/fastapi_startkit/environment/environment.py
+++ b/fastapi_startkit/src/fastapi_startkit/environment/environment.py
@@ -87,3 +87,21 @@ def env(value, default="", cast=True):
return True
else:
return env_var
+
+
+def value(env_var, default=""):
+ if env_var == "":
+ env_var = default
+
+ if isinstance(env_var, bool):
+ return env_var
+ elif env_var is None:
+ return None
+ elif isinstance(env_var, int) or (isinstance(env_var, str) and env_var.isnumeric()):
+ return int(env_var)
+ elif env_var in ("false", "False"):
+ return False
+ elif env_var in ("true", "True"):
+ return True
+ else:
+ return env_var
diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py b/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py
index be268cd5..a595b4d1 100644
--- a/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py
+++ b/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py
@@ -1,5 +1,8 @@
-from fastapi_startkit.console.command import Command
from cleo.helpers import option
+from fastapi_startkit import Config
+from fastapi_startkit.console.command import Command
+from fastapi_startkit.environment import value as cast_value
+from fastapi_startkit.support import Uriable, Uri
class ServeCommand(Command):
@@ -37,36 +40,40 @@ class ServeCommand(Command):
),
]
+ def resolve_option(self, key: str, default: str | int | None = None):
+ value = self.option(key) or Config.get(f"fastapi.{key}", default)
+
+ return cast_value(value)
+
+ def resolve_url(self) -> Uriable:
+ host = self.resolve_option("host", "127.0.0.1")
+ port = self.resolve_option("port", 8000)
+
+ return Uri.of(Config.get("fastapi.app_url", "http://127.0.0.1:8000")).with_host(host).with_port(port)
+
def handle(self):
import uvicorn
+
from fastapi_startkit import Config
from fastapi_startkit.container import Container
# Resolve server settings: CLI flag > fastapi config > uvicorn default (None)
- cfg_host = Config.get("fastapi.host", "127.0.0.1")
- cfg_port = Config.get("fastapi.port", 8000)
- cfg_reload = Config.get("fastapi.reload", True)
cfg_reload_dirs = Config.get("fastapi.reload_dirs") or None
cfg_reload_excludes = Config.get("fastapi.reload_excludes") or None
- host = self.option("host") or cfg_host
- port = int(self.option("port") or cfg_port)
- reload = cfg_reload if self.option("reload") is None else self.option("reload")
- app = self.option("app")
-
- exist = self.is_app_exist()
+ url = self.resolve_url()
kwargs = {
- "host": host,
- "port": port,
- "reload": reload,
+ "host": url.host(),
+ "port": url.port(),
+ "reload": self.resolve_option("reload", True),
"ws": "websockets-sansio",
}
- if exist:
+ if self.is_app_exist():
kwargs.update(
{
- "app": app,
+ "app": self.option("app"),
"factory": True,
}
)
@@ -75,10 +82,10 @@ def handle(self):
if cfg_reload_excludes is not None:
kwargs["reload_excludes"] = cfg_reload_excludes
- self.line(f"Starting Uvicorn server on {host}:{port} [{app}]...")
+ self.line(f"Starting Uvicorn server on {url.host()}:{url.port()} [{self.option('app')}]...")
else:
- self.line(f"Starting Uvicorn server on {host}:{port}...")
+ self.line(f"Starting Uvicorn server on {url.host()}:{url.port()}...")
kwargs.update({"app": Container.instance().fastapi, "reload": False})
try:
diff --git a/fastapi_startkit/tests/fastapi/test_serve_command.py b/fastapi_startkit/tests/fastapi/test_serve_command.py
new file mode 100644
index 00000000..b1cc7ebb
--- /dev/null
+++ b/fastapi_startkit/tests/fastapi/test_serve_command.py
@@ -0,0 +1,187 @@
+"""Tests for ServeCommand using Cleo's CommandTester.
+
+Exercises option parsing, host/port resolution, and output messages without
+starting a real Uvicorn server. ``Config.get`` and ``uvicorn.run`` are patched
+so the tests are self-contained with no live app or network required.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+from cleo.testers.command_tester import CommandTester
+
+from fastapi_startkit.fastapi.commands.serve_command import ServeCommand
+
+_DEFAULT_HOST = "127.0.0.1"
+_DEFAULT_PORT = 8000
+_DEFAULT_APP_URL = f"http://{_DEFAULT_HOST}:{_DEFAULT_PORT}"
+
+
+# ---------------------------------------------------------------------------
+# Helper
+# ---------------------------------------------------------------------------
+
+
+def run(
+ args: str = "",
+ *,
+ config: dict | None = None,
+ app_found: bool = False,
+) -> tuple[CommandTester, MagicMock]:
+ """Execute ServeCommand with *args* and return (tester, mock_uvicorn).
+
+ Parameters
+ ----------
+ args:
+ Argument string forwarded verbatim to ``CommandTester.execute()``.
+ config:
+ Mapping of config keys to values that ``Config.get`` will return.
+ Defaults to an empty dict (all keys fall back to their defaults).
+ app_found:
+ When ``True``, ``importlib.util.find_spec`` returns a non-None spec so
+ that ``is_app_exist()`` yields ``True``.
+ """
+ cfg = config or {}
+
+ def _config_get(key, default=None):
+ return cfg.get(key, default)
+
+ mock_uvicorn = MagicMock()
+ mock_spec = MagicMock() if app_found else None
+
+ with (
+ patch("fastapi_startkit.fastapi.commands.serve_command.Config.get", side_effect=_config_get),
+ patch("fastapi_startkit.configuration.config.Config.get", side_effect=_config_get),
+ patch("uvicorn.run", mock_uvicorn),
+ patch("importlib.util.find_spec", return_value=mock_spec),
+ ):
+ cmd = ServeCommand()
+ tester = CommandTester(cmd)
+ tester.execute(args, interactive=False)
+
+ return tester, mock_uvicorn
+
+
+# ---------------------------------------------------------------------------
+# 1. Default behaviour — no CLI flags, no config, no app bootstrap module
+# ---------------------------------------------------------------------------
+
+
+class TestServeCommandDefaults:
+ def test_exits_successfully(self):
+ tester, _ = run()
+ assert tester.status_code == 0
+
+ def test_uvicorn_called(self):
+ _, mock_uvicorn = run()
+ mock_uvicorn.assert_called_once()
+
+ def test_default_host_in_output(self):
+ tester, _ = run()
+ assert _DEFAULT_HOST in tester.io.fetch_output()
+
+ def test_default_port_in_output(self):
+ tester, _ = run()
+ assert str(_DEFAULT_PORT) in tester.io.fetch_output()
+
+ def test_uvicorn_kwargs_contain_ws(self):
+ _, mock_uvicorn = run()
+ _, kwargs = mock_uvicorn.call_args
+ assert kwargs.get("ws") == "websockets-sansio"
+
+
+# ---------------------------------------------------------------------------
+# 2. CLI --host / --port override defaults
+# ---------------------------------------------------------------------------
+
+
+class TestCliOptions:
+ def test_custom_host_appears_in_output(self):
+ tester, _ = run("--host 0.0.0.0")
+ assert "0.0.0.0" in tester.io.fetch_output()
+
+ def test_custom_port_appears_in_output(self):
+ tester, _ = run("--port 9000")
+ assert "9000" in tester.io.fetch_output()
+
+ def test_host_and_port_together_in_output(self):
+ tester, _ = run("--host 0.0.0.0 --port 9000")
+ output = tester.io.fetch_output()
+ assert "0.0.0.0" in output
+ assert "9000" in output
+
+ def test_custom_host_passed_to_uvicorn(self):
+ _, mock_uvicorn = run("--host 0.0.0.0")
+ _, kwargs = mock_uvicorn.call_args
+ assert kwargs["host"] == "0.0.0.0"
+
+ def test_custom_port_passed_to_uvicorn(self):
+ _, mock_uvicorn = run("--port 9000")
+ _, kwargs = mock_uvicorn.call_args
+ assert kwargs["port"] == 9000
+
+
+# ---------------------------------------------------------------------------
+# 3. Config-driven host / port (no CLI flags)
+# ---------------------------------------------------------------------------
+
+
+class TestConfigOptions:
+ def test_config_host_used_when_no_cli_flag(self):
+ tester, _ = run(config={"fastapi.host": "10.0.0.1", "fastapi.app_url": "http://10.0.0.1:8000"})
+ assert "10.0.0.1" in tester.io.fetch_output()
+
+ def test_config_port_used_when_no_cli_flag(self):
+ tester, _ = run(config={"fastapi.port": 7777, "fastapi.app_url": "http://127.0.0.1:7777"})
+ assert "7777" in tester.io.fetch_output()
+
+ def test_app_url_host_overridden_by_config_default(self):
+ """resolve_url() merges APP_URL with fastapi.host/port.
+
+ APP_URL sets the base, but fastapi.host and fastapi.port (which default
+ to 127.0.0.1 / 8000) take priority in the current implementation.
+ When no explicit host/port is in config, defaults win over APP_URL.
+ """
+ tester, _ = run(config={"fastapi.app_url": "http://staging.example.com:5000"})
+ output = tester.io.fetch_output()
+ # Default host wins because resolve_option("host", "127.0.0.1") takes over.
+ assert _DEFAULT_HOST in output
+
+
+# ---------------------------------------------------------------------------
+# 4. App bootstrap detected (is_app_exist → True)
+# ---------------------------------------------------------------------------
+
+
+class TestAppFound:
+ def test_app_label_in_output(self):
+ tester, _ = run("--app myapp.bootstrap:app", app_found=True)
+ assert "myapp.bootstrap:app" in tester.io.fetch_output()
+
+ def test_uvicorn_called_with_factory_true(self):
+ _, mock_uvicorn = run(app_found=True)
+ _, kwargs = mock_uvicorn.call_args
+ assert kwargs.get("factory") is True
+
+ def test_uvicorn_called_with_app_string(self):
+ _, mock_uvicorn = run("--app myapp.bootstrap:app", app_found=True)
+ _, kwargs = mock_uvicorn.call_args
+ assert kwargs.get("app") == "myapp.bootstrap:app"
+
+
+# ---------------------------------------------------------------------------
+# 5. App bootstrap NOT found (is_app_exist → False)
+# ---------------------------------------------------------------------------
+
+
+class TestAppNotFound:
+ def test_no_factory_kwarg_when_app_not_found(self):
+ _, mock_uvicorn = run(app_found=False)
+ _, kwargs = mock_uvicorn.call_args
+ assert "factory" not in kwargs
+
+ def test_reload_disabled_when_app_not_found(self):
+ _, mock_uvicorn = run(app_found=False)
+ _, kwargs = mock_uvicorn.call_args
+ assert kwargs.get("reload") is False
diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock
index 947f8d4b..dd79aa53 100644
--- a/fastapi_startkit/uv.lock
+++ b/fastapi_startkit/uv.lock
@@ -593,7 +593,7 @@ wheels = [
[[package]]
name = "fastapi-startkit"
-version = "0.40.1"
+version = "0.42.0"
source = { editable = "." }
dependencies = [
{ name = "cleo" },