fix(api): leere Listen als [] statt null -- Kalender blieb weiss
Ein Konto ohne Kalender bekam auf GET /api/v1/calendars die Antwort
`null`, und die Ansicht brach ab:
for (const cal of calendars) -> "e is not iterable"
Nicht der Kalender ist schuld, sondern eine Eigenheit, die jeden
Listenendpunkt betrifft: In Go ist eine Liste ohne Eintraege oft nil,
und encoding/json schreibt dafuer null statt []. Jede Store-Funktion,
die mit `var out []T` anfaengt, liefert das bei leerem Ergebnis --
Favoriten, Papierkorb, Versionen, Freigaben, Etiketten. Der Kalender war
nur der, der daran zerbricht.
Deshalb die Regel dort, wo alle Antworten durchlaufen: response.JSON
macht aus einer nil-Liste eine leere. Einzelne Objekte und nil bleiben
unberuehrt. In den Stores waere es die falsche Stelle -- es gibt
Dutzende Listenendpunkte, und der naechste faengt wieder mit
`var out []T` an. Eine Regel, an die man sich erinnern muss, ist keine.
Dazu zwei Dinge, die derselbe Fall ans Licht brachte:
- Der Web-Weg legte, anders als CalDAV, keinen Standardkalender an.
Ein neues Konto stand vor einer leeren Ansicht und musste erraten,
dass es selbst einen anlegen soll -- waehrend ein CalDAV-Client, der
sich vorher verbunden hatte, bereits einen sah, den die Oberflaeche
nicht zeigte.
- Die Ansicht faengt eine unerwartete Antwort jetzt selbst ab (?? []).
Der Server schickt kein null mehr; eine Seite sollte daran trotzdem
nicht zerbrechen.
Drei Tests in pkg/response: leere Liste wird [], befuellte bleibt
unveraendert, Objekte und nil bleiben, was sie sind.
Zur Korrektur meiner frueheren Erklaerung: Die fehlenden Cache-Header
waren ein echtes Problem, aber nicht die Ursache dieses Fehlers. Der
gemeldete Bundle-Name stimmte diesmal mit dem ausgelieferten ueberein --
daran war zu sehen, dass es kein alter Stand sein konnte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
55dcf517f2
commit
fbaf24bdbd
4 files changed
+107
-2
No files matched your search
@@ -31,6 +31,15 @@ func NewAPIHandler(store *Store) *APIHandler {
|
|||||||
// GET /api/v1/calendars
|
// GET /api/v1/calendars
|
||||||
func (h *APIHandler) List(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
user := auth.UserFromContext(r.Context())
|
user := auth.UserFromContext(r.Context())
|
||||||
|
// Wie im CalDAV-Weg: Wer noch keinen Kalender hat, bekommt einen. Sonst
|
||||||
|
// steht ein neues Konto vor einer leeren Ansicht und muss erst erraten,
|
||||||
|
// dass es selbst einen anlegen soll – und ein CalDAV-Client, der sich
|
||||||
|
// vorher verbunden hat, sähe bereits einen, den die Weboberfläche nicht
|
||||||
|
// zeigt.
|
||||||
|
if _, err := h.store.EnsureDefaultCalendar(r.Context(), user.ID); err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "list calendars failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
cals, err := h.store.ListCalendars(r.Context(), user.ID)
|
cals, err := h.store.ListCalendars(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "list calendars failed")
|
response.Error(w, http.StatusInternalServerError, "list calendars failed")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package response
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"reflect"
|
||||||
)
|
)
|
||||||
|
|
||||||
type envelope struct {
|
type envelope struct {
|
||||||
@@ -13,7 +14,31 @@ type envelope struct {
|
|||||||
func JSON(w http.ResponseWriter, status int, data any) {
|
func JSON(w http.ResponseWriter, status int, data any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(envelope{Data: data})
|
json.NewEncoder(w).Encode(envelope{Data: leereListeStattNull(data)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// leereListeStattNull macht aus einer nicht belegten Liste eine leere.
|
||||||
|
//
|
||||||
|
// In Go ist eine Liste ohne Einträge oft nil, und `encoding/json` schreibt
|
||||||
|
// dafür `null` statt `[]`. Jede Store-Funktion, die ihre Liste mit
|
||||||
|
// `var out []T` beginnt, liefert das bei leerem Ergebnis – und jede
|
||||||
|
// Oberfläche, die darüber läuft, bricht dann ab.
|
||||||
|
//
|
||||||
|
// Genau so ist die Kalenderansicht bei einem Konto ohne Kalender
|
||||||
|
// weiß geblieben: `for (const cal of calendars)` über `null`.
|
||||||
|
//
|
||||||
|
// Der Ort dafür ist hier und nicht in den Stores: Es gibt Dutzende
|
||||||
|
// Listenendpunkte, und der nächste wird wieder mit `var out []T` anfangen.
|
||||||
|
// Eine Regel, an die man sich erinnern muss, ist keine.
|
||||||
|
func leereListeStattNull(data any) any {
|
||||||
|
if data == nil {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
v := reflect.ValueOf(data)
|
||||||
|
if v.Kind() == reflect.Slice && v.IsNil() {
|
||||||
|
return reflect.MakeSlice(v.Type(), 0, 0).Interface()
|
||||||
|
}
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
func Error(w http.ResponseWriter, status int, msg string) {
|
func Error(w http.ResponseWriter, status int, msg string) {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package response
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Eine leere Liste muss als [] herauskommen, nicht als null. Jede
|
||||||
|
// Oberfläche, die über die Antwort läuft, bricht sonst ab – so ist die
|
||||||
|
// Kalenderansicht bei einem Konto ohne Kalender weiß geblieben.
|
||||||
|
func TestJSONLeereListeWirdZuLeeremArray(t *testing.T) {
|
||||||
|
type eintrag struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
var leer []eintrag
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
JSON(w, 200, leer)
|
||||||
|
|
||||||
|
if got := w.Body.String(); !contains(got, `"data":[]`) {
|
||||||
|
t.Fatalf("erwartet \"data\":[], bekam %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONBefuellteListeBleibtUnveraendert(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
JSON(w, 200, []string{"a", "b"})
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Data []string `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out.Data) != 2 || out.Data[0] != "a" {
|
||||||
|
t.Fatalf("Inhalt verändert: %v", out.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einzelne Objekte und nil dürfen davon nicht berührt werden – sonst
|
||||||
|
// verwandelte die Regel jede fehlende Antwort in eine leere Liste.
|
||||||
|
func TestJSONObjekteBleibenObjekte(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
JSON(w, 200, map[string]int{"n": 1})
|
||||||
|
if got := w.Body.String(); !contains(got, `"n":1`) {
|
||||||
|
t.Fatalf("Objekt verändert: %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
JSON(w, 200, nil)
|
||||||
|
if got := w.Body.String(); contains(got, "[]") {
|
||||||
|
t.Fatalf("nil sollte keine Liste werden: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -147,7 +147,10 @@ export default function CalendarPage() {
|
|||||||
const loadCalendars = useCallback(async () => {
|
const loadCalendars = useCallback(async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
setCalendars(await calendarApi.list())
|
// ?? []: Eine Liste, die als null ankommt, legte die ganze Seite lahm
|
||||||
|
// (das ist einmal passiert). Der Server schickt das nicht mehr – aber
|
||||||
|
// eine Ansicht sollte an einer unerwarteten Antwort nicht zerbrechen.
|
||||||
|
setCalendars(await calendarApi.list() ?? [])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
|
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in new issue
Block a user