[feat] add fastapi adapter
This commit is contained in:
parent
05543bbbbb
commit
a930185754
12 changed files with 220 additions and 16 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ class AppSectionConfig:
|
|||
class HttpConfig:
|
||||
host: str
|
||||
port: int
|
||||
api_prefix: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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(config)
|
||||
|
||||
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,
|
||||
|
|
|
|||
0
adapter/http/fastapi/__init__.py
Normal file
0
adapter/http/fastapi/__init__.py
Normal file
18
adapter/http/fastapi/app.py
Normal file
18
adapter/http/fastapi/app.py
Normal 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
|
||||
19
adapter/http/fastapi/dependencies.py
Normal file
19
adapter/http/fastapi/dependencies.py
Normal 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
|
||||
31
adapter/http/fastapi/lifespan.py
Normal file
31
adapter/http/fastapi/lifespan.py
Normal 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()
|
||||
69
adapter/http/fastapi/middleware.py
Normal file
69
adapter/http/fastapi/middleware.py
Normal 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'
|
||||
40
adapter/http/fastapi/routers/v1/router.py
Normal file
40
adapter/http/fastapi/routers/v1/router.py
Normal 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)
|
||||
17
adapter/http/fastapi/schemas.py
Normal file
17
adapter/http/fastapi/schemas.py
Normal 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
|
||||
|
|
@ -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
15
main.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue