Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .github/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/requirements/test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ PyJWT
httpx
pytest>=7.4
pytest-asyncio>=0.23
modal
3 changes: 2 additions & 1 deletion modal_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
run(fastapi_app, host=env_config.server_host, port=env_config.server_port)
31 changes: 20 additions & 11 deletions modal_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,46 +43,55 @@

@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"),
],
)

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"),
],
)

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"),
Expand Down
8 changes: 6 additions & 2 deletions src/api/routes.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import uuid
import os
from typing import List

from fastapi import APIRouter, Depends, HTTPException, status
Expand All @@ -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()

Expand All @@ -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},
)

Expand Down
18 changes: 12 additions & 6 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import os
import uuid
from contextlib import asynccontextmanager

Expand All @@ -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,
)

Expand All @@ -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)
39 changes: 39 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion tests/test_health.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from fastapi.testclient import TestClient
import os


def test_health_returns_200(client: TestClient):
Expand All @@ -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):
Expand Down