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..ccc90bb7 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,9 @@
-from fastapi_startkit.console.command import Command
from cleo.helpers import option
+from fastapi_startkit.console.command import Command
+from fastapi_startkit.support import Uri
+
+_DEFAULT_HOST = "127.0.0.1"
+_DEFAULT_PORT = 8000
class ServeCommand(Command):
@@ -37,24 +41,58 @@ class ServeCommand(Command):
),
]
+ def resolve_host_port(self) -> tuple[str, int]:
+ """Return (host, port) applying the priority chain:
+
+ CLI --host/--port > APP_HOST/APP_PORT (fastapi.host/port config)
+ > APP_URL (fastapi.app_url config) > built-in defaults.
+ """
+ from fastapi_startkit import Config
+
+ host = _DEFAULT_HOST
+ port = _DEFAULT_PORT
+
+ # Layer 1: APP_URL — parse host and port from the URL when set.
+ app_url = Config.get("fastapi.app_url", "") or ""
+ if app_url:
+ # Bare hosts like "myapp.com:9000" have no scheme; add one so
+ # urlparse can extract hostname and port correctly.
+ normalised = app_url if "://" in app_url else f"http://{app_url}"
+ parsed = Uri.of(normalised)
+ if parsed.host():
+ host = parsed.host()
+ if parsed.port():
+ port = parsed.port()
+
+ # Layer 2: APP_HOST / APP_PORT environment variables (via config fields).
+ cfg_host = Config.get("fastapi.host", "") or ""
+ cfg_port = Config.get("fastapi.port", 0) or 0
+ if cfg_host:
+ host = cfg_host
+ if cfg_port:
+ port = int(cfg_port)
+
+ # Layer 3: CLI flags win over everything.
+ cli_host = self.option("host")
+ cli_port = self.option("port")
+ if cli_host:
+ host = cli_host
+ if cli_port:
+ port = int(cli_port)
+
+ return host, 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)
+ host, port = self.resolve_host_port()
+
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()
+ reload = Config.get("fastapi.reload", True) if self.option("reload") is None else self.option("reload")
kwargs = {
"host": host,
@@ -63,10 +101,10 @@ def handle(self):
"ws": "websockets-sansio",
}
- if exist:
+ if self.is_app_exist():
kwargs.update(
{
- "app": app,
+ "app": self.option("app"),
"factory": True,
}
)
@@ -75,7 +113,7 @@ 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 {host}:{port} [{self.option('app')}]...")
else:
self.line(f"Starting Uvicorn server on {host}:{port}...")
diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py b/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py
index f85534c4..047d888f 100644
--- a/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py
+++ b/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py
@@ -9,10 +9,14 @@ class FastAPIConfig:
All fields can be overridden via environment variables or by publishing a
``config/fastapi.py`` file in the application root.
+
+ Resolution order for host/port (highest priority wins):
+ CLI --host / --port > APP_HOST / APP_PORT > APP_URL > built-in default
"""
- host: str = dataclasses.field(default_factory=lambda: env("APP_HOST", "127.0.0.1"))
- port: int = dataclasses.field(default_factory=lambda: env("APP_PORT", 8000))
+ app_url: str = dataclasses.field(default_factory=lambda: env("APP_URL", ""))
+ host: str = dataclasses.field(default_factory=lambda: env("APP_HOST", ""))
+ port: int = dataclasses.field(default_factory=lambda: env("APP_PORT", 0))
reload: bool = dataclasses.field(default_factory=lambda: env("APP_RELOAD", True))
reload_dirs: list | None = None
reload_excludes: list = dataclasses.field(
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..e1a1a9c1
--- /dev/null
+++ b/fastapi_startkit/tests/fastapi/test_serve_command.py
@@ -0,0 +1,270 @@
+"""Tests for ServeCommand.resolve_host_port().
+
+Priority chain under test:
+ CLI --host/--port > APP_HOST/APP_PORT (fastapi.host/port)
+ > APP_URL (fastapi.app_url) > built-in defaults (127.0.0.1, 8000).
+
+``resolve_host_port`` is tested in isolation — no uvicorn, no real Application
+required. We patch :func:`fastapi_startkit.Config.get` and stub the Cleo
+option bag so we can drive every combination without IO.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from fastapi_startkit.fastapi.commands.serve_command import ServeCommand
+
+_DEFAULT_HOST = "127.0.0.1"
+_DEFAULT_PORT = 8000
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def make_command(cli_host: str | None = None, cli_port: str | None = None) -> ServeCommand:
+ """Return a ServeCommand whose ``option()`` returns the given CLI values."""
+ cmd = ServeCommand.__new__(ServeCommand)
+
+ def _option(name):
+ if name == "host":
+ return cli_host
+ if name == "port":
+ return cli_port
+ return None
+
+ cmd.option = _option # type: ignore[method-assign]
+ return cmd
+
+
+def config_get(config: dict):
+ """Return a side_effect for ``Config.get`` driven by *config*."""
+
+ def _get(key, default=None):
+ return config.get(key, default)
+
+ return _get
+
+
+# ---------------------------------------------------------------------------
+# 1. Default fallback — nothing set anywhere
+# ---------------------------------------------------------------------------
+
+
+class TestDefaults:
+ def test_default_host_and_port(self):
+ cmd = make_command()
+ cfg = {}
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == _DEFAULT_HOST
+ assert port == _DEFAULT_PORT
+
+
+# ---------------------------------------------------------------------------
+# 2. APP_URL only
+# ---------------------------------------------------------------------------
+
+
+class TestAppUrl:
+ def test_app_url_host_and_port_extracted(self):
+ cmd = make_command()
+ cfg = {"fastapi.app_url": "http://myapp.com:9000"}
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == "myapp.com"
+ assert port == 9000
+
+ def test_app_url_no_port_falls_back_to_default_port(self):
+ """APP_URL with no port component → default port 8000."""
+ cmd = make_command()
+ cfg = {"fastapi.app_url": "http://myapp.com"}
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == "myapp.com"
+ assert port == _DEFAULT_PORT
+
+ def test_app_url_bare_host_with_scheme_omitted(self):
+ """``myapp.com:9000`` (no scheme) is normalised before parsing."""
+ cmd = make_command()
+ cfg = {"fastapi.app_url": "myapp.com:9000"}
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == "myapp.com"
+ assert port == 9000
+
+ def test_app_url_no_host_falls_back_to_default_host(self):
+ """An APP_URL that resolves to an empty hostname keeps the default host."""
+ cmd = make_command()
+ # An empty APP_URL string → treated as "not set"
+ cfg = {"fastapi.app_url": ""}
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == _DEFAULT_HOST
+ assert port == _DEFAULT_PORT
+
+
+# ---------------------------------------------------------------------------
+# 3. APP_HOST / APP_PORT override APP_URL
+# ---------------------------------------------------------------------------
+
+
+class TestEnvOverridesAppUrl:
+ def test_app_host_overrides_app_url_host(self):
+ cmd = make_command()
+ cfg = {
+ "fastapi.app_url": "http://myapp.com:9000",
+ "fastapi.host": "0.0.0.0",
+ }
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == "0.0.0.0"
+ assert port == 9000 # port still from APP_URL
+
+ def test_app_port_overrides_app_url_port(self):
+ cmd = make_command()
+ cfg = {
+ "fastapi.app_url": "http://myapp.com:9000",
+ "fastapi.port": 5000,
+ }
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == "myapp.com"
+ assert port == 5000 # port overridden
+
+ def test_app_host_and_port_both_override_app_url(self):
+ cmd = make_command()
+ cfg = {
+ "fastapi.app_url": "http://myapp.com:9000",
+ "fastapi.host": "0.0.0.0",
+ "fastapi.port": 5000,
+ }
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == "0.0.0.0"
+ assert port == 5000
+
+
+# ---------------------------------------------------------------------------
+# 4. CLI flags override everything
+# ---------------------------------------------------------------------------
+
+
+class TestCliOverridesAll:
+ def test_cli_host_overrides_app_url_and_env(self):
+ cmd = make_command(cli_host="localhost")
+ cfg = {
+ "fastapi.app_url": "http://myapp.com:9000",
+ "fastapi.host": "0.0.0.0",
+ }
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == "localhost"
+ assert port == 9000
+
+ def test_cli_port_overrides_app_url_and_env(self):
+ cmd = make_command(cli_port="3000")
+ cfg = {
+ "fastapi.app_url": "http://myapp.com:9000",
+ "fastapi.port": 5000,
+ }
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == "myapp.com"
+ assert port == 3000
+
+ def test_cli_host_and_port_override_everything(self):
+ cmd = make_command(cli_host="localhost", cli_port="3000")
+ cfg = {
+ "fastapi.app_url": "http://myapp.com:9000",
+ "fastapi.host": "0.0.0.0",
+ "fastapi.port": 5000,
+ }
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == "localhost"
+ assert port == 3000
+
+ def test_cli_host_with_no_app_url(self):
+ """CLI --host works even when APP_URL is not configured."""
+ cmd = make_command(cli_host="0.0.0.0")
+ cfg = {}
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == "0.0.0.0"
+ assert port == _DEFAULT_PORT
+
+ def test_cli_port_with_no_app_url(self):
+ """CLI --port works even when APP_URL is not configured."""
+ cmd = make_command(cli_port="9999")
+ cfg = {}
+ with patch("fastapi_startkit.configuration.config.Config.get", side_effect=config_get(cfg)):
+ host, port = cmd.resolve_host_port()
+ assert host == _DEFAULT_HOST
+ assert port == 9999
+
+
+# ---------------------------------------------------------------------------
+# 5. FastAPIConfig dataclass — field defaults
+# ---------------------------------------------------------------------------
+
+
+class TestFastAPIConfigDefaults:
+ def test_host_defaults_to_empty_string_when_no_env(self):
+ """Config host/port default to empty/zero so they don't mask APP_URL."""
+ import os
+
+ from fastapi_startkit.fastapi.config.fastapi import FastAPIConfig
+
+ env_backup = {k: os.environ.pop(k) for k in ("APP_HOST", "APP_PORT", "APP_URL") if k in os.environ}
+ try:
+ cfg = FastAPIConfig()
+ assert cfg.host == ""
+ assert cfg.port == 0
+ assert cfg.app_url == ""
+ finally:
+ os.environ.update(env_backup)
+
+ def test_host_reads_from_app_host_env(self):
+ import os
+
+ from fastapi_startkit.fastapi.config.fastapi import FastAPIConfig
+
+ env_backup = {k: os.environ.pop(k) for k in ("APP_HOST", "APP_PORT", "APP_URL") if k in os.environ}
+ try:
+ os.environ["APP_HOST"] = "10.0.0.1"
+ cfg = FastAPIConfig()
+ assert cfg.host == "10.0.0.1"
+ finally:
+ os.environ.update(env_backup)
+ os.environ.pop("APP_HOST", None)
+
+ def test_port_reads_from_app_port_env(self):
+ import os
+
+ from fastapi_startkit.fastapi.config.fastapi import FastAPIConfig
+
+ env_backup = {k: os.environ.pop(k) for k in ("APP_HOST", "APP_PORT", "APP_URL") if k in os.environ}
+ try:
+ os.environ["APP_PORT"] = "9001"
+ cfg = FastAPIConfig()
+ assert cfg.port == 9001
+ finally:
+ os.environ.update(env_backup)
+ os.environ.pop("APP_PORT", None)
+
+ def test_app_url_reads_from_app_url_env(self):
+ import os
+
+ from fastapi_startkit.fastapi.config.fastapi import FastAPIConfig
+
+ env_backup = {k: os.environ.pop(k) for k in ("APP_HOST", "APP_PORT", "APP_URL") if k in os.environ}
+ try:
+ os.environ["APP_URL"] = "http://staging.myapp.com:7000"
+ cfg = FastAPIConfig()
+ assert cfg.app_url == "http://staging.myapp.com:7000"
+ finally:
+ os.environ.update(env_backup)
+ os.environ.pop("APP_URL", None)
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" },