API-Vertrag auf snake_case umstellen (ADR 0036) #126

Open
opened 2026-10-22 15:50:00 +00:00 by claude · 2 comments
Collaborator

Entschieden am 22.10.2026, siehe docs/adr/0036-feldnamen-in-snake-case.md.

Ziel

JSON-Feldnamen und Query-Parameter stehen auf der Leitung in snake_case (Zalando-Regeln 118 und 130): phase_dates statt phaseDates, ?season_id= statt ?seasonId=.

  • Java bleibt camelCase. Übersetzt wird zentral, die Records ändern sich nicht.
  • Pfade bleiben kebab-case (/phase-dates), das verlangt Zalando-Regel 129 so.
  • Enum-Werte bleiben UPPER_SNAKE (REGISTRATION_OPEN).

Für den Nutzer ändert sich nichts Sichtbares. Die Abnahme ist darum technisch.

Umfang

  1. Backend, zwei Einstellungen. Beide sind nötig:
    • quarkus.jackson.property-naming-strategy=SNAKE_CASE für die Antworten und Anfragen.
    • mp.openapi.extensions.smallrye.property-naming-strategy für das Schema. Ohne sie bliebe openapi.json in camelCase, und die generierten Typen würden nicht mehr stimmen.
    • Gegenprobe: Ein Feld im Schema und in einer echten Antwort heissen gleich.
  2. Query-Parameter: categoryId, seasonId, validFrom → category_id, season_id, valid_from (im @QueryParam und bei allen Aufrufern).
  3. Backend-Tests, die JSON-Pfade oder Anfragekörper als Text tragen (body("phaseDates…"), Maps).
  4. openapi.json neu mit mvn -B clean verify, danach die Typen neu erzeugen. In portal, content und shared meldet tsc jeden Zugriff auf ein umbenanntes Feld. Für die Homepage prüfen, wie weit astro check reicht.
  5. Was kein Compiler findet, von Hand durchgehen:
    • E2E: Typen, die die Tests selbst hinschreiben (apiGet<{ phaseDates: … }>), und die Anfragekörper.
    • Untypisierte Anfragekörper in den SPAs, zum Beispiel { phase: target, date: … } im PhaseDialog.
    • Skripte unter scripts/, die die API aufrufen: unter anderem matchblatt-import.py, import-ttvp-termine.py, import-doppel-handicap.py, seed-saison-2025-2026.sh, import-ttvp-*.sh, e2e-run.sh, preview-baseline.sh, staging-restore-prod.sh.
    • Skills: .claude/skills/matchblaetter, verify, project-review/metrics.py (liest openapi.json).
  6. Angemeldete Nutzer: localStorage.auth hält die LoginResponse, darin clubId. Nach dem Deploy liest der Code club_id, und Vereinsverantwortliche, die noch angemeldet sind, sähen ihren Verein nicht mehr. getStoredAuth in packages/shared/src/auth.tsx verwirft darum die alte Form und erzwingt ein neues Login, so wie schon beim Umbau von role auf roles.

Nicht betroffen:

  • Die Felder der Fehlerantworten (RFC 9457) sind einwortig.
  • Listen bleiben nackte Arrays (ADR 0035).

Hinweis zum Zeitpunkt

Der Umbau berührt fast jede Frontend-Datei. Er sollte darum laufen, wenn kein anderer Branch Frontend-Code offen hat. Sonst gibt es Konflikte in jeder Datei.

Abnahme

  • mvn -B clean verify, die drei Frontend-Builds, E2E und metrics.py diff sind grün.
  • In openapi.json steht kein Feldname mehr mit Grossbuchstaben (grep -E '"[a-z]+[A-Z]').
  • Login als Vereinsverantwortlicher, der vorher mit der alten Version angemeldet war: Er wird zum Login geschickt und sieht danach seinen Verein.
  • matchblatt-import.py läuft einmal gegen Staging.
