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..2c847c79 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py @@ -1,6 +1,8 @@ -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 + class ServeCommand(Command): name = "serve" @@ -42,15 +44,40 @@ def handle(self): 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) + # 1. Start from hardcoded defaults + host: str = "127.0.0.1" + port: int = 8000 + + # 2. Override with APP_URL (lowest env-level source) + app_url: str | None = Config.get("fastapi.app_url") + if app_url: + normalized = app_url if "://" in app_url else f"http://{app_url}" + uri = Uri.of(normalized) + if uri.host(): + host = uri.host() + if uri.port() is not None: + port = uri.port() + + # 3. Override with APP_HOST / APP_PORT (higher than APP_URL) + cfg_host: str | None = Config.get("fastapi.host") + cfg_port: int | None = Config.get("fastapi.port") + if cfg_host: + host = cfg_host + if cfg_port is not None: + port = int(cfg_port) + + # 4. Override with CLI flags (highest priority) + cli_host: str | None = self.option("host") + cli_port: str | None = self.option("port") + if cli_host: + host = cli_host + if cli_port: + port = int(cli_port) + reload = cfg_reload if self.option("reload") is None else self.option("reload") app = self.option("app") diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py b/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py index f85534c4..4f699dad 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py @@ -7,12 +7,14 @@ class FastAPIConfig: """Server configuration for the uvicorn/FastAPI serve command. - All fields can be overridden via environment variables or by publishing a - ``config/fastapi.py`` file in the application root. + All fields are raw environment values — no parsing or defaults applied here. + Resolution logic (APP_HOST / APP_PORT → APP_URL → 127.0.0.1 / 8000) + lives in ServeCommand. """ - 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)) + host: str | None = dataclasses.field(default_factory=lambda: env("APP_HOST")) + port: int | None = dataclasses.field(default_factory=lambda: env("APP_PORT")) + app_url: str | None = dataclasses.field(default_factory=lambda: env("APP_URL")) 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_fastapi_config.py b/fastapi_startkit/tests/fastapi/test_fastapi_config.py new file mode 100644 index 00000000..5d38afbd --- /dev/null +++ b/fastapi_startkit/tests/fastapi/test_fastapi_config.py @@ -0,0 +1,52 @@ +"""Tests for FastAPIConfig raw values.""" + +from fastapi_startkit.fastapi.config.fastapi import FastAPIConfig + + +class TestFastAPIConfigDefaults: + """FastAPIConfig stores raw env values — no defaults or resolution here.""" + + def test_host_is_falsy_without_env(self, monkeypatch): + monkeypatch.delenv("APP_HOST", raising=False) + assert not FastAPIConfig().host + + def test_port_is_falsy_without_env(self, monkeypatch): + monkeypatch.delenv("APP_PORT", raising=False) + assert not FastAPIConfig().port + + def test_app_url_is_falsy_without_env(self, monkeypatch): + monkeypatch.delenv("APP_URL", raising=False) + assert not FastAPIConfig().app_url + + def test_default_reload_is_true(self, monkeypatch): + monkeypatch.delenv("APP_RELOAD", raising=False) + assert FastAPIConfig().reload is True + + def test_default_reload_dirs_is_none(self): + assert FastAPIConfig().reload_dirs is None + + def test_default_reload_excludes(self): + excludes = FastAPIConfig().reload_excludes + assert "*.log" in excludes + assert "tests/*" in excludes + assert "node_modules/*" in excludes + + +class TestFastAPIConfigEnvVars: + """FastAPIConfig reads raw env values without defaults.""" + + def test_app_host(self, monkeypatch): + monkeypatch.setenv("APP_HOST", "0.0.0.0") + assert FastAPIConfig().host == "0.0.0.0" + + def test_app_port(self, monkeypatch): + monkeypatch.setenv("APP_PORT", "9000") + assert FastAPIConfig().port == 9000 + + def test_app_url(self, monkeypatch): + monkeypatch.setenv("APP_URL", "http://myapp.com:9000") + assert FastAPIConfig().app_url == "http://myapp.com:9000" + + def test_app_reload_false(self, monkeypatch): + monkeypatch.setenv("APP_RELOAD", "False") + assert FastAPIConfig().reload is False