from __future__ import annotations from collections.abc import AsyncIterator, Callable from contextlib import AsyncExitStack, asynccontextmanager, suppress import logging from uuid import uuid4 import uvicorn from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from starlette.concurrency import run_in_threadpool from qonto_assistant import __version__ from qonto_assistant.audit import AuditLogger from qonto_assistant.auth import actor_claims_from_request from qonto_assistant.config import Settings from qonto_assistant.credentials import build_credential_provider from qonto_assistant.errors import QontoAssistantError, UpstreamError from qonto_assistant.mcp_auth import BearerTokenAuthMiddleware from qonto_assistant.mcp_server import create_mcp_server from qonto_assistant.policy import PolicyEngine from qonto_assistant.qonto_client import FixtureQontoClient, QontoClient from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter from qonto_assistant.service import CapabilityService def create_app( *, settings: Settings | None = None, service: CapabilityService | None = None, audit_logger: AuditLogger | None = None, rate_limiter: RateLimiter | None = None, concurrency_limiter: ConcurrencyLimiter | None = None, ) -> FastAPI: settings = settings or Settings.from_env() audit_logger = audit_logger or AuditLogger() service = service or _build_service( settings=settings, audit_logger=audit_logger, rate_limiter=rate_limiter, concurrency_limiter=concurrency_limiter, ) mcp_server = create_mcp_server(settings=settings, service=service) mcp_app = mcp_server.streamable_http_app() if settings.mcp_auth_token: mcp_app.add_middleware(BearerTokenAuthMiddleware, token=settings.mcp_auth_token) @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: async with AsyncExitStack() as stack: await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app)) yield with suppress(Exception): service.client.close() app = FastAPI(title="qonto-assistant", version=__version__, lifespan=lifespan) app.state.settings = settings app.state.service = service app.mount("/mcp", mcp_app) @app.exception_handler(QontoAssistantError) def handle_qonto_error(_: Request, exc: QontoAssistantError) -> JSONResponse: body = {"error_code": exc.error_code, "detail": exc.message} if isinstance(exc, UpstreamError) and exc.upstream_status is not None: body["upstream_status"] = exc.upstream_status return JSONResponse(status_code=exc.status_code, content=body) @app.get("/v1/health") async def health() -> dict[str, str]: return { "status": "ok", "service": settings.service_name, "version": __version__, "policy_file": str(settings.policy_file), } @app.get("/v1/accounts") async def get_accounts(request: Request) -> JSONResponse: claims = actor_claims_from_request(request, settings) request_id = _request_id(request) payload = await run_in_threadpool(service.get_accounts, claims=claims, request_id=request_id) return JSONResponse(content=payload, headers={"X-Request-ID": request_id}) @app.get("/v1/transactions") async def get_transactions( request: Request, account_slug: str | None = None, page: int = 1, page_size: int = 50, window_days: int = 31, status: str | None = "completed", side: str | None = None, ) -> JSONResponse: claims = actor_claims_from_request(request, settings) request_id = _request_id(request) payload = await run_in_threadpool( service.list_transactions, claims=claims, request_id=request_id, account_slug=account_slug, page=page, page_size=page_size, window_days=window_days, status=status, side=side, ) return JSONResponse(content=payload, headers={"X-Request-ID": request_id}) @app.get("/v1/snapshot") async def get_snapshot( request: Request, window_days: int = 31, page_size: int = 50, ) -> JSONResponse: claims = actor_claims_from_request(request, settings) request_id = _request_id(request) payload = await run_in_threadpool( service.get_snapshot, claims=claims, request_id=request_id, window_days=window_days, page_size=page_size, ) return JSONResponse(content=payload, headers={"X-Request-ID": request_id}) return app def _request_id(request: Request) -> str: existing = getattr(request.state, "request_id", None) if existing: return existing request.state.request_id = request.headers.get("x-request-id", str(uuid4())) return request.state.request_id def _build_service( *, settings: Settings, audit_logger: AuditLogger, rate_limiter: RateLimiter | None, concurrency_limiter: ConcurrencyLimiter | None, ) -> CapabilityService: policy = PolicyEngine.from_file( settings.policy_file, required_scope=settings.required_scope, enforce_scope=settings.enforce_scope, ) if settings.qonto_fixture_dir is not None: client = FixtureQontoClient(fixture_dir=settings.qonto_fixture_dir) else: credential_provider = build_credential_provider(settings) client = QontoClient( base_url=settings.qonto_base_url, organization_path=settings.qonto_organization_path, transactions_path=settings.qonto_transactions_path, auth_mode=settings.qonto_auth_mode, timeout_seconds=settings.qonto_timeout_seconds, max_retries=settings.qonto_max_retries, credential_provider=credential_provider, ) return CapabilityService( client=client, policy=policy, audit_logger=audit_logger, rate_limiter=rate_limiter or RateLimiter( limit=settings.rate_limit_requests, window_seconds=settings.rate_limit_window_seconds, ), concurrency_limiter=concurrency_limiter or ConcurrencyLimiter(limit=settings.max_concurrency), ) def main() -> None: settings = Settings.from_env() logging.basicConfig(level=logging.INFO, format="%(message)s") uvicorn.run( "qonto_assistant.app:create_app", factory=True, host=settings.host, port=settings.port, reload=False, )