144 lines
5.1 KiB
Python
144 lines
5.1 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, Request, status
|
|
from fastapi import Response as FastAPIResponse
|
|
from fastapi.responses import FileResponse, Response
|
|
|
|
from .common import run_asgi
|
|
from .oauth_metadata import OAuthMetadataSettings, protected_resource_metadata
|
|
|
|
_REQUEST_EXCLUDED = frozenset({"host", "content-length", "connection"})
|
|
_RESPONSE_ALLOWED = frozenset(
|
|
{
|
|
"cache-control",
|
|
"content-type",
|
|
"expires",
|
|
"location",
|
|
"pragma",
|
|
"set-cookie",
|
|
"www-authenticate",
|
|
"x-trace-id",
|
|
}
|
|
)
|
|
_MCP_RESOURCE_PATH = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}/[a-z0-9][a-z0-9-]{0,62}$")
|
|
|
|
|
|
def build_app() -> FastAPI:
|
|
control_url = os.environ.get("HUB_CONTROL_URL", "http://control-api:8080").rstrip("/")
|
|
worker_url = os.environ.get("HUB_WORKER_URL", "http://worker:8080").rstrip("/")
|
|
static_root = Path(os.environ.get("HUB_WEB_ROOT", "/opt/hub/web-dist")).resolve()
|
|
if not (static_root / "index.html").is_file():
|
|
raise ValueError("built Hub web assets are unavailable")
|
|
oauth_metadata = OAuthMetadataSettings.from_environment()
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
|
|
application.state.control = httpx.AsyncClient(base_url=control_url, timeout=30)
|
|
application.state.worker = httpx.AsyncClient(base_url=worker_url, timeout=30)
|
|
try:
|
|
yield
|
|
finally:
|
|
await application.state.control.aclose()
|
|
await application.state.worker.aclose()
|
|
|
|
app = FastAPI(
|
|
title="ITWorx MCP Hub web",
|
|
docs_url=None,
|
|
redoc_url=None,
|
|
openapi_url=None,
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
@app.get("/healthz")
|
|
@app.get("/health")
|
|
def health() -> dict[str, str]:
|
|
return {"status": "healthy", "component": "web"}
|
|
|
|
@app.get("/ready")
|
|
@app.get("/health/ready")
|
|
async def ready(response: FastAPIResponse) -> dict[str, str]:
|
|
try:
|
|
upstream = await app.state.control.get("/ready")
|
|
if upstream.status_code != 200:
|
|
raise RuntimeError("control API not ready")
|
|
worker = await app.state.worker.get("/health/ready")
|
|
if worker.status_code != 200:
|
|
raise RuntimeError("worker not ready")
|
|
except Exception:
|
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
|
return {"status": "not-ready", "component": "web"}
|
|
return {"status": "ready", "component": "web"}
|
|
|
|
@app.get("/version")
|
|
def version() -> dict[str, str]:
|
|
return {"version": os.environ.get("HUB_RELEASE_VERSION", "1.0.0-rc")}
|
|
|
|
def protected_resource(resource_path: str) -> dict[str, object]:
|
|
if not _MCP_RESOURCE_PATH.fullmatch(resource_path):
|
|
resource_path = "gitea-all/prod"
|
|
return protected_resource_metadata(resource_path, oauth_metadata)
|
|
|
|
@app.get("/.well-known/oauth-protected-resource")
|
|
def default_oauth_protected_resource_metadata() -> dict[str, object]:
|
|
return protected_resource("gitea-all/prod")
|
|
|
|
@app.get("/.well-known/oauth-protected-resource/mcp/{project}/{environment}")
|
|
def oauth_protected_resource_metadata(project: str, environment: str) -> dict[str, object]:
|
|
return protected_resource(f"{project}/{environment}")
|
|
|
|
async def proxy(request: Request, path: str) -> Response:
|
|
headers = {
|
|
key: value
|
|
for key, value in request.headers.items()
|
|
if key.lower() not in _REQUEST_EXCLUDED
|
|
}
|
|
upstream = await app.state.control.request(
|
|
request.method,
|
|
f"/{path}",
|
|
params=request.query_params,
|
|
content=await request.body(),
|
|
headers=headers,
|
|
follow_redirects=False,
|
|
)
|
|
response_headers = {
|
|
key: value
|
|
for key, value in upstream.headers.items()
|
|
if key.lower() in _RESPONSE_ALLOWED
|
|
}
|
|
return Response(
|
|
content=upstream.content,
|
|
status_code=upstream.status_code,
|
|
headers=response_headers,
|
|
media_type=None,
|
|
)
|
|
|
|
@app.api_route("/auth/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
|
async def auth_proxy(request: Request, path: str) -> Response:
|
|
return await proxy(request, f"auth/{path}")
|
|
|
|
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
|
async def api_proxy(request: Request, path: str) -> Response:
|
|
return await proxy(request, f"api/{path}")
|
|
|
|
@app.get("/{path:path}", response_model=None)
|
|
async def static(path: str) -> FileResponse:
|
|
candidate = (static_root / path).resolve()
|
|
if candidate != static_root and static_root not in candidate.parents:
|
|
candidate = static_root / "index.html"
|
|
if not candidate.is_file():
|
|
candidate = static_root / "index.html"
|
|
return FileResponse(candidate)
|
|
|
|
return app
|
|
|
|
|
|
def run() -> None:
|
|
run_asgi(build_app())
|