diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index 679454a..0eeb64d 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -11,6 +11,21 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ## [Unreleased] +### Changed +- `EnvConfig` in `modal_common.py` now centralizes app and server metadata (`app_version`, `app_description`, `server_host`, `server_port`, `server_prefix`) and adds `max_concurrent_requests` +- Deployment wiring in `modal_app.py` now uses `@modal.concurrent(max_inputs=env_config.max_concurrent_requests)` and local `uvicorn` startup reads host/port from environment config +- FastAPI app metadata and API prefix in `src/main.py` now come from environment config instead of hardcoded values +- Health response metadata in `src/api/routes.py` now reports environment-aware `service_name` and config-driven `version` +- Modal environment domain field renamed from `custom_domain` to `server_domain` across environment definitions + +### Added +- Dedicated CI dependency files: `.github/requirements/test.txt` and `.github/requirements/docs.txt` +- Lightweight `modal` module stub in `tests/conftest.py` so tests can run in local/CI contexts where `modal` is not installed + +### Fixed +- GitHub workflows now install dependencies from repository-scoped requirement files (`.github/workflows/app-testing.yml`, `.github/workflows/docs.yml`) +- Health endpoint test expectation updated to validate the environment-aware service name format + --- ## [1.0.0] — 2026-05-03 diff --git a/.github/requirements/test.txt b/.github/requirements/test.txt index 45d06a0..8f1d8be 100644 --- a/.github/requirements/test.txt +++ b/.github/requirements/test.txt @@ -5,3 +5,4 @@ PyJWT httpx pytest>=7.4 pytest-asyncio>=0.23 +modal \ No newline at end of file diff --git a/modal_app.py b/modal_app.py index bffcc95..0e1fb6a 100644 --- a/modal_app.py +++ b/modal_app.py @@ -17,6 +17,7 @@ # SETTING MODAL PROJECT @app.function(**build_fastapi_config(env_config)) @modal.asgi_app() +@modal.concurrent(max_inputs=env_config.max_concurrent_requests) def fastapi_app(): from src.main import app as fastapi_app return fastapi_app @@ -25,4 +26,4 @@ def fastapi_app(): def main(): from src.main import app as fastapi_app from uvicorn import run - run(fastapi_app, host="0.0.0.0", port=8000) \ No newline at end of file + run(fastapi_app, host=env_config.server_host, port=env_config.server_port) \ No newline at end of file diff --git a/modal_common.py b/modal_common.py index 5204cdf..695b830 100644 --- a/modal_common.py +++ b/modal_common.py @@ -43,30 +43,39 @@ @dataclass class EnvConfig: - # Required app config + # APP CONFIGURATION env_name: str app_name: str = "modal-template-fastapi" - - # Optional custom domain - custom_domain: Optional[str] = None - - # Hardware config + app_version: str = "1.0.0" + app_description: str = "A FastAPI template deployed on Modal with CRUD endpoints." + + # SERVER CONFIGURATION + server_port: int = 8000 + server_host: str = "0.0.0.0" + server_reload: bool = False + server_prefix: str = "/api/v1" + + # CUSTOM SERVER DOMAIN(OPTIONAL) + server_domain: Optional[str] = None + + # HARDWARE CONFIGURATION cpu_core_count: int = 1 ram_memory_mib: int = 256 gpu_type: Optional[str] = None - # Runtime config + # RUNTIME CONFIGURATION server_hard_timeout_seconds: int = 150 min_containers: int = 0 + max_concurrent_requests: int = 5 - # Modal resources + # MODAL RESOURCES secrets: list = field(default_factory=list) volumes: Dict[str, modal.Volume] = field(default_factory=lambda: FASTAPI_VOLUME) FEAT = EnvConfig( env_name="feat", - custom_domain="feat-app.modal.run", + server_domain="feat-app.modal.run", secrets=[ modal.Secret.from_name("fastapi-auth-secrets"), ], @@ -74,7 +83,7 @@ class EnvConfig: DEV = EnvConfig( env_name="dev", - custom_domain="dev-app.modal.run", + server_domain="dev-app.modal.run", secrets=[ modal.Secret.from_name("fastapi-auth-secrets"), ], @@ -82,7 +91,7 @@ class EnvConfig: PROD = EnvConfig( env_name="prod", - custom_domain="prod-app.modal.run", + server_domain="prod-app.modal.run", # min_containers=1, # Uncomment this to run 1 container in production, when building Apps secrets=[ modal.Secret.from_name("fastapi-auth-secrets"), diff --git a/src/api/routes.py b/src/api/routes.py index 87557bc..bd4334f 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,4 +1,5 @@ import uuid +import os from typing import List from fastapi import APIRouter, Depends, HTTPException, status @@ -11,6 +12,9 @@ HealthStatus, ItemResponse, ) +from modal_common import get_env_config + +env_config = get_env_config(os.environ.get("MODAL_ENV", "dev")) router = APIRouter() @@ -20,8 +24,8 @@ async def health_check(): return HealthCheckResponse( session_id=str(uuid.uuid4()), status=HealthStatus.HEALTHY, - service_name="modal-template-fastapi", - version="1.0.0", + service_name=f'{env_config.app_name}-{env_config.env_name}', + version=env_config.app_version, services_summary={"total": 1, "healthy": 1, "unhealthy": 0}, ) diff --git a/src/main.py b/src/main.py index 040219e..453d59b 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,5 @@ import logging +import os import uuid from contextlib import asynccontextmanager @@ -10,21 +11,26 @@ from src.api.models import ErrorDetail from src.api.routes import router +from modal_common import get_env_config + +env_config = get_env_config(os.environ.get("MODAL_ENV", "dev")) + logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +APP_NAME = f'{env_config.app_name}-{env_config.env_name}' @asynccontextmanager async def lifespan(app: FastAPI): - logger.info("Starting modal-template-fastapi") + logger.info(f"Starting {APP_NAME}") yield - logger.info("Shutting down modal-template-fastapi") + logger.info(f"Shutting down {APP_NAME}") app = FastAPI( - title="modal-template-fastapi", - description="A FastAPI template deployed on Modal with CRUD endpoints.", - version="1.0.0", + title=APP_NAME, + description=env_config.app_description, + version=env_config.app_version, lifespan=lifespan, ) @@ -49,4 +55,4 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE ) -app.include_router(router, prefix="/api/v1") +app.include_router(router, prefix=env_config.server_prefix) diff --git a/tests/conftest.py b/tests/conftest.py index 0fe5432..6f40af1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ import os +import sys import time +from types import SimpleNamespace # Must be set before any src imports so auth._get_secret_key() resolves os.environ.setdefault("JWT_SECRET", "test-secret-key-for-pytest-at-least-32-bytes") @@ -8,6 +10,43 @@ import pytest from fastapi.testclient import TestClient +# Provide a lightweight modal stub for local test runs where modal is not installed. +if "modal" not in sys.modules: + class _DummyImage: + def apt_install(self, *args, **kwargs): + return self + + def uv_pip_install(self, *args, **kwargs): + return self + + def add_local_dir(self, *args, **kwargs): + return self + + class _DummyImageFactory: + @staticmethod + def debian_slim(*args, **kwargs): + return _DummyImage() + + class _DummyVolume: + @staticmethod + def from_name(*args, **kwargs): + return _DummyVolume() + + class _DummySecret: + @staticmethod + def from_name(*args, **kwargs): + return {} + + @staticmethod + def from_dict(*args, **kwargs): + return {} + + sys.modules["modal"] = SimpleNamespace( + Image=_DummyImageFactory, + Volume=_DummyVolume, + Secret=_DummySecret, + ) + from src.main import app from src.api import handler from src.api.auth import get_current_user diff --git a/tests/test_health.py b/tests/test_health.py index d9b2482..7f55478 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -1,4 +1,5 @@ from fastapi.testclient import TestClient +import os def test_health_returns_200(client: TestClient): @@ -13,7 +14,8 @@ def test_health_status_is_healthy(client: TestClient): def test_health_service_name(client: TestClient): data = client.get("/api/v1/health").json() - assert data["service_name"] == "modal-template-fastapi" + expected_service_name = f"modal-template-fastapi-{os.environ.get('MODAL_ENV', 'dev')}" + assert data["service_name"] == expected_service_name def test_health_version(client: TestClient):