The platform no longer exposes a public /api/* REST surface. The original HTTP route handlers are preserved verbatim as the single source of truth inside an internal, never-socket-bound FastAPI app (rpc_app), and are now reached only via RPC-over-WebSocket dispatch. - realtime router: anonymous connections allowed only to /api/auth/login and /api/auth/signup; all other RPCs/subscribes refused until token auth. - RPC dispatch re-injects the bearer token and X-Request-ID, then forwards through httpx.ASGITransport against the internal app (no network hop). - Public app now serves only /, /health, /health/ready and /api/ws; docs/openapi disabled. - Frontend: apiRequest routes /api/* to ws.rpc(); /health probes keep fetch. RealtimeClient gains rpc() with id correlation, timeout and AbortSignal. - Tests repointed to get_rpc_app(); anonymous-auth behaviour covered. Result: zero REST on the public surface; 264 backend tests + frontend typecheck pass.
152 lines
5.6 KiB
Python
152 lines
5.6 KiB
Python
"""FastAPI application factory.
|
|
|
|
Nothing in the inner layers imports FastAPI; this module wires them together.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import Base, engine
|
|
from app.interfaces.http.errors import register_exception_handlers
|
|
from app.interfaces.http.request_context import RequestContextMiddleware
|
|
from app.interfaces.http.routers import health
|
|
from app.interfaces.realtime.bus import install as install_realtime_bus
|
|
from app.interfaces.realtime.bus import start_relay, stop_relay
|
|
from app.interfaces.realtime.router import realtime_manager
|
|
from app.interfaces.realtime.router import router as realtime_router
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger("agy.api")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI):
|
|
try:
|
|
# In development we create any missing tables so `pnpm dev` works on a
|
|
# fresh clone. Production schema changes go through Alembic
|
|
# (`pnpm db:migrate`).
|
|
if not settings.is_production:
|
|
import app.infrastructure.db.models # noqa: F401 - register metadata
|
|
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
logger.info("Development schema ensured.")
|
|
logger.info(
|
|
"Adapters -> ai=%s git=%s domain=%s sandbox=%s payment=%s",
|
|
settings.ai_default_provider,
|
|
settings.git_provider,
|
|
settings.domain_router,
|
|
settings.sandbox_provider,
|
|
settings.payment_provider,
|
|
)
|
|
except Exception as exc:
|
|
# Startup failures are the hardest to diagnose after the fact: the
|
|
# process is gone and all that remains is whatever reached stderr, which
|
|
# a container runtime routinely discards. Record it, then still fail
|
|
# loudly — a half-started API must not accept traffic.
|
|
await _record_startup_failure(exc)
|
|
raise
|
|
|
|
# --- realtime wiring ---------------------------------------------------
|
|
# This process is the publisher for API-driven events and the relay for
|
|
# anything the standalone workers publish. Both are best-effort: if Redis
|
|
# is not reachable the in-process fan-out keeps every HTTP-driven feed live.
|
|
bus = install_realtime_bus()
|
|
bus.attach(realtime_manager().dispatch)
|
|
await start_relay()
|
|
|
|
yield
|
|
|
|
await stop_relay()
|
|
|
|
|
|
async def _record_startup_failure(exc: BaseException) -> None:
|
|
"""Persist a boot failure. Never raises; the caller re-raises regardless."""
|
|
try:
|
|
from app.domain.audit.models import ErrorSeverity, ErrorSource
|
|
from app.infrastructure.error_log import record_error
|
|
|
|
await record_error(
|
|
kind="startup_failed",
|
|
message=str(exc) or type(exc).__name__,
|
|
exc=exc,
|
|
source=ErrorSource.STARTUP,
|
|
severity=ErrorSeverity.CRITICAL,
|
|
metadata={"app_env": settings.app_env},
|
|
)
|
|
except Exception: # pragma: no cover - boot is already failing
|
|
logger.exception("Could not record the startup failure.")
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
"""Build the *public* app.
|
|
|
|
This is the only app uvicorn binds. It deliberately exposes no REST API: the
|
|
application surface is reachable only over the ``/api/ws`` WebSocket (live
|
|
feeds + RPC). ``/health`` stays as an HTTP liveness probe because deploy
|
|
orchestration and the browser's status check both need a plain GET, and a
|
|
probe is not an application endpoint.
|
|
"""
|
|
application = FastAPI(
|
|
title=settings.app_name,
|
|
version="0.1.0",
|
|
description="AI Workforce Platform — standalone multi-tenant SaaS.",
|
|
lifespan=lifespan,
|
|
# No REST API on the public surface, so the interactive docs are off.
|
|
docs_url=None,
|
|
openapi_url=None,
|
|
)
|
|
|
|
# Starlette applies middleware outermost-first in reverse registration
|
|
# order, so the last one added wraps everything else. The request-context
|
|
# middleware goes last on purpose: that way even a CORS rejection gets an
|
|
# ``X-Request-ID`` — and a CORS rejection is exactly the kind of opaque
|
|
# failure where a correlatable id is worth having.
|
|
application.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.cors_origin_list,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
# Without this the browser cannot read the header off a cross-origin
|
|
# response, and the id would be useless to the web client.
|
|
expose_headers=["X-Request-ID"],
|
|
)
|
|
|
|
application.add_middleware(RequestContextMiddleware)
|
|
|
|
register_exception_handlers(application)
|
|
|
|
# The only public routes: a liveness probe and the single WebSocket that
|
|
# carries every application call (live events + RPC). All HTTP handlers live
|
|
# in the internal app reached through that socket.
|
|
application.include_router(health.router)
|
|
application.include_router(realtime_router, prefix="/api")
|
|
|
|
@application.get("/", tags=["meta"])
|
|
async def root() -> dict:
|
|
return {
|
|
"name": settings.app_name,
|
|
"version": "0.1.0",
|
|
"health": "/health",
|
|
"websocket": "/api/ws",
|
|
"note": (
|
|
"All application calls travel over the WebSocket at /api/ws "
|
|
"(live feeds + RPC). There is no REST API on this server."
|
|
),
|
|
}
|
|
|
|
return application
|
|
|
|
|
|
app = create_app()
|