Initial commit: API gateway with admin console

- FastAPI async gateway with httpx proxying to multiple upstreams
- SQLite database with SQLAlchemy ORM
- Admin console: manage services, users, API keys, endpoint access
- Per-key, per-endpoint granular access control
- OpenAPI document sync and caching (5-minute TTL)
- Request/response logging with full transaction inspection
- In-memory rate limiting (per-key, fixed-window)
- Tiered log retention (7d payloads, 90d rows, incremental vacuum)
- TLS verification toggle per service (for self-signed certificates)
- Service connectivity validation with automatic endpoint refresh
- Request browser with filters and deep-link inspection
- Docker setup with persistent volume
- Modal forms for create/edit flows

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Samuel Amar
2026-07-29 14:00:22 +02:00
co-authored by Claude Haiku 4.5
commit 77d7a50fa9
31 changed files with 2927 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}Services · API Gateway{% endblock %}
{% macro trash(label) %}
<button class="icon" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
{% endmacro %}
{% macro pencil(label, modal) %}
<button class="icon edit" data-modal="{{ modal }}" title="{{ label }}" aria-label="{{ label }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
</button>
{% endmacro %}
{% block content %}
<div class="row" style="margin-bottom:20px">
<h1 style="margin:0">Connected APIs</h1>
<button class="right" data-modal="modal-new-service">+ Add service</button>
</div>
<div class="card">
<table>
<thead>
<tr><th>Name</th><th>Gateway route</th><th>Upstream</th><th>Timeout</th><th>Requests</th><th>Connectivity</th><th>On</th><th></th></tr>
</thead>
<tbody>
{% for s in services %}
<tr>
<td class="strong">{{ s.name }}</td>
<td><code>/{{ s.slug }}/…</code></td>
<td>{{ s.base_url }}</td>
<td>{{ s.timeout_seconds }}s</td>
<td>{{ counts.get(s.id, 0) }}</td>
<td style="white-space:nowrap">
<button type="button" class="ghost validate-btn" data-id="{{ s.id }}">Validate</button>
<span class="vresult" id="vresult-{{ s.id }}"></span>
</td>
<td>
<form class="inline" method="post" action="/admin/services/{{ s.id }}/toggle">
<label class="switch" title="{{ 'Turn off' if s.is_active else 'Turn on' }}">
<input type="checkbox" {% if s.is_active %}checked{% endif %} onchange="this.form.submit()">
<span class="slider"></span>
</label>
</form>
</td>
<td style="white-space:nowrap">
{{ pencil('Edit ' ~ s.name, 'modal-service-' ~ s.id) }}
<form class="inline" method="post" action="/admin/services/{{ s.id }}/delete"
data-confirm="Deletes {{ s.name }} and its endpoints">
{{ trash('Delete ' ~ s.name) }}
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8">No services registered yet.</td></tr>
{% endfor %}
</tbody>
</table>
<div class="hint">Endpoints are managed on the <a href="/admin/keys">API Keys</a> page,
where each service's catalog is kept in sync with its OpenAPI document.</div>
</div>
{% for s in services %}
<div class="modal-overlay" id="modal-service-{{ s.id }}" role="dialog" aria-modal="true" aria-label="Edit {{ s.name }}">
<div class="modal">
<h2>Edit {{ s.name }}</h2>
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<form method="post" action="/admin/services/{{ s.id }}/update" class="grid cols-2">
<div><label>Name</label><input type="text" name="name" value="{{ s.name }}" required></div>
<div><label>Base URL</label><input type="url" name="base_url" value="{{ s.base_url }}" required></div>
<div><label>Timeout (seconds)</label><input type="number" name="timeout_seconds" value="{{ s.timeout_seconds }}" step="0.5" min="1"></div>
<div><label>Description</label><input type="text" name="description" value="{{ s.description }}"></div>
<div style="grid-column:1/-1" class="checks">
<label><input type="checkbox" name="verify_tls" {% if s.verify_tls %}checked{% endif %}>
Verify TLS certificate <span class="hint" style="margin:0">(untick for self-signed / internal-CA upstreams)</span></label>
</div>
<div><button>Save changes</button></div>
</form>
</div>
</div>
{% endfor %}
<div class="modal-overlay" id="modal-new-service" role="dialog" aria-modal="true" aria-label="Register a new API">
<div class="modal">
<h2>Register a new API</h2>
<button type="button" class="icon modal-close" title="Close" aria-label="Close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<form method="post" action="/admin/services" class="grid cols-2">
<div><label>Name</label><input type="text" name="name" placeholder="Weather API" required></div>
<div><label>Slug (route segment)</label><input type="text" name="slug" placeholder="weather" pattern="[a-z0-9\-]+" required>
<div class="hint">Consumers will call <code>/&lt;slug&gt;/…</code></div></div>
<div><label>Base URL</label><input type="url" name="base_url" placeholder="https://api.example.com/v1" required></div>
<div><label>Timeout (seconds)</label><input type="number" name="timeout_seconds" value="30" step="0.5" min="1"></div>
<div style="grid-column:1/-1"><label>Description</label><input type="text" name="description" placeholder="Optional"></div>
<div style="grid-column:1/-1" class="checks">
<label><input type="checkbox" name="verify_tls" checked>
Verify TLS certificate <span class="hint" style="margin:0">(untick for self-signed / internal-CA upstreams)</span></label>
</div>
<div><button>Add service</button></div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Connectivity probe. Finding an OpenAPI document also refreshes the
// endpoint cache, so a validation doubles as an endpoint import.
async function validateService(id) {
const out = document.getElementById('vresult-' + id);
if (!out) return;
out.className = 'vresult';
out.innerHTML = '<span class="spinner"></span>';
try {
const resp = await fetch(`/admin/services/${id}/validate`, { method: 'POST' });
const d = await resp.json();
if (d.ok && d.spec_found) {
out.className = 'vresult ok';
out.textContent = `✓ reachable · ${d.endpoints} endpoints cached · ${d.latency_ms} ms`;
} else if (d.ok) {
out.className = 'vresult warn';
out.textContent = `✓ reachable (HTTP ${d.status_code}) · no OpenAPI document · ${d.latency_ms} ms`;
} else {
out.className = 'vresult err';
out.textContent = `✗ unreachable — ${d.error}`;
}
} catch {
out.className = 'vresult err';
out.textContent = '✗ validation failed';
}
}
document.querySelectorAll('.validate-btn').forEach(btn =>
btn.addEventListener('click', () => validateService(btn.dataset.id)));
// A freshly registered service is validated automatically.
const pending = new URLSearchParams(location.search).get('validate');
if (pending) {
validateService(pending);
history.replaceState(null, '', '/admin/services');
}
</script>
{% endblock %}