feat: add Tink API request logger
All checks were successful
Build and Deploy / deploy (push) Successful in 21s
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:
@@ -326,7 +326,7 @@ async def step4(request: Request):
|
|||||||
result = demo_data.MOCK_ACCOUNTS
|
result = demo_data.MOCK_ACCOUNTS
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
client = _client()
|
client = _client(log_cb=_logger(sess))
|
||||||
result = await client.list_accounts(user_token)
|
result = await client.list_accounts(user_token)
|
||||||
# Fall back to demo data if no accounts connected yet
|
# Fall back to demo data if no accounts connected yet
|
||||||
if not result.get("accounts"):
|
if not result.get("accounts"):
|
||||||
@@ -335,8 +335,7 @@ async def step4(request: Request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)
|
error = str(e)
|
||||||
|
|
||||||
return templates.TemplateResponse("step.html", {
|
return templates.TemplateResponse("step.html", _ctx(request, {
|
||||||
"request": request,
|
|
||||||
"step": 4,
|
"step": 4,
|
||||||
"title": "Konti",
|
"title": "Konti",
|
||||||
"subtitle": "Account List with Balances",
|
"subtitle": "Account List with Balances",
|
||||||
@@ -349,7 +348,7 @@ async def step4(request: Request):
|
|||||||
"is_demo": is_demo or (result == demo_data.MOCK_ACCOUNTS),
|
"is_demo": is_demo or (result == demo_data.MOCK_ACCOUNTS),
|
||||||
"next_step": 5,
|
"next_step": 5,
|
||||||
"prev_step": 3,
|
"prev_step": 3,
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -376,7 +375,7 @@ async def step5(request: Request, account_id: Optional[str] = None):
|
|||||||
result = demo_data.MOCK_TRANSACTIONS
|
result = demo_data.MOCK_TRANSACTIONS
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
client = _client()
|
client = _client(log_cb=_logger(sess))
|
||||||
result = await client.list_transactions(user_token, account_id=account_id)
|
result = await client.list_transactions(user_token, account_id=account_id)
|
||||||
if not result.get("transactions"):
|
if not result.get("transactions"):
|
||||||
result = demo_data.MOCK_TRANSACTIONS
|
result = demo_data.MOCK_TRANSACTIONS
|
||||||
@@ -384,8 +383,7 @@ async def step5(request: Request, account_id: Optional[str] = None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)
|
error = str(e)
|
||||||
|
|
||||||
return templates.TemplateResponse("step.html", {
|
return templates.TemplateResponse("step.html", _ctx(request, {
|
||||||
"request": request,
|
|
||||||
"step": 5,
|
"step": 5,
|
||||||
"title": "Transaktioner",
|
"title": "Transaktioner",
|
||||||
"subtitle": "Transaction History",
|
"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),
|
"is_demo": is_demo or (result == demo_data.MOCK_TRANSACTIONS),
|
||||||
"next_step": 6,
|
"next_step": 6,
|
||||||
"prev_step": 4,
|
"prev_step": 4,
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -445,7 +443,7 @@ async def step6(request: Request):
|
|||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
client = _client()
|
client = _client(log_cb=_logger(sess))
|
||||||
app_token_resp = await client.get_app_token(scope="user:create")
|
app_token_resp = await client.get_app_token(scope="user:create")
|
||||||
app_token = app_token_resp.get("access_token", "")
|
app_token = app_token_resp.get("access_token", "")
|
||||||
|
|
||||||
@@ -460,7 +458,6 @@ async def step6(request: Request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
err_str = str(e)
|
err_str = str(e)
|
||||||
if "404" in err_str:
|
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"}
|
result_webhooks = {"note": "Webhook API ikke tilgængeligt i sandbox — kun i produktion"}
|
||||||
webhook_registered = {
|
webhook_registered = {
|
||||||
"url": webhook_url,
|
"url": webhook_url,
|
||||||
@@ -470,8 +467,7 @@ async def step6(request: Request):
|
|||||||
else:
|
else:
|
||||||
error = err_str
|
error = err_str
|
||||||
|
|
||||||
return templates.TemplateResponse("step6.html", {
|
return templates.TemplateResponse("step6.html", _ctx(request, {
|
||||||
"request": request,
|
|
||||||
"step": 6,
|
"step": 6,
|
||||||
"title": "Webhooks & Events",
|
"title": "Webhooks & Events",
|
||||||
"subtitle": "Real-time Event Notifications",
|
"subtitle": "Real-time Event Notifications",
|
||||||
@@ -485,7 +481,28 @@ async def step6(request: Request):
|
|||||||
"app_base_url": s.app_base_url,
|
"app_base_url": s.app_base_url,
|
||||||
"next_step": None,
|
"next_step": None,
|
||||||
"prev_step": 5,
|
"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)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -48,6 +48,14 @@
|
|||||||
<code class="text-xs text-emerald-300 font-mono font-semibold">{{ session_customer }}</code>
|
<code class="text-xs text-emerald-300 font-mono font-semibold">{{ session_customer }}</code>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
<a href="/demo/reset" class="text-sm text-slate-400 hover:text-white transition">↺ Reset</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
99
src/templates/log.html
Normal file
99
src/templates/log.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user