[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,
|
env_values,
|
||||||
'APP_HTTP_PORT',
|
'APP_HTTP_PORT',
|
||||||
),
|
),
|
||||||
api_prefix=_yaml_or_env_str(
|
|
||||||
http_section,
|
|
||||||
'api_prefix',
|
|
||||||
'http.api_prefix',
|
|
||||||
env_values,
|
|
||||||
'APP_HTTP_API_PREFIX',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
logging=LoggingConfig(
|
logging=LoggingConfig(
|
||||||
level=_yaml_or_env_str(
|
level=_yaml_or_env_str(
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ class AppSectionConfig:
|
||||||
class HttpConfig:
|
class HttpConfig:
|
||||||
host: str
|
host: str
|
||||||
port: int
|
port: int
|
||||||
api_prefix: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,17 @@ def build_container(
|
||||||
config_path: Path | str | None = None,
|
config_path: Path | str | None = None,
|
||||||
env_path: Path | str | None = None,
|
env_path: Path | str | None = None,
|
||||||
environ: Mapping[str, str] | None = None,
|
environ: Mapping[str, str] | None = None,
|
||||||
|
config: AppConfig | None = None,
|
||||||
) -> AppContainer:
|
) -> AppContainer:
|
||||||
config = load_config(
|
app_config = config
|
||||||
|
if app_config is None:
|
||||||
|
app_config = load_config(
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
env_path=env_path,
|
env_path=env_path,
|
||||||
environ=environ,
|
environ=environ,
|
||||||
)
|
)
|
||||||
observability = setup_otel(config)
|
|
||||||
|
observability = setup_otel(app_config)
|
||||||
|
|
||||||
user_repository = InMemoryUserRepository()
|
user_repository = InMemoryUserRepository()
|
||||||
repositories = AppRepositories(user=user_repository)
|
repositories = AppRepositories(user=user_repository)
|
||||||
|
|
@ -60,7 +64,7 @@ def build_container(
|
||||||
)
|
)
|
||||||
|
|
||||||
return AppContainer(
|
return AppContainer(
|
||||||
config=config,
|
config=app_config,
|
||||||
observability=observability,
|
observability=observability,
|
||||||
repositories=repositories,
|
repositories=repositories,
|
||||||
usecases=usecases,
|
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:
|
http:
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8000
|
port: 8000
|
||||||
api_prefix: /api/v1
|
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: INFO
|
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