From 7a516580ffc916a9c437ad8fcacc7f0305b5795a Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Tue, 18 Nov 2025 08:39:30 -0500 Subject: [PATCH] feat(internal): add ListOr[T any] type This is a utility type that lets you decode a JSON T or list of T as a single value. This will be used with Redis Sentinel config so that you can specify multiple sentinel addresses. Ref TecharoHQ/botstopper#24 Assisted-by: GLM 4.6 via Claude Code Signed-off-by: Xe Iaso --- internal/listor.go | 39 ++++++++++++++++++++ internal/listor_test.go | 79 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 internal/listor.go create mode 100644 internal/listor_test.go diff --git a/internal/listor.go b/internal/listor.go new file mode 100644 index 00000000..b6ba57fe --- /dev/null +++ b/internal/listor.go @@ -0,0 +1,39 @@ +package internal + +import ( + "encoding/json" +) + +// ListOr[T any] is a slice that can contain either a single T or multiple T values. +// During JSON unmarshaling, it checks if the first character is '[' to determine +// whether to treat the JSON as an array or a single value. +type ListOr[T any] []T + +func (lo *ListOr[T]) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + + // Check if first non-whitespace character is '[' + firstChar := data[0] + for i := 0; i < len(data); i++ { + if data[i] != ' ' && data[i] != '\t' && data[i] != '\n' && data[i] != '\r' { + firstChar = data[i] + break + } + } + + if firstChar == '[' { + // It's an array, unmarshal directly + return json.Unmarshal(data, (*[]T)(lo)) + } else { + // It's a single value, unmarshal as a single item in a slice + var single T + if err := json.Unmarshal(data, &single); err != nil { + return err + } + *lo = ListOr[T]{single} + } + + return nil +} \ No newline at end of file diff --git a/internal/listor_test.go b/internal/listor_test.go new file mode 100644 index 00000000..31fffbfb --- /dev/null +++ b/internal/listor_test.go @@ -0,0 +1,79 @@ +package internal + +import ( + "encoding/json" + "testing" +) + +func TestListOr_UnmarshalJSON(t *testing.T) { + t.Run("single value should be unmarshaled as single item", func(t *testing.T) { + var lo ListOr[string] + + err := json.Unmarshal([]byte(`"hello"`), &lo) + if err != nil { + t.Fatalf("Failed to unmarshal single string: %v", err) + } + + if len(lo) != 1 { + t.Fatalf("Expected 1 item, got %d", len(lo)) + } + + if lo[0] != "hello" { + t.Errorf("Expected 'hello', got %q", lo[0]) + } + }) + + t.Run("array should be unmarshaled as multiple items", func(t *testing.T) { + var lo ListOr[string] + + err := json.Unmarshal([]byte(`["hello", "world"]`), &lo) + if err != nil { + t.Fatalf("Failed to unmarshal array: %v", err) + } + + if len(lo) != 2 { + t.Fatalf("Expected 2 items, got %d", len(lo)) + } + + if lo[0] != "hello" { + t.Errorf("Expected 'hello', got %q", lo[0]) + } + if lo[1] != "world" { + t.Errorf("Expected 'world', got %q", lo[1]) + } + }) + + t.Run("single number should be unmarshaled as single item", func(t *testing.T) { + var lo ListOr[int] + + err := json.Unmarshal([]byte(`42`), &lo) + if err != nil { + t.Fatalf("Failed to unmarshal single number: %v", err) + } + + if len(lo) != 1 { + t.Fatalf("Expected 1 item, got %d", len(lo)) + } + + if lo[0] != 42 { + t.Errorf("Expected 42, got %d", lo[0]) + } + }) + + t.Run("array of numbers should be unmarshaled as multiple items", func(t *testing.T) { + var lo ListOr[int] + + err := json.Unmarshal([]byte(`[1, 2, 3]`), &lo) + if err != nil { + t.Fatalf("Failed to unmarshal number array: %v", err) + } + + if len(lo) != 3 { + t.Fatalf("Expected 3 items, got %d", len(lo)) + } + + if lo[0] != 1 || lo[1] != 2 || lo[2] != 3 { + t.Errorf("Expected [1, 2, 3], got %v", lo) + } + }) +} \ No newline at end of file