diff --git a/adapter/config/loader.py b/adapter/config/loader.py index c91b811..550482c 100644 --- a/adapter/config/loader.py +++ b/adapter/config/loader.py @@ -59,13 +59,6 @@ def load_config( env_values, 'APP_HTTP_PORT', ), - api_prefix=_yaml_or_env_str( - http_section, - 'api_prefix', - 'http.api_prefix', - env_values, - 'APP_HTTP_API_PREFIX', - ), ), logging=LoggingConfig( level=_yaml_or_env_str( diff --git a/adapter/config/model.py b/adapter/config/model.py index dd8643c..c540dee 100644 --- a/adapter/config/model.py +++ b/adapter/config/model.py @@ -11,7 +11,6 @@ class AppSectionConfig: class HttpConfig: host: str port: int - api_prefix: str @dataclass(frozen=True, slots=True) diff --git a/adapter/di/container.py b/adapter/di/container.py index b1efdbe..95ee499 100644 --- a/adapter/di/container.py +++ b/adapter/di/container.py @@ -41,13 +41,17 @@ def build_container( config_path: Path | str | None = None, env_path: Path | str | None = None, environ: Mapping[str, str] | None = None, + config: AppConfig | None = None, ) -> AppContainer: - config = load_config( - config_path=config_path, - env_path=env_path, - environ=environ, - ) - observability = setup_otel(config) + app_config = config + if app_config is None: + app_config = load_config( + config_path=config_path, + env_path=env_path, + environ=environ, + ) + + observability = setup_otel(app_config) user_repository = InMemoryUserRepository() repositories = AppRepositories(user=user_repository) @@ -60,7 +64,7 @@ def build_container( ) return AppContainer( - config=config, + config=app_config, observability=observability, repositories=repositories, usecases=usecases, diff --git a/adapter/http/fastapi/__init__.py b/adapter/http/fastapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapter/http/fastapi/app.py b/adapter/http/fastapi/app.py new file mode 100644 index 0000000..d7ed189 --- /dev/null +++ b/adapter/http/fastapi/app.py @@ -0,0 +1,18 @@ +from adapter.config.loader import load_config +from adapter.config.model import AppConfig +from adapter.http.fastapi.dependencies import APP_CONFIG_STATE +from adapter.http.fastapi.lifespan import app_lifespan +from adapter.http.fastapi.middleware import register_middleware +from adapter.http.fastapi.routers.v1.router import router as v1_router +from fastapi import FastAPI + +API_V1_PREFIX = '/api/v1' + + +def create_app(config: AppConfig | None = None) -> FastAPI: + app_config = load_config() if config is None else config + app = FastAPI(title=app_config.app.name, lifespan=app_lifespan) + setattr(app.state, APP_CONFIG_STATE, app_config) + register_middleware(app) + app.include_router(v1_router, prefix=API_V1_PREFIX) + return app diff --git a/adapter/http/fastapi/dependencies.py b/adapter/http/fastapi/dependencies.py new file mode 100644 index 0000000..5afba58 --- /dev/null +++ b/adapter/http/fastapi/dependencies.py @@ -0,0 +1,19 @@ +from typing import cast + +from adapter.di.container import AppContainer +from fastapi import Depends, Request +from usecase.user import GetUser + +APP_CONTAINER_STATE = 'container' +APP_CONFIG_STATE = 'config' + + +def get_container(request: Request) -> AppContainer: + container = getattr(request.app.state, APP_CONTAINER_STATE, None) + if container is None: + raise RuntimeError('container unavailable') + return cast(AppContainer, container) + + +def get_get_user(container: AppContainer = Depends(get_container)) -> GetUser: + return container.usecases.get_user diff --git a/adapter/http/fastapi/lifespan.py b/adapter/http/fastapi/lifespan.py new file mode 100644 index 0000000..4c82bc1 --- /dev/null +++ b/adapter/http/fastapi/lifespan.py @@ -0,0 +1,31 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + +from adapter.di.container import build_container +from adapter.http.fastapi.dependencies import APP_CONFIG_STATE, APP_CONTAINER_STATE +from fastapi import FastAPI + + +@asynccontextmanager +async def app_lifespan(app: FastAPI) -> AsyncIterator[None]: + config = getattr(app.state, APP_CONFIG_STATE, None) + if config is None: + raise RuntimeError('config unavailable') + + container = build_container(config=config) + instrumented = False + setattr(app.state, APP_CONTAINER_STATE, container) + + try: + FastAPIInstrumentor.instrument_app( + app, + tracer_provider=container.observability.tracer_provider, + ) + instrumented = True + yield + finally: + if instrumented: + FastAPIInstrumentor.uninstrument_app(app) + container.shutdown() diff --git a/adapter/http/fastapi/middleware.py b/adapter/http/fastapi/middleware.py new file mode 100644 index 0000000..bcdd36e --- /dev/null +++ b/adapter/http/fastapi/middleware.py @@ -0,0 +1,69 @@ +from time import perf_counter + +from adapter.http.fastapi.dependencies import get_container +from fastapi import FastAPI, Request, Response + +REQUEST_COUNT = 'http.server.request.count' +REQUEST_DURATION = 'http.server.request.duration' + + +def register_middleware(app: FastAPI) -> None: + @app.middleware('http') + async def request_logging_middleware( + request: Request, + call_next, + ) -> Response: + start = perf_counter() + status_code = 500 + + try: + response = await call_next(request) + status_code = response.status_code + return response + finally: + duration_ms = (perf_counter() - start) * 1000 + container = get_container(request) + container.observability.logger.info( + 'http_request', + attrs={ + 'http.method': request.method, + 'http.path': request.url.path, + 'http.status_code': status_code, + 'http.duration_ms': duration_ms, + }, + ) + + @app.middleware('http') + async def metrics_middleware( + request: Request, + call_next, + ) -> Response: + start = perf_counter() + status_code = 500 + + try: + response = await call_next(request) + status_code = response.status_code + return response + finally: + duration_ms = (perf_counter() - start) * 1000 + container = get_container(request) + attrs: dict[str, str | int] = { + 'http.method': request.method, + 'http.path': _metric_path(request), + 'http.status_code': status_code, + } + container.observability.metrics.increment(REQUEST_COUNT, attrs=attrs) + container.observability.metrics.record( + REQUEST_DURATION, + duration_ms, + attrs=attrs, + ) + + +def _metric_path(request: Request) -> str: + route = request.scope.get('route') + path = getattr(route, 'path', None) + if isinstance(path, str) and path: + return path + return 'unmatched' diff --git a/adapter/http/fastapi/routers/v1/router.py b/adapter/http/fastapi/routers/v1/router.py new file mode 100644 index 0000000..df3d575 --- /dev/null +++ b/adapter/http/fastapi/routers/v1/router.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from adapter.di.container import AppContainer +from adapter.http.fastapi.dependencies import get_container, get_get_user +from adapter.http.fastapi.schemas import ErrorResponse, HealthResponse, UserResponse +from domain.error import UserNotFoundError +from usecase.user import GetUser, GetUserQuery + +router = APIRouter() + + +@router.get( + '/health', + response_model=HealthResponse, + status_code=status.HTTP_200_OK, +) +def health(container: AppContainer = Depends(get_container)) -> HealthResponse: + return HealthResponse( + status='ok', + app=container.config.app.name, + env=container.config.app.env, + ) + + +@router.get( + '/users/{user_id}', + response_model=UserResponse, + responses={status.HTTP_404_NOT_FOUND: {'model': ErrorResponse}}, + status_code=status.HTTP_200_OK, +) +def get_user(user_id: str, usecase: GetUser = Depends(get_get_user)) -> UserResponse: + try: + user = usecase.execute(GetUserQuery(user_id=user_id)) + except UserNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + + return UserResponse(id=user.id, email=user.email, name=user.name) diff --git a/adapter/http/fastapi/schemas.py b/adapter/http/fastapi/schemas.py new file mode 100644 index 0000000..e11c95b --- /dev/null +++ b/adapter/http/fastapi/schemas.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: str + app: str + env: str + + +class UserResponse(BaseModel): + id: str + email: str + name: str + + +class ErrorResponse(BaseModel): + detail: str diff --git a/config/app.yaml b/config/app.yaml index 79ad34e..b5c7241 100644 --- a/config/app.yaml +++ b/config/app.yaml @@ -5,7 +5,6 @@ app: http: host: 0.0.0.0 port: 8000 - api_prefix: /api/v1 logging: level: INFO diff --git a/main.py b/main.py new file mode 100644 index 0000000..8236ece --- /dev/null +++ b/main.py @@ -0,0 +1,15 @@ +import uvicorn + +from adapter.config.loader import load_config +from adapter.http.fastapi.app import create_app + +config = load_config() +app = create_app(config) + + +def run() -> None: + uvicorn.run(app, host=config.http.host, port=config.http.port) + + +if __name__ == '__main__': + run()