Entschieden am 22.10.2026, siehe `docs/adr/0036-feldnamen-in-snake-case.md`. ## Ziel JSON-Feldnamen und Query-Parameter stehen auf der Leitung in snake_case (Zalando-Regeln 118 und 130): `phase_dates` statt `phaseDates`, `?season_id=` statt `?seasonId=`. - **Java bleibt camelCase.** Übersetzt wird zentral, die Records ändern sich nicht. - **Pfade bleiben kebab-case** (`/phase-dates`), das verlangt Zalando-Regel 129 so. - **Enum-Werte bleiben UPPER_SNAKE** (`REGISTRATION_OPEN`). Für den Nutzer ändert sich nichts Sichtbares. Die Abnahme ist darum technisch. ## Umfang 1. **Backend, zwei Einstellungen.** Beide sind nötig: - `quarkus.jackson.property-naming-strategy=SNAKE_CASE` für die Antworten und Anfragen. - `mp.openapi.extensions.smallrye.property-naming-strategy` für das Schema. Ohne sie bliebe `openapi.json` in camelCase, und die generierten Typen würden nicht mehr stimmen. - Gegenprobe: Ein Feld im Schema und in einer echten Antwort heissen gleich. 2. **Query-Parameter:** `categoryId`, `seasonId`, `validFrom` → `category_id`, `season_id`, `valid_from` (im `@QueryParam` und bei allen Aufrufern). 3. **Backend-Tests**, die JSON-Pfade oder Anfragekörper als Text tragen (`body("phaseDates…")`, Maps). 4. **`openapi.json` neu** mit `mvn -B clean verify`, danach die Typen neu erzeugen. In portal, content und shared meldet `tsc` jeden Zugriff auf ein umbenanntes Feld. Für die Homepage prüfen, wie weit `astro check` reicht. 5. **Was kein Compiler findet**, von Hand durchgehen: - E2E: Typen, die die Tests selbst hinschreiben (`apiGet<{ phaseDates: … }>`), und die Anfragekörper. - Untypisierte Anfragekörper in den SPAs, zum Beispiel `{ phase: target, date: … }` im PhaseDialog. - Skripte unter `scripts/`, die die API aufrufen: unter anderem `matchblatt-import.py`, `import-ttvp-termine.py`, `import-doppel-handicap.py`, `seed-saison-2025-2026.sh`, `import-ttvp-*.sh`, `e2e-run.sh`, `preview-baseline.sh`, `staging-restore-prod.sh`. - Skills: `.claude/skills/matchblaetter`, `verify`, `project-review/metrics.py` (liest `openapi.json`). 6. **Angemeldete Nutzer:** `localStorage.auth` hält die `LoginResponse`, darin `clubId`. Nach dem Deploy liest der Code `club_id`, und Vereinsverantwortliche, die noch angemeldet sind, sähen ihren Verein nicht mehr. `getStoredAuth` in `packages/shared/src/auth.tsx` verwirft darum die alte Form und erzwingt ein neues Login, so wie schon beim Umbau von `role` auf `roles`. Nicht betroffen: - Die Felder der Fehlerantworten (RFC 9457) sind einwortig. - Listen bleiben nackte Arrays (ADR 0035). ## Hinweis zum Zeitpunkt Der Umbau berührt fast jede Frontend-Datei. Er sollte darum laufen, wenn kein anderer Branch Frontend-Code offen hat. Sonst gibt es Konflikte in jeder Datei. ## Abnahme - `mvn -B clean verify`, die drei Frontend-Builds, E2E und `metrics.py diff` sind grün. - In `openapi.json` steht kein Feldname mehr mit Grossbuchstaben (`grep -E '"[a-z]+[A-Z]'`). - Login als Vereinsverantwortlicher, der vorher mit der alten Version angemeldet war: Er wird zum Login geschickt und sieht danach seinen Verein. - `matchblatt-import.py` läuft einmal gegen Staging.
Author
Collaborator

Ergänzung zu Punkt 3 (Backend-Tests), aus der Analyse der unbekannten Felder vom 23.10.2026.

Anfragekörper als Records statt als Text

Die Backend-Tests schicken ihre Anfragekörper heute fast immer als handgeschriebenes JSON:

