Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
359 changes: 359 additions & 0 deletions src/ad_buyer/interfaces/api/buyer_ui.py
Original file line number Diff line number Diff line change
@@ -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 """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ad Buyer Agent</title>
<style>
:root { color-scheme: light; --ink: #172033; --muted: #5b6678; --line: #d7dde8;
--surface: #f6f8fb; --accent: #0f766e; --accent-strong: #0a5f59;
--warn: #9a3412; --code: #101827; }
* { box-sizing: border-box; }
body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
color: var(--ink); background: #ffffff; }
header { display: flex; align-items: center; justify-content: space-between; gap: 24px;
padding: 18px 28px; border-bottom: 1px solid var(--line); background: #ffffff;
position: sticky; top: 0; z-index: 10; }
h1 { font-size: 22px; line-height: 1.2; margin: 0; letter-spacing: 0; }
h2 { font-size: 18px; margin: 24px 0 12px; letter-spacing: 0; }
nav { display: flex; gap: 8px; flex-wrap: wrap; }
nav a, button { border: 1px solid var(--line); background: #ffffff; color: var(--ink);
border-radius: 6px; padding: 8px 10px; font: inherit; cursor: pointer;
text-decoration: none; min-height: 38px; }
nav a:hover, button:hover { border-color: var(--accent); color: var(--accent-strong); }
main { display: grid; grid-template-columns: minmax(280px, 380px) minmax(0, 1fr);
min-height: calc(100vh - 75px); }
aside { border-right: 1px solid var(--line); background: var(--surface); padding: 20px;
overflow: auto; }
section { padding: 20px 24px; min-width: 0; }
label { display: block; font-size: 13px; font-weight: 650; color: var(--muted); }
input, textarea, select { width: 100%; border: 1px solid var(--line); border-radius: 6px;
padding: 9px 10px; font: inherit; background: #ffffff; color: var(--ink); }
textarea { min-height: 132px; resize: vertical; font-family: ui-monospace, SFMono-Regular,
Menlo, Consolas, monospace; }
.overview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.metric, .operation { border: 1px solid var(--line); background: #ffffff;
border-radius: 8px; padding: 12px; }
.metric b { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }
.metric span { display: block; font-size: 18px; font-weight: 750; overflow-wrap: anywhere; }
.toolbar { display: flex; gap: 10px; align-items: center; margin: 0 0 16px; flex-wrap: wrap; }
.operation { width: 100%; text-align: left; margin-bottom: 8px; min-height: 72px; }
.operation strong { display: block; font-size: 13px; margin-bottom: 4px; overflow-wrap: anywhere; }
.operation span { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }
.selected { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }
.form-grid { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 12px;
align-items: start; }
.param-grid { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(120px, 1fr);
gap: 8px; margin-bottom: 8px; }
.response { border: 1px solid var(--line); border-radius: 8px; padding: 14px;
background: var(--code); color: #f6f8fb; overflow: auto; min-height: 230px;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
white-space: pre-wrap; }
.warning { color: var(--warn); }
@media (max-width: 900px) { header, main { display: block; } aside { border-right: 0;
border-bottom: 1px solid var(--line); } .overview, .form-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header>
<h1>Ad Buyer Agent</h1>
<nav aria-label="Buyer API documentation">
<a href="/docs" target="_blank" rel="noreferrer">Swagger</a>
<a href="/redoc" target="_blank" rel="noreferrer">ReDoc</a>
<a href="/openapi.json" target="_blank" rel="noreferrer">OpenAPI</a>
<a href="/mcp" target="_blank" rel="noreferrer">MCP</a>
</nav>
</header>
<main>
<aside>
<div class="toolbar">
<input id="search" type="search" placeholder="Filter buyer endpoints">
</div>
<div id="operations"></div>
</aside>
<section>
<div class="overview" aria-label="Buyer overview">
<div class="metric"><b>Buyer API</b><span id="api-health">checking</span></div>
<div class="metric"><b>Version</b><span id="api-version">unknown</span></div>
<div class="metric"><b>Operations</b><span id="operation-count">0</span></div>
<div class="metric"><b>Selected</b><span id="selected-operation">none</span></div>
</div>
<h2 id="operation-title">Buyer API Explorer</h2>
<div class="form-grid">
<label for="method">Method</label><select id="method"></select>
<label for="path">Path</label><input id="path" value="/health">
<label for="api-key">API Key</label><input id="api-key" type="password" autocomplete="off">
<label for="query">Query Parameters</label><div id="query"></div>
<label for="headers">Extra Headers JSON</label><textarea id="headers" spellcheck="false">{}</textarea>
<label for="body">JSON Body</label><textarea id="body" spellcheck="false"></textarea>
</div>
<div class="toolbar">
<button id="send">Send request</button>
<button id="add-query">Add query</button>
<button id="health-refresh">Refresh health</button>
</div>
<pre id="response" class="response">Select an operation or send /health.</pre>
</section>
</main>
<script src="/buyer/openapi-client.js"></script>
<script>
const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
const method = document.querySelector('#method');
const path = document.querySelector('#path');
const apiKey = document.querySelector('#api-key');
const body = document.querySelector('#body');
const headers = document.querySelector('#headers');
const response = document.querySelector('#response');
const operations = document.querySelector('#operations');
const search = document.querySelector('#search');
const query = document.querySelector('#query');
methods.forEach((name) => method.append(new Option(name, name)));

function setResponse(value) {
response.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}

function addQueryRow(name = '', value = '') {
const row = document.createElement('div');
row.className = 'param-grid';
row.innerHTML = '<input aria-label="Query name" placeholder="name">' +
'<input aria-label="Query value" placeholder="value">';
row.children[0].value = name;
row.children[1].value = value;
query.append(row);
}

function currentQuery() {
const params = {};
for (const row of query.querySelectorAll('.param-grid')) {
const [name, value] = row.querySelectorAll('input');
if (name.value.trim()) params[name.value.trim()] = value.value;
}
return params;
}

function requestHeaders() {
const parsed = headers.value.trim() ? JSON.parse(headers.value) : {};
if (apiKey.value) parsed['x-api-key'] = apiKey.value;
return parsed;
}

function renderOperations() {
const filter = search.value.toLowerCase();
operations.replaceChildren();
const items = window.buyerOpenApi.operations.filter((operation) => {
const searchable = `${operation.method} ${operation.path} ${operation.summary} ` +
`${(operation.tags || []).join(' ')}`;
return searchable.toLowerCase().includes(filter);
});
document.querySelector('#operation-count').textContent = String(items.length);
for (const operation of items) {
const button = document.createElement('button');
button.className = 'operation';
button.innerHTML = `<strong>${operation.method} ${operation.path}</strong>` +
`<span>${operation.summary}</span>`;
button.addEventListener('click', () => selectOperation(operation, button));
operations.append(button);
}
}

function selectOperation(operation, button) {
operations.querySelectorAll('.operation').forEach((item) => item.classList.remove('selected'));
button.classList.add('selected');
method.value = operation.method;
path.value = operation.path;
document.querySelector('#operation-title').textContent = operation.summary;
document.querySelector('#selected-operation').textContent = `${operation.method} ${operation.path}`;
query.replaceChildren();
for (const parameter of operation.parameters || []) {
if (parameter.in === 'query') addQueryRow(parameter.name, '');
}
if (!query.children.length) addQueryRow();
body.value = operation.requestBody ? '{\\n \\n}' : '';
}

async function refreshHealth() {
try {
const result = await window.buyerApiClient.request({ method: 'GET', path: '/health' });
document.querySelector('#api-health').textContent = result.ok ? result.data.status : 'unavailable';
document.querySelector('#api-version').textContent = result.data.version || 'unknown';
} catch (error) {
document.querySelector('#api-health').textContent = 'unavailable';
document.querySelector('#api-version').textContent = 'unknown';
}
}

async function sendRequest() {
try {
const bodyText = body.value.trim();
const result = await window.buyerApiClient.request({
method: method.value,
path: path.value,
headers: requestHeaders(),
query: currentQuery(),
body: bodyText ? JSON.parse(bodyText) : null,
});
setResponse({ status: result.status, ok: result.ok, data: result.data });
} catch (error) {
setResponse(`Request failed: ${error.message}`);
}
}

document.querySelector('#send').addEventListener('click', sendRequest);
document.querySelector('#health-refresh').addEventListener('click', refreshHealth);
document.querySelector('#add-query').addEventListener('click', () => addQueryRow());
search.addEventListener('input', renderOperations);
addQueryRow();
renderOperations();
refreshHealth();
</script>
</body>
</html>"""
14 changes: 13 additions & 1 deletion src/ad_buyer/interfaces/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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."""
Expand Down
Loading