From 66b486510b9073903e38c0dd6382ae8accb0def3 Mon Sep 17 00:00:00 2001 From: Paperclip Developer Date: Mon, 6 Jul 2026 17:56:54 +0000 Subject: [PATCH] Build buyer UI shell and API explorer --- src/ad_buyer/interfaces/api/buyer_ui.py | 359 ++++++++++++++++++++++++ src/ad_buyer/interfaces/api/main.py | 14 +- tests/unit/test_buyer_ui.py | 112 ++++++++ 3 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 src/ad_buyer/interfaces/api/buyer_ui.py create mode 100644 tests/unit/test_buyer_ui.py 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 """ + + + + + Ad Buyer Agent + + + +
+

Ad Buyer Agent

+ +
+
+ +
+
+
Buyer APIchecking
+
Versionunknown
+
Operations0
+
Selectednone
+
+

Buyer API Explorer

+
+ + + +
+ + +
+
+ + + +
+
Select an operation or send /health.
+
+
+ + + +""" diff --git a/src/ad_buyer/interfaces/api/main.py b/src/ad_buyer/interfaces/api/main.py index 457a3c5f..ee0768b5 100644 --- a/src/ad_buyer/interfaces/api/main.py +++ b/src/ad_buyer/interfaces/api/main.py @@ -125,7 +125,15 @@ def _mount_order_router() -> None: _mount_order_router() # Paths that never require authentication -_PUBLIC_PATHS = {"/health", "/docs", "/openapi.json", "/redoc"} +_PUBLIC_PATHS = { + "/health", + "/docs", + "/openapi.json", + "/redoc", + "/buyer", + "/buyer/openapi-client.js", + "/buyer/api/proxy", +} @app.middleware("http") @@ -206,6 +214,7 @@ def _get_order_store() -> OrderStore | None: # Mount buyer order status/audit endpoints +from .buyer_ui import install_buyer_ui # noqa: E402 from .order_endpoints import create_order_router as _create_order_router # noqa: E402 @@ -238,6 +247,9 @@ def _persist_job(job_id: str, job: dict[str, Any]) -> None: logger.exception("Failed to persist job %s", job_id) +install_buyer_ui(app) + + # Request/Response Models class CampaignBrief(BaseModel): """Campaign brief for booking.""" diff --git a/tests/unit/test_buyer_ui.py b/tests/unit/test_buyer_ui.py new file mode 100644 index 00000000..6a20222b --- /dev/null +++ b/tests/unit/test_buyer_ui.py @@ -0,0 +1,112 @@ +"""Tests for the buyer UI shell and API explorer.""" + +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from ad_buyer.config.settings import Settings +from ad_buyer.interfaces.api import main as api_module +from ad_buyer.interfaces.api.buyer_ui import _clean_proxy_path, _query_param_pairs + + +def _client() -> TestClient: + return TestClient(api_module.app) + + +def _make_settings(api_key: str = "") -> Settings: + return Settings.model_construct( + api_key=api_key, + anthropic_api_key="", + iab_server_url="http://localhost:8001", + seller_endpoints="", + opendirect_base_url="http://localhost:3000/api/v2.1", + opendirect_token=None, + opendirect_api_key=None, + default_llm_model="anthropic/claude-sonnet-4-5-20250929", + manager_llm_model="anthropic/claude-opus-4-20250514", + llm_temperature=0.3, + llm_max_tokens=4096, + database_url="sqlite:///./ad_buyer.db", + redis_url=None, + crew_memory_enabled=True, + crew_verbose=True, + crew_max_iterations=15, + cors_allowed_origins="", + environment="development", + log_level="INFO", + ) + + +def _patch_settings(api_key: str): + return patch.object(api_module, "settings", _make_settings(api_key)) + + +def test_buyer_shell_links_to_docs_and_generated_client(): + response = _client().get("/buyer") + + assert response.status_code == 200 + assert "Ad Buyer Agent" in response.text + assert 'href="/docs"' in response.text + assert 'href="/redoc"' in response.text + assert 'href="/openapi.json"' in response.text + assert 'src="/buyer/openapi-client.js"' in response.text + assert "Buyer API Explorer" in response.text + + +def test_buyer_openapi_client_exports_operations(): + response = _client().get("/buyer/openapi-client.js") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/javascript") + assert "window.buyerOpenApi" in response.text + assert '"path":"/health"' in response.text + assert "window.buyerApiClient" in response.text + + +def test_buyer_proxy_calls_app_relative_endpoint(): + response = _client().post( + "/buyer/api/proxy", + json={"method": "GET", "path": "/health"}, + ) + + assert response.status_code == 200 + assert response.json() == {"status": "healthy", "version": "1.0.0"} + + +def test_buyer_proxy_forwards_api_key_to_protected_endpoints(): + with _patch_settings("test-secret-key"): + unauthorized = _client().post( + "/buyer/api/proxy", + json={"method": "GET", "path": "/bookings"}, + ) + authorized = _client().post( + "/buyer/api/proxy", + json={ + "method": "GET", + "path": "/bookings", + "headers": {"x-api-key": "test-secret-key"}, + }, + ) + + assert unauthorized.status_code == 401 + assert authorized.status_code == 200 + + +def test_clean_proxy_path_rejects_absolute_and_recursive_paths(): + assert _clean_proxy_path("health") == "/health" + + for path in ("https://example.test/health", "//example.test/health", "/buyer/api/proxy"): + try: + _clean_proxy_path(path) + except Exception as exc: # noqa: BLE001 - FastAPI raises HTTPException here + assert getattr(exc, "status_code") == 400 + else: + raise AssertionError(f"Expected {path} to be rejected") + + +def test_query_param_pairs_preserves_repeated_values(): + assert _query_param_pairs({"tag": ["a", "b"], "limit": "2"}) == [ + ("tag", "a"), + ("tag", "b"), + ("limit", "2"), + ]