feat: add Tink API request logger
All checks were successful
Build and Deploy / deploy (push) Successful in 21s

- TinkClient now accepts on_request callback; all API methods log via
  shared _get/_post helpers (method, url, req/resp body, status, timing)
- _logger(sess) helper creates a session-bound callback (max 50 entries)
- All route handlers pass _logger(sess) to _client()
- New GET /demo/log — shows all API calls in reverse-chronological order,
  collapsible req/resp bodies, status + duration badges
- New POST /demo/log/clear — clears the log
- Navbar gets 'API Log' link (always visible)
- _ctx() now applied to ALL template responses (steps 1–6)
This commit is contained in:
Henrik Jess Nielsen
2026-05-22 19:20:33 +02:00
parent a77c709d4d
commit 5e14a219b1
3 changed files with 137 additions and 13 deletions

View File

@@ -326,7 +326,7 @@ async def step4(request: Request):
result = demo_data.MOCK_ACCOUNTS
else:
try:
client = _client()
client = _client(log_cb=_logger(sess))
result = await client.list_accounts(user_token)
# Fall back to demo data if no accounts connected yet
if not result.get("accounts"):
@@ -335,8 +335,7 @@ async def step4(request: Request):
except Exception as e:
error = str(e)
return templates.TemplateResponse("step.html", {
"request": request,
return templates.TemplateResponse("step.html", _ctx(request, {
"step": 4,
"title": "Konti",
"subtitle": "Account List with Balances",
@@ -349,7 +348,7 @@ async def step4(request: Request):
"is_demo": is_demo or (result == demo_data.MOCK_ACCOUNTS),
"next_step": 5,
"prev_step": 3,
})
}))
# ---------------------------------------------------------------------------
@@ -376,7 +375,7 @@ async def step5(request: Request, account_id: Optional[str] = None):
result = demo_data.MOCK_TRANSACTIONS
else:
try:
client = _client()
client = _client(log_cb=_logger(sess))
result = await client.list_transactions(user_token, account_id=account_id)
if not result.get("transactions"):
result = demo_data.MOCK_TRANSACTIONS
@@ -384,8 +383,7 @@ async def step5(request: Request, account_id: Optional[str] = None):
except Exception as e:
error = str(e)
return templates.TemplateResponse("step.html", {
"request": request,
return templates.TemplateResponse("step.html", _ctx(request, {
"step": 5,
"title": "Transaktioner",
"subtitle": "Transaction History",
@@ -398,7 +396,7 @@ async def step5(request: Request, account_id: Optional[str] = None):
"is_demo": is_demo or (result == demo_data.MOCK_TRANSACTIONS),
"next_step": 6,
"prev_step": 4,
})
}))
# ---------------------------------------------------------------------------
@@ -445,7 +443,7 @@ async def step6(request: Request):
}
else:
try:
client = _client()
client = _client(log_cb=_logger(sess))
app_token_resp = await client.get_app_token(scope="user:create")
app_token = app_token_resp.get("access_token", "")
@@ -460,7 +458,6 @@ async def step6(request: Request):
except Exception as e:
err_str = str(e)
if "404" in err_str:
# Sandbox doesn't expose webhook management — show sample data instead
result_webhooks = {"note": "Webhook API ikke tilgængeligt i sandbox — kun i produktion"}
webhook_registered = {
"url": webhook_url,
@@ -470,8 +467,7 @@ async def step6(request: Request):
else:
error = err_str
return templates.TemplateResponse("step6.html", {
"request": request,
return templates.TemplateResponse("step6.html", _ctx(request, {
"step": 6,
"title": "Webhooks & Events",
"subtitle": "Real-time Event Notifications",
@@ -485,7 +481,28 @@ async def step6(request: Request):
"app_base_url": s.app_base_url,
"next_step": None,
"prev_step": 5,
})
}))
# ---------------------------------------------------------------------------
# API Request Log
# ---------------------------------------------------------------------------
@router.get("/demo/log", response_class=HTMLResponse)
async def api_log(request: Request):
sess = _session(request)
log = sess.get("api_log", [])
return templates.TemplateResponse("log.html", _ctx(request, {
"log": list(reversed(log)), # newest first
"log_count": len(log),
}))
@router.post("/demo/log/clear")
async def clear_log(request: Request):
sess = _session(request)
sess["api_log"] = []
return RedirectResponse("/demo/log", status_code=303)
# ---------------------------------------------------------------------------

View File

@@ -48,6 +48,14 @@
<code class="text-xs text-emerald-300 font-mono font-semibold">{{ session_customer }}</code>
</div>
{% endif %}
<a href="/demo/log"
class="text-sm text-slate-400 hover:text-violet-300 transition flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
API Log
</a>
<a href="/demo/reset" class="text-sm text-slate-400 hover:text-white transition">↺ Reset</a>
</div>
</div>

99
src/templates/log.html Normal file
View File

@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}API Log — Tink Demo{% endblock %}
{% block content %}
<div class="max-w-5xl mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">📋 API Request Log</h1>
<p class="text-slate-400 text-sm mt-1">Alle Tink API-kald i denne session</p>
</div>
<div class="flex items-center gap-3">
<span class="text-slate-400 text-sm">{{ log_count }} kald registreret</span>
{% if log_count > 0 %}
<form method="post" action="/demo/log/clear">
<button type="submit"
class="text-xs px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 text-slate-300 transition">
Ryd log
</button>
</form>
{% endif %}
<a href="/demo/step/1"
class="text-xs px-3 py-1.5 rounded bg-violet-600 hover:bg-violet-500 text-white transition">
← Tilbage til demo
</a>
</div>
</div>
{% if log_count == 0 %}
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-12 text-center">
<div class="text-4xl mb-4">📭</div>
<p class="text-slate-400">Ingen API-kald endnu.</p>
<p class="text-slate-500 text-sm mt-1">Gå igennem demo-steppene for at se kaldene her.</p>
<a href="/demo/step/1" class="inline-block mt-4 px-4 py-2 rounded-lg bg-violet-600 hover:bg-violet-500 text-white text-sm transition">
Start fra Step 1
</a>
</div>
{% else %}
<div class="space-y-3">
{% for entry in log %}
<div class="rounded-xl border {% if entry.ok %}border-emerald-700/40 bg-emerald-900/10{% else %}border-red-700/40 bg-red-900/10{% endif %} overflow-hidden">
{# Header row #}
<button onclick="this.nextElementSibling.classList.toggle('hidden')"
class="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-white/5 transition">
{# Method badge #}
<span class="text-xs font-mono font-bold px-2 py-0.5 rounded
{% if entry.method == 'GET' %}bg-blue-500/20 text-blue-300 border border-blue-500/30
{% else %}bg-violet-500/20 text-violet-300 border border-violet-500/30{% endif %}">
{{ entry.method }}
</span>
{# Status badge #}
<span class="text-xs font-mono px-2 py-0.5 rounded
{% if entry.ok %}bg-emerald-500/20 text-emerald-300 border border-emerald-500/30
{% else %}bg-red-500/20 text-red-300 border border-red-500/30{% endif %}">
{{ entry.status }}
</span>
{# URL — strip domain for cleanliness #}
<span class="font-mono text-sm text-slate-200 flex-1 truncate">
{{ entry.url | replace("https://api.tink.com", "") }}
</span>
{# Time + duration #}
<span class="text-xs text-slate-500 font-mono">{{ entry.ts }}</span>
<span class="text-xs text-slate-500 font-mono">{{ entry.duration_ms }}ms</span>
<svg class="w-4 h-4 text-slate-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{# Collapsible body — hidden by default #}
<div class="hidden border-t {% if entry.ok %}border-emerald-700/20{% else %}border-red-700/20{% endif %} px-4 py-3 grid grid-cols-1 md:grid-cols-2 gap-4">
{% if entry.req_body %}
<div>
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Request</div>
<pre class="text-xs font-mono text-slate-300 bg-slate-900/60 rounded p-3 overflow-auto max-h-48 whitespace-pre-wrap">{{ entry.req_body | tojson(indent=2) }}</pre>
</div>
{% endif %}
<div {% if not entry.req_body %}class="md:col-span-2"{% endif %}>
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Response</div>
<pre class="text-xs font-mono text-slate-300 bg-slate-900/60 rounded p-3 overflow-auto max-h-48 whitespace-pre-wrap">{{ entry.resp_body | tojson(indent=2) }}</pre>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}