Art des Körpers Stellen
JSON im String, .body("{\"vorname\": …}") 391
Textblock oder String.format 49
Map.of 7
Request-Record, new CreatePlayerRequest(…) 28

Die ersten drei Arten müssen mit der Umstellung auf snake_case ohnehin angefasst werden, weil jeder Feldname darin camelCase ist. Dabei werden sie zu Request-Records. Dann übersetzt die zentrale Einstellung die Namen auch in den Tests, und ein Feld, das der Vertrag nicht kennt, lässt schon der Compiler nicht durch.

Vorher prüfen: Nimmt RestAssured in den @QuarkusTest-Klassen denselben ObjectMapper wie Quarkus? Sonst schicken die Records nach der Umstellung weiter camelCase. Die Gegenprobe ist ein Test, der einen Record mit einem zweiwortigen Feld schickt und dessen Wert in der Antwort wiederfindet.

Die strenge Einstellung hilft beim Umbau

Seit feature/unbekannte-felder steht quarkus.jackson.fail-on-unknown-properties=true. Ein Feld, das der Vertrag nicht kennt, gibt 400. Für diesen Umbau heisst das: Ein Anfragekörper, der nach der Umstellung noch seasonId statt season_id schickt, fällt in Tests, E2E und Skripten als 400 auf, statt dass der Wert stillschweigend nicht ankommt. Für die Antworten gilt das nicht, dort bleibt es bei tsc und der Durchsicht unter Punkt 5.

Ergänzung zu Punkt 3 (Backend-Tests), aus der Analyse der unbekannten Felder vom 23.10.2026. ## Anfragekörper als Records statt als Text Die Backend-Tests schicken ihre Anfragekörper heute fast immer als handgeschriebenes JSON: | Art des Körpers | Stellen | |---|---| | JSON im String, `.body("{\"vorname\": …}")` | 391 | | Textblock oder `String.format` | 49 | | `Map.of` | 7 | | Request-Record, `new CreatePlayerRequest(…)` | 28 | Die ersten drei Arten müssen mit der Umstellung auf snake_case ohnehin angefasst werden, weil jeder Feldname darin camelCase ist. **Dabei werden sie zu Request-Records.** Dann übersetzt die zentrale Einstellung die Namen auch in den Tests, und ein Feld, das der Vertrag nicht kennt, lässt schon der Compiler nicht durch. Vorher prüfen: Nimmt RestAssured in den `@QuarkusTest`-Klassen denselben ObjectMapper wie Quarkus? Sonst schicken die Records nach der Umstellung weiter camelCase. Die Gegenprobe ist ein Test, der einen Record mit einem zweiwortigen Feld schickt und dessen Wert in der Antwort wiederfindet. ## Die strenge Einstellung hilft beim Umbau Seit `feature/unbekannte-felder` steht `quarkus.jackson.fail-on-unknown-properties=true`. Ein Feld, das der Vertrag nicht kennt, gibt 400. Für diesen Umbau heisst das: Ein Anfragekörper, der nach der Umstellung noch `seasonId` statt `season_id` schickt, fällt in Tests, E2E und Skripten als 400 auf, statt dass der Wert stillschweigend nicht ankommt. Für die Antworten gilt das nicht, dort bleibt es bei `tsc` und der Durchsicht unter Punkt 5.
Author
Collaborator

Gesichert aus logs/endpunkte/werte.md (von Git ignoriert, lag nur auf der Devbox). Ollis Entscheide vom 23.10. zu Aufzählungswerten und Feldnamen, die mit diesem Issue englisch werden. Bis dahin gilt im URL-Umbau: vorhandene Werte bleiben, neue Werte ohne Vorbild sind gleich englisch (Plan Etappe 2, ADR 0038 Abschnitt „Etappe 2“).

Aufzählungswerte auf der Leitung (Olli 23.10.)

