[feat] add fastapi adapter

This commit is contained in:
Azamat 2026-03-20 15:06:39 +03:00
parent 05543bbbbb
commit a930185754
12 changed files with 220 additions and 16 deletions

View file

@ -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(

View file

@ -11,7 +11,6 @@ class AppSectionConfig:
class HttpConfig:
host: str
port: int
api_prefix: str
@dataclass(frozen=True, slots=True)

View file

@ -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,

View file

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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'

View file

@ -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)

View file

@ -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

View file

@ -5,7 +5,6 @@ app:
http:
host: 0.0.0.0
port: 8000
api_prefix: /api/v1
logging:
level: INFO

15
main.py Normal file
View file

@ -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()