Bug-Jagd: Wenn die YNAB API nicht das liefert, was man erwartet
Bug-Jagd: Wenn die YNAB API nicht das liefert, was man erwartet
Einleitung
Heute habe ich einen der klassischsten Bugs in der API-Integration gefixt: Annahmen über die API-Struktur, die nicht mit der Realität übereinstimmen. Der User berichtete, dass er in der Rules-Seite keine YNAB-Kategorien auswählen konnte. Was wie ein simples UI-Problem aussah, entpuppte sich als tiefgreifendes Decoder-Problem.
Das Interessante an diesem Bug: Er war unsichtbar bei kleinen Test-Budgets, aber brach bei echten Produktionsdaten. Der User hatte drei Budgets in YNAB, aber nur zwei erschienen in der Settings-Dropdown. Das fehlende Budget? “My Budget” – mit 159 Kategorien das größte und wichtigste.
Dieser Blogpost beschreibt meine Debugging-Reise von “Kategorien laden nicht” bis zum Fix eines JSON-Decoder-Problems, das tief in der YNAB-API-Struktur verwurzelt war.
Ausgangslage
BudgetBuddy ist eine F#-Anwendung, die Transaktionen von Comdirect nach YNAB synchronisiert. Die YNAB-Integration war bereits implementiert und funktionierte – zumindest dachten wir das. Der YnabClient.fs enthielt Decoder für alle YNAB-Datentypen:
let budgetDetailDecoder : Decoder<YnabBudgetWithAccounts> =
Decode.object (fun get -> {
Budget = { ... }
Accounts = get.Required.Field "accounts" (Decode.list accountDecoder)
Categories = get.Required.Field "categories" (Decode.list categoryDecoder)
})
Das Problem? Dieser Decoder basierte auf Annahmen, nicht auf der tatsächlichen API-Dokumentation.
Herausforderung 1: Das Symptom verstehen
Das Problem
Der User meldete: “Ich kann in den Rules keine YNAB Budgets auswählen.” Das klang nach einem Frontend-Problem – vielleicht wurde die Kategorie-Dropdown nicht richtig befüllt?
Der Debugging-Prozess
Mein erster Instinkt war, im Browser zu schauen. Mit dem Chrome DevTools MCP konnte ich direkt in die laufende Anwendung schauen:
- Navigierte zur Rules-Seite
- Öffnete das Edit-Modal für eine Regel
- Sah: “No categories loaded. Please configure YNAB first.”
Aber halt – YNAB war konfiguriert! In den Settings war eine Verbindung aktiv. Also lag das Problem tiefer.
Die erste falsche Fährte
Ich untersuchte zunächst eine Race Condition im Frontend. Die Theorie: Wenn man schnell von Settings zu Rules navigiert, sind die Kategorien vielleicht noch nicht geladen. Ich fixte diese Race Condition auch (ein legitimer Bug), aber das löste nicht das Hauptproblem.
Lesson Learned: Manchmal gibt es mehrere Bugs gleichzeitig. Der offensichtliche Bug ist nicht immer der wichtigste.
Herausforderung 2: Das richtige Budget finden
Das Problem
Bei genauerer Untersuchung stellte ich fest: Der User hatte das Budget “Haus” ausgewählt, das 0 Kategorien hatte. Also schlug ich vor, zu “My Budget” zu wechseln, das 159 Kategorien haben sollte.
Aber: “My Budget” erschien gar nicht in der Dropdown!
Der User hatte 3 Budgets in YNAB:
- “Haus” – erschien ✓
- “Testbudget” – erschien ✓
- “My Budget” – fehlte ✗
Die Diagnose
Ich fügte Debug-Logging zum Server hinzu:
| Ok budgets ->
printfn "Found %d budgets from getBudgets" budgets.Length
for budget in budgets do
printfn "Fetching details for budget: %s (%A)" budget.Name budget.Id
match! YnabClient.getBudgetWithAccounts token budget.Id with
| Ok details ->
printfn " SUCCESS: Got %d accounts, %d categories"
details.Accounts.Length details.Categories.Length
| Error err ->
printfn " FAILED: %s" (ynabErrorToString err)
Die Server-Logs zeigten:
Found 3 budgets from getBudgets
Fetching details for budget: My Budget (...)
FAILED: Invalid YNAB response: Failed to parse budget details:
Error at: `$.data.budget.category_groups.[0]`
Expecting an object with a field named `categories` but instead got:
{
"id": "300eefb2-f934-4a8a-99c3-3dad585b5da4",
"name": "Internal Master Category",
"hidden": false,
"deleted": false
}
Eureka! Der Decoder erwartete ein categories-Feld, aber manche Category Groups haben keines!
Herausforderung 3: Die YNAB API-Struktur verstehen
Das Problem
Mein ursprünglicher Decoder ging davon aus, dass die YNAB API Kategorien als flache Liste liefert:
{
"data": {
"budget": {
"categories": [
{ "id": "...", "name": "Groceries", "category_group_name": "Essential" }
]
}
}
}
Die Realität sah anders aus. Der /budgets/{id} Endpoint liefert:
{
"data": {
"budget": {
"category_groups": [
{
"id": "...",
"name": "Internal Master Category",
"hidden": false,
"deleted": false
// KEIN "categories" Feld!
},
{
"id": "...",
"name": "Essential Expenses",
"categories": [
{ "id": "...", "name": "Groceries" }
]
}
]
}
}
}
Die Erkenntnisse
- Kategorien sind in
category_groupsverschachtelt, nicht in einer flachencategories-Liste - Manche Category Groups haben kein
categories-Feld (z.B. “Internal Master Category”) - Der Group-Name kommt vom Parent-Objekt, nicht aus der Kategorie selbst
Warum der Bug bei kleinen Budgets nicht auftrat
“Haus” und “Testbudget” hatten entweder:
- Keine Category Groups ohne
categories-Feld, oder - Der Decoder schlug fehl, aber die Fehler wurden still ignoriert
Bei “My Budget” war “Internal Master Category” das erste Element im Array, was den gesamten Decoder zum Absturz brachte.
Herausforderung 4: Den Decoder reparieren
Die Lösung
Ich musste drei neue Decoder schreiben:
/// Decoder für Kategorien innerhalb von category_groups
let categoryInGroupDecoder (groupName: string) : Decoder<YnabCategory> =
Decode.object (fun get -> {
Id = get.Required.Field "id" Decode.guid |> YnabCategoryId
Name = get.Required.Field "name" Decode.string
GroupName = groupName // Vom Parent übergeben!
})
/// Decoder für die category_groups-Struktur
let categoryGroupsDecoder : Decoder<YnabCategory list> =
Decode.list (
Decode.object (fun get ->
let groupName = get.Required.Field "name" Decode.string
// Optional! Manche Groups haben keine categories
let categories =
get.Optional.Field "categories"
(Decode.list (categoryInGroupDecoder groupName))
categories |> Option.defaultValue []
)
)
|> Decode.map List.concat // Flatten der verschachtelten Listen
let budgetDetailDecoder : Decoder<YnabBudgetWithAccounts> =
Decode.object (fun get -> {
Budget = { ... }
Accounts = get.Required.Field "accounts" (Decode.list accountDecoder)
// NEU: category_groups statt categories, und Optional!
Categories =
get.Optional.Field "category_groups" categoryGroupsDecoder
|> Option.defaultValue []
})
Architekturentscheidung: Warum Optional.Field?
Ich hätte auch Required.Field verwenden und leere Arrays als Default behandeln können. Aber:
- Robustheit: Wenn YNAB irgendwann
category_groupsumbenennt oder weglässt, bricht nicht alles zusammen - Defensive Programmierung: Lieber “keine Kategorien” als “API kaputt”
- Konsistenz: Manche Budgets haben vielleicht wirklich keine Category Groups
Warum ein separater categoryInGroupDecoder?
Der bestehende categoryDecoder erwartete category_group_name als eigenes Feld:
let categoryDecoder : Decoder<YnabCategory> =
Decode.object (fun get -> {
Id = get.Required.Field "id" Decode.guid |> YnabCategoryId
Name = get.Required.Field "name" Decode.string
GroupName = get.Required.Field "category_group_name" Decode.string
})
Dieses Feld existiert aber nur beim /categories Endpoint, nicht bei /budgets/{id}. Statt den bestehenden Decoder zu modifizieren, erstellte ich einen neuen, der den Group-Name als Parameter akzeptiert. Das erhält die Kompatibilität mit beiden API-Endpoints.
Herausforderung 5: Die Tests anpassen
Das Problem
Nach dem Fix schlugen die Unit-Tests fehl:
Should have 3 categories. Expected list to have length 3, but length was 0
Die Tests verwendeten noch das alte JSON-Format:
let budgetDetailJson = """
{
"data": {
"budget": {
"id": "budget-123",
"name": "My Budget",
"accounts": [...],
"categories": [...] // FALSCH!
}
}
}
"""
Die Lösung
Ich aktualisierte die Test-Daten auf das echte API-Format:
let budgetDetailJson = """
{
"data": {
"budget": {
"id": "budget-123",
"name": "My Budget",
"accounts": [...],
"category_groups": [
{
"id": "group-internal",
"name": "Internal Master Category",
"hidden": false,
"deleted": false
},
{
"id": "group-1",
"name": "Essential Expenses",
"categories": [
{ "id": "cat-1", "name": "Groceries" },
{ "id": "cat-2", "name": "Rent" }
]
},
{
"id": "group-2",
"name": "Fun Money",
"categories": [
{ "id": "cat-3", "name": "Entertainment" }
]
}
]
}
}
}
"""
Wichtig: Ich fügte explizit “Internal Master Category” ohne categories-Feld hinzu, um den Edge Case zu testen.
Herausforderung 6: Debug-Code aufräumen
Das Problem
Ich hatte Debug-Logging zum Server hinzugefügt, um das Problem zu diagnostizieren:
printfn "Found %d budgets from getBudgets" budgets.Length
printfn "Fetching details for budget: %s (%A)" budget.Name budget.Id
printfn " SUCCESS: Got %d accounts, %d categories" ...
printfn " FAILED: %s" (ynabErrorToString err)
Diese Zeilen müssen vor dem Commit entfernt werden.
Die Lösung
Einfach die printfn-Aufrufe entfernen:
| Ok budgets ->
let! budgetsWithDetails = async {
let mutable results = []
for budget in budgets do
match! YnabClient.getBudgetWithAccounts token budget.Id with
| Ok details ->
results <- details :: results
| Error _ ->
() // Skip budgets that fail to load
return results |> List.rev
}
return Ok budgetsWithDetails
Architekturentscheidung: Ich habe printfn "FAILED" auch entfernt, obwohl man argumentieren könnte, dass Fehler geloggt werden sollten. Der Grund: Diese Funktion ist Teil einer User-facing API. Wenn ein Budget nicht geladen werden kann, überspringen wir es still. Der User sieht einfach weniger Budgets – was immer noch besser ist als ein kompletter Fehler. Für Production-Logging sollte ein richtiges Logging-Framework verwendet werden.
Lessons Learned
1. API-Dokumentation lesen, nicht raten
Ich hatte angenommen, dass die YNAB API Kategorien als flache Liste liefert, weil das “logisch” erschien. Ein Blick in die offizielle Dokumentation hätte den Bug von Anfang an verhindert.
2. Tests mit echten API-Daten
Die ursprünglichen Tests verwendeten vereinfachte JSON-Strukturen. Das führte dazu, dass der Decoder in Tests funktionierte, aber in Production scheiterte. Wenn möglich, immer echte API-Responses als Test-Daten verwenden.
3. Optional-Felder defensiv behandeln
In Thoth.Json.Net gibt es Required.Field und Optional.Field. Bei externen APIs ist Optional.Field oft sicherer, weil sich APIs ändern können. Lieber mit fehlenden Daten umgehen als abstürzen.
4. Edge Cases in Tests abdecken
Der Bug trat nur auf, weil “Internal Master Category” keine nested categories hatte. Mein Fix-Test deckt diesen Edge Case jetzt explizit ab. Jeder Bug ist eine Gelegenheit, einen neuen Test-Case hinzuzufügen.
5. Debug-Code systematisch entfernen
Ich habe einen Task in meiner Todo-Liste erstellt: “Remove debug logging”. Das verhindert, dass Debug-Code versehentlich committed wird.
Fazit
Was wurde erreicht?
- Bug gefixt: Alle 3 YNAB-Budgets erscheinen jetzt in der Dropdown
- 159 Kategorien laden: “My Budget” funktioniert vollständig
- Tests aktualisiert: Echtes API-Format in Test-Daten
- Code aufgeräumt: Debug-Logging entfernt
Dateien geändert
| Datei | Änderung |
|---|---|
src/Server/YnabClient.fs |
Neue Decoder für category_groups-Struktur |
src/Server/Api.fs |
Debug-Logging hinzugefügt und wieder entfernt |
src/Tests/YnabClientTests.fs |
Test-Daten auf echtes API-Format aktualisiert |
diary/development.md |
Diary-Eintrag hinzugefügt |
Test-Ergebnisse
- 115 Tests passed
- 6 Tests skipped (Integration-Tests ohne Credentials)
- 0 Fehler
Key Takeaways für Neulinge
1. Thoth.Json.Net Decoder sind mächtig, aber erfordern Präzision
Die Decoder-Syntax in Thoth.Json.Net ist elegant, aber jedes Feld muss exakt zur API-Struktur passen. Nutze Optional.Field für Felder, die fehlen könnten, und erstelle separate Decoder für verschiedene API-Endpoints, wenn die Strukturen unterschiedlich sind.
2. Debugging-Workflow mit Debug-Logging
Wenn API-Calls fehlschlagen, füge temporäres Logging hinzu:
match result with
| Ok data -> printfn "SUCCESS: %A" data
| Error err -> printfn "FAILED: %s" err
Aber erstelle einen Task, um das Logging später zu entfernen!
3. Nested Structures erfordern nested Decoders
Bei verschachtelten JSON-Strukturen wie category_groups.[].categories.[] brauchst du:
- Einen Decoder für das innerste Element
- Einen Decoder, der die Liste dekodiert
- Einen Decoder, der die Parent-Struktur verarbeitet
Decode.map List.concatum verschachtelte Listen zu flatten
Dieser Blogpost dokumentiert echte Debugging-Arbeit an BudgetBuddy, einer F#-Anwendung zur Synchronisation von Banktransaktionen mit YNAB.