Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,41 @@
from fastapi_startkit.console.command import Command
from urllib.parse import urlparse

from cleo.helpers import option

from fastapi_startkit.console.command import Command


def _resolve_host_port(
cfg_host: str | None,
cfg_port: int | None,
app_url: str | None,
) -> tuple[str, int]:
"""Resolve host and port with priority: APP_HOST/APP_PORT → APP_URL → defaults.

Args:
cfg_host: Value of APP_HOST (may be None).
cfg_port: Value of APP_PORT (may be None).
app_url: Value of APP_URL (may be None).

Returns:
A (host, port) tuple always containing concrete values.
"""
host = cfg_host or None
port = int(cfg_port) if cfg_port else None

if app_url and (not host or port is None):
raw = app_url
parsed = urlparse(raw if "://" in raw else f"http://{raw}")
if not host:
host = parsed.hostname or None
if port is None:
port = parsed.port or None

host = host or "127.0.0.1"
port = port if port is not None else 8000

return host, port


class ServeCommand(Command):
name = "serve"
Expand Down Expand Up @@ -42,15 +77,20 @@ 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)
# Read raw config values — no defaults here
cfg_host = Config.get("fastapi.host")
cfg_port = Config.get("fastapi.port")
cfg_app_url = Config.get("fastapi.app_url")
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)
# Full resolution: APP_HOST/APP_PORT → APP_URL → 127.0.0.1/8000
resolved_host, resolved_port = _resolve_host_port(cfg_host, cfg_port, cfg_app_url)

# CLI flags override resolved config
host = self.option("host") or resolved_host
port = int(self.option("port") or resolved_port)
reload = cfg_reload if self.option("reload") is None else self.option("reload")
app = self.option("app")

Expand Down
10 changes: 6 additions & 4 deletions fastapi_startkit/src/fastapi_startkit/fastapi/config/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
92 changes: 92 additions & 0 deletions fastapi_startkit/tests/fastapi/test_fastapi_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Tests for FastAPIConfig host/port resolution (APP_HOST, APP_PORT, APP_URL)."""

import pytest

from fastapi_startkit.fastapi.config.fastapi import FastAPIConfig


class TestDefaults:
def test_default_host(self, monkeypatch):
monkeypatch.delenv("APP_HOST", raising=False)
monkeypatch.delenv("APP_URL", raising=False)
assert FastAPIConfig().host == "127.0.0.1"

def test_default_port(self, monkeypatch):
monkeypatch.delenv("APP_PORT", raising=False)
monkeypatch.delenv("APP_URL", raising=False)
assert FastAPIConfig().port == 8000

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 TestExplicitEnvVars:
def test_app_host(self, monkeypatch):
monkeypatch.setenv("APP_HOST", "0.0.0.0")
monkeypatch.delenv("APP_URL", raising=False)
assert FastAPIConfig().host == "0.0.0.0"

def test_app_port(self, monkeypatch):
monkeypatch.setenv("APP_PORT", "9000")
monkeypatch.delenv("APP_URL", raising=False)
assert FastAPIConfig().port == 9000

def test_app_reload_false(self, monkeypatch):
monkeypatch.setenv("APP_RELOAD", "False")
assert FastAPIConfig().reload is False


class TestAppUrlParsing:
def test_host_and_port_from_url(self, monkeypatch):
monkeypatch.setenv("APP_URL", "http://myapp.com:9000")
monkeypatch.delenv("APP_HOST", raising=False)
monkeypatch.delenv("APP_PORT", raising=False)
cfg = FastAPIConfig()
assert cfg.host == "myapp.com"
assert cfg.port == 9000

def test_https_url_no_port(self, monkeypatch):
monkeypatch.setenv("APP_URL", "https://production.example.com")
monkeypatch.delenv("APP_HOST", raising=False)
monkeypatch.delenv("APP_PORT", raising=False)
cfg = FastAPIConfig()
assert cfg.host == "production.example.com"
assert cfg.port == 8000

def test_url_without_scheme(self, monkeypatch):
monkeypatch.setenv("APP_URL", "myapp.com:9000")
monkeypatch.delenv("APP_HOST", raising=False)
monkeypatch.delenv("APP_PORT", raising=False)
cfg = FastAPIConfig()
assert cfg.host == "myapp.com"
assert cfg.port == 9000


class TestPriority:
def test_app_host_wins_over_url(self, monkeypatch):
monkeypatch.setenv("APP_URL", "http://myapp.com:9000")
monkeypatch.setenv("APP_HOST", "override.com")
assert FastAPIConfig().host == "override.com"

def test_app_port_wins_over_url(self, monkeypatch):
monkeypatch.setenv("APP_URL", "http://myapp.com:9000")
monkeypatch.setenv("APP_PORT", "7777")
assert FastAPIConfig().port == 7777

def test_app_host_overrides_only_host_url_port_still_used(self, monkeypatch):
monkeypatch.setenv("APP_URL", "http://myapp.com:9000")
monkeypatch.setenv("APP_HOST", "override.com")
monkeypatch.delenv("APP_PORT", raising=False)
cfg = FastAPIConfig()
assert cfg.host == "override.com"
assert cfg.port == 9000
2 changes: 1 addition & 1 deletion fastapi_startkit/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading