Buyer API Explorer
+Select an operation or send /health.+
diff --git a/src/ad_buyer/interfaces/api/buyer_ui.py b/src/ad_buyer/interfaces/api/buyer_ui.py new file mode 100644 index 00000000..c77d1d3a --- /dev/null +++ b/src/ad_buyer/interfaces/api/buyer_ui.py @@ -0,0 +1,359 @@ +"""Buyer-owned UI shell and API explorer.""" + +from __future__ import annotations + +import json +from typing import Any, Literal + +import httpx +from fastapi import APIRouter, FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, Response +from pydantic import BaseModel, Field + + +class BuyerApiExplorerRequest(BaseModel): + """Proxy request generated by the buyer API explorer.""" + + method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] + path: str = Field(..., min_length=1) + headers: dict[str, str] = Field(default_factory=dict) + query: dict[str, str | list[str]] = Field(default_factory=dict) + body: Any = None + + +def install_buyer_ui(app: FastAPI) -> None: + """Mount the buyer UI routes on the FastAPI app.""" + + router = APIRouter(include_in_schema=False) + + @router.get("/buyer", response_class=HTMLResponse) + async def buyer_shell() -> HTMLResponse: + return HTMLResponse(_buyer_html()) + + @router.get("/buyer/openapi-client.js") + async def buyer_openapi_client(request: Request) -> Response: + openapi = request.app.openapi() + operations = _openapi_operations(openapi) + script = _openapi_client_script(operations) + return Response(script, media_type="application/javascript; charset=utf-8") + + @router.post("/buyer/api/proxy") + async def buyer_api_proxy(payload: BuyerApiExplorerRequest, request: Request) -> Response: + path = _clean_proxy_path(payload.path) + headers = _proxy_headers(payload.headers, request) + query_params = _query_param_pairs(payload.query) + transport = httpx.ASGITransport(app=request.app) + + async with httpx.AsyncClient( + transport=transport, + base_url=str(request.base_url).rstrip("/"), + ) as client: + proxied = await client.request( + payload.method, + path, + params=query_params, + headers=headers, + json=payload.body if payload.body is not None else None, + ) + + response_headers = {} + if request_id := proxied.headers.get("x-request-id"): + response_headers["x-request-id"] = request_id + return Response( + content=proxied.content, + status_code=proxied.status_code, + headers=response_headers, + media_type=proxied.headers.get("content-type", "application/json"), + ) + + app.include_router(router) + + +def _clean_proxy_path(path: str) -> str: + path = path.strip() + if "://" in path or path.startswith("//"): + raise HTTPException(status_code=400, detail="Proxy path must be app-relative") + if not path.startswith("/"): + path = f"/{path}" + if path.startswith("/buyer/api/proxy"): + raise HTTPException(status_code=400, detail="Proxy recursion is not allowed") + return path + + +def _proxy_headers(headers: dict[str, str], request: Request) -> dict[str, str]: + allowed = {"accept", "content-type", "x-api-key"} + proxied = { + key: value + for key, value in headers.items() + if key.lower() in allowed and "\n" not in key and "\n" not in value + } + if "x-api-key" not in {key.lower() for key in proxied}: + api_key = request.headers.get("x-api-key") + if api_key: + proxied["x-api-key"] = api_key + return proxied + + +def _query_param_pairs(query: dict[str, str | list[str]]) -> list[tuple[str, str]]: + pairs: list[tuple[str, str]] = [] + for key, value in query.items(): + values = value if isinstance(value, list) else [value] + pairs.extend((key, item) for item in values) + return pairs + + +def _openapi_operations(openapi: dict[str, Any]) -> list[dict[str, Any]]: + operations: list[dict[str, Any]] = [] + for path, path_item in sorted(openapi.get("paths", {}).items()): + for method, operation in sorted(path_item.items()): + if method.upper() not in {"GET", "POST", "PUT", "PATCH", "DELETE"}: + continue + operations.append( + { + "method": method.upper(), + "path": path, + "operationId": operation.get("operationId"), + "summary": operation.get("summary") or operation.get("operationId") or path, + "tags": operation.get("tags", []), + "parameters": operation.get("parameters", []), + "requestBody": operation.get("requestBody"), + } + ) + return operations + + +def _openapi_client_script(operations: list[dict[str, Any]]) -> str: + operations_json = json.dumps(operations, separators=(",", ":")) + return f""" +window.buyerOpenApi = {{ operations: {operations_json} }}; +window.buyerApiClient = {{ + async request({{ method, path, headers = {{}}, query = {{}}, body = null }}) {{ + const response = await fetch('/buyer/api/proxy', {{ + method: 'POST', + headers: {{ 'content-type': 'application/json' }}, + body: JSON.stringify({{ method, path, headers, query, body }}), + }}); + const contentType = response.headers.get('content-type') || ''; + const text = await response.text(); + let data = text; + if (contentType.includes('application/json') && text) {{ + try {{ data = JSON.parse(text); }} catch (error) {{ data = text; }} + }} + return {{ ok: response.ok, status: response.status, headers: contentType, data }}; + }}, +}}; +""".strip() + + +def _buyer_html() -> str: + return """ + +
+ + +Select an operation or send /health.+