Englisch im selben Zug wie snake_case (#126): Java-Konstante, Leitung, gespeicherter Wert per Migration.

Typ heute neu
MatchStatus OFFEN TERMINIERT GESPIELT BESTAETIGT UNSCHEDULED SCHEDULED PLAYED CONFIRMED
ProposalStatus OFFEN AKZEPTIERT ABGELEHNT ZURUECKGEZOGEN PENDING ACCEPTED REJECTED WITHDRAWN
ResultStatus ENTWURF EINGEREICHT BESTAETIGT KORREKTUR_ANGEFRAGT DRAFT SUBMITTED CONFIRMED CORRECTION_REQUESTED
RegistrationStatus NOT_STARTED OPEN COMPLETED NOT_STARTED IN_PROGRESS COMPLETED
CategoryForm, GameType EINZEL DOPPEL SINGLES DOUBLES
CompetitionType MEISTERSCHAFT CUP TURNIER LIGA CUP TOURNAMENT
RoundKind LIGA_VORRUNDE LIGA_RUECKRUNDE CUP_HAUPTRUNDE CUP_FINALRUNDE TURNIER_RUNDE LIGA_VORRUNDE LIGA_RUECKRUNDE CUP_MAIN_ROUND CUP_FINAL_ROUND TOURNAMENT_ROUND
OfficialFunction SPIELLEITER PRAESIDENT SEKTIONSLEITER SPIELLEITER PRESIDENT SEKTIONSLEITER (Sektionsleiter auf die Fachbegriff-Liste)
DocumentCategory REGLEMENTE PROTOKOLLE TERMINE TURNIERE FORMULARE SONSTIGES REGULATIONS MINUTES DATES TOURNAMENTS FORMS OTHER
ArticleCategory NEWS SIEGER_ARCHIV NEWS WINNERS_ARCHIVE

Bleiben: Halbserie VORRUNDE RUECKRUNDE, CMS-Bereich BASLER SCHWEIZER TURNIER KURS.
OPEN nur noch fuer "nicht fixiert" (Spielplan- und Spielwochen-Status, Gegenstueck FINALIZED).
Nur intern, nicht auf der Leitung: MatchSide HEIM GAST BEIDE, Side A B UNENTSCHIEDEN.
scope (users/self/tasks, users/self/team-matches): OWN_TEAMS CLUB ALL (nicht VERBAND).

Feldnamen (Olli 23.10.)

Englisch im selben Zug wie snake_case (#126):

Typ heute neu
CutoffPlayerPreview angehoben gerechnet manuell publiziert is_raised is_calculated is_manual is_published
CutoffPlayerPreview klassierungBisher klassierungNeu vorschlag klassierung_before klassierung_after suggestion
CutoffPreviewResponse spieler stichtag uebernommenAm untergrenze players cutoff_date adopted_at floor
CutoffResponse werte values
KlassierungAenderungResponse → KlassierungChangeResponse vorher nachher before after
EloHistoryEntry paarung veranstaltungMarke pairing event_label
EloReportResponse uebersprungenNichtSpielberechtigt uebersprungenOhneResultat uebersprungenWalkover skipped_ineligible skipped_without_result skipped_walkover
TeamMatchDetailResponse darfHeimAktionen darfGastAktionen can_act_as_home can_act_as_away
ClubDetailResponse, ClubRegistrationRequest praesident president
ClubDetailResponse, PublicClubResponse meisterschaftTeams meisterschaftTeamCount liga_teams liga_team_count

Bleiben (Regel 5, in CLAUDE.md ergaenzt): Person/Kontakt/Adresse inkl. natel, telefon_geschaeftlich, email_geschaeftlich, hausnummer, venue_hausnummer, captain_vorname/-nachname; CMS inkl. datum_bis; Fachbegriffe klassierung…, halbserie, spielleiter, sektionsleiter, saison….

Gesichert aus `logs/endpunkte/werte.md` (von Git ignoriert, lag nur auf der Devbox). Ollis Entscheide vom 23.10. zu Aufzählungswerten und Feldnamen, die mit diesem Issue englisch werden. Bis dahin gilt im URL-Umbau: vorhandene Werte bleiben, neue Werte ohne Vorbild sind gleich englisch (Plan Etappe 2, ADR 0038 Abschnitt „Etappe 2“). ## Aufzählungswerte auf der Leitung (Olli 23.10.) Englisch im selben Zug wie snake_case (#126): Java-Konstante, Leitung, gespeicherter Wert per Migration. | Typ | heute | neu | |---|---|---| | MatchStatus | OFFEN TERMINIERT GESPIELT BESTAETIGT | UNSCHEDULED SCHEDULED PLAYED CONFIRMED | | ProposalStatus | OFFEN AKZEPTIERT ABGELEHNT ZURUECKGEZOGEN | PENDING ACCEPTED REJECTED WITHDRAWN | | ResultStatus | ENTWURF EINGEREICHT BESTAETIGT KORREKTUR_ANGEFRAGT | DRAFT SUBMITTED CONFIRMED CORRECTION_REQUESTED | | RegistrationStatus | NOT_STARTED OPEN COMPLETED | NOT_STARTED IN_PROGRESS COMPLETED | | CategoryForm, GameType | EINZEL DOPPEL | SINGLES DOUBLES | | CompetitionType | MEISTERSCHAFT CUP TURNIER | LIGA CUP TOURNAMENT | | RoundKind | LIGA_VORRUNDE LIGA_RUECKRUNDE CUP_HAUPTRUNDE CUP_FINALRUNDE TURNIER_RUNDE | LIGA_VORRUNDE LIGA_RUECKRUNDE CUP_MAIN_ROUND CUP_FINAL_ROUND TOURNAMENT_ROUND | | OfficialFunction | SPIELLEITER PRAESIDENT SEKTIONSLEITER | SPIELLEITER PRESIDENT SEKTIONSLEITER (Sektionsleiter auf die Fachbegriff-Liste) | | DocumentCategory | REGLEMENTE PROTOKOLLE TERMINE TURNIERE FORMULARE SONSTIGES | REGULATIONS MINUTES DATES TOURNAMENTS FORMS OTHER | | ArticleCategory | NEWS SIEGER_ARCHIV | NEWS WINNERS_ARCHIVE | Bleiben: Halbserie VORRUNDE RUECKRUNDE, CMS-Bereich BASLER SCHWEIZER TURNIER KURS. `OPEN` nur noch fuer "nicht fixiert" (Spielplan- und Spielwochen-Status, Gegenstueck FINALIZED). Nur intern, nicht auf der Leitung: MatchSide HEIM GAST BEIDE, Side A B UNENTSCHIEDEN. scope (users/self/tasks, users/self/team-matches): OWN_TEAMS CLUB ALL (nicht VERBAND). ## Feldnamen (Olli 23.10.) Englisch im selben Zug wie snake_case (#126): | Typ | heute | neu | |---|---|---| | CutoffPlayerPreview | angehoben gerechnet manuell publiziert | is_raised is_calculated is_manual is_published | | CutoffPlayerPreview | klassierungBisher klassierungNeu vorschlag | klassierung_before klassierung_after suggestion | | CutoffPreviewResponse | spieler stichtag uebernommenAm untergrenze | players cutoff_date adopted_at floor | | CutoffResponse | werte | values | | KlassierungAenderungResponse → KlassierungChangeResponse | vorher nachher | before after | | EloHistoryEntry | paarung veranstaltungMarke | pairing event_label | | EloReportResponse | uebersprungenNichtSpielberechtigt uebersprungenOhneResultat uebersprungenWalkover | skipped_ineligible skipped_without_result skipped_walkover | | TeamMatchDetailResponse | darfHeimAktionen darfGastAktionen | can_act_as_home can_act_as_away | | ClubDetailResponse, ClubRegistrationRequest | praesident | president | | ClubDetailResponse, PublicClubResponse | meisterschaftTeams meisterschaftTeamCount | liga_teams liga_team_count | Bleiben (Regel 5, in CLAUDE.md ergaenzt): Person/Kontakt/Adresse inkl. natel, telefon_geschaeftlich, email_geschaeftlich, hausnummer, venue_hausnummer, captain_vorname/-nachname; CMS inkl. datum_bis; Fachbegriffe klassierung…, halbserie, spielleiter, sektionsleiter, saison….
Sign in to join this conversation.
No description provided.