From c2c4171a3a1432695e14c62c2f174bff78f14980 Mon Sep 17 00:00:00 2001 From: Yonah Date: Wed, 17 Sep 2025 07:47:51 +0900 Subject: [PATCH] generic: emulate nullable string behaviour This fills the gap for nullable strings returned by the API. Signed-off-by: Yonah --- generic.go | 23 ++++++++++++++++++++++ generic_test.go | 51 +++++++++++++++++++++++++++++++------------------ 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/generic.go b/generic.go index 47a71d9..309bc15 100644 --- a/generic.go +++ b/generic.go @@ -13,6 +13,29 @@ type Response[T any] struct { Data T `json:"data"` } +// NullableString is a JSON string where its zero value behaves like null. +type NullableString string + +func (s *NullableString) MarshalJSON() ([]byte, error) { + if *s == "" { + return []byte("null"), nil + } + return json.Marshal(string(*s)) +} + +func (s *NullableString) UnmarshalJSON(data []byte) (err error) { + var v *string + err = json.Unmarshal(data, &v) + if err == nil { + if v != nil { + *s = NullableString(*v) + } else { + *s = "" + } + } + return +} + // StringInt is a JSON string representing an integer. type StringInt int diff --git a/generic_test.go b/generic_test.go index bc053cf..7b93e16 100644 --- a/generic_test.go +++ b/generic_test.go @@ -11,13 +11,17 @@ import ( . "git.gensokyo.uk/yonah/monstersirenfetch" ) +func TestNullableString(t *testing.T) { + checkJSONRoundTripM(t, []jsonRoundTripTestCase[NullableString]{ + {"zero", nil, `null`, ""}, + {"string", nil, `"null"`, "null"}, + + {"invalid json", newSyntaxError("unexpected end of JSON input", 5), `"null`, "\x00"}, + }) +} + func TestStringInt(t *testing.T) { - testCases := []struct { - name string - wantErr error - data string - val int - }{ + checkJSONRoundTripM(t, []jsonRoundTripTestCase[StringInt]{ {"valid long", nil, `"3735928559"`, 0xdeadbeef}, {"valid pad 4", nil, `"0000"`, 0}, {"valid 4", nil, `"1000"`, 1e3}, @@ -26,13 +30,29 @@ func TestStringInt(t *testing.T) { {"invalid json", newSyntaxError("unexpected end of JSON input", 11), `"3735928559`, -1}, {"invalid number", &strconv.NumError{Func: "Atoi", Num: ":3735928559", Err: strconv.ErrSyntax}, `":3735928559"`, -1}, - } + }) +} + +func newSyntaxError(msg string, offset int64) *json.SyntaxError { + e := &json.SyntaxError{Offset: offset} + msgV := reflect.ValueOf(e).Elem().FieldByName("msg") + reflect.NewAt(msgV.Type(), unsafe.Pointer(msgV.UnsafeAddr())).Elem().SetString(msg) + return e +} + +type jsonRoundTripTestCase[T comparable] struct { + name string + wantErr error + data string + val T +} + +func checkJSONRoundTripM[T comparable](t *testing.T, testCases []jsonRoundTripTestCase[T]) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { if tc.wantErr == nil { t.Run("marshal", func(t *testing.T) { - v := StringInt(tc.val) - if got, err := json.Marshal(&v); err != nil { + if got, err := json.Marshal(&tc.val); err != nil { t.Fatalf("Marshal: error = %v", err) } else if string(got) != tc.data { t.Errorf("Marshal: %s, want %s", string(got), tc.data) @@ -41,7 +61,7 @@ func TestStringInt(t *testing.T) { } t.Run("unmarshal", func(t *testing.T) { - var got StringInt + var got T err := json.Unmarshal([]byte(tc.data), &got) if !reflect.DeepEqual(err, tc.wantErr) { @@ -49,8 +69,8 @@ func TestStringInt(t *testing.T) { } if tc.wantErr == nil { - if int(got) != tc.val { - t.Errorf("Unmarshal: %d, want %d", got, tc.val) + if got != tc.val { + t.Errorf("Unmarshal: %v, want %v", got, tc.val) } } }) @@ -58,13 +78,6 @@ func TestStringInt(t *testing.T) { } } -func newSyntaxError(msg string, offset int64) *json.SyntaxError { - e := &json.SyntaxError{Offset: offset} - msgV := reflect.ValueOf(e).Elem().FieldByName("msg") - reflect.NewAt(msgV.Type(), unsafe.Pointer(msgV.UnsafeAddr())).Elem().SetString(msg) - return e -} - func checkJSONRoundTrip[T any](t *testing.T, v T, data []byte) { t.Run("marshal", func(t *testing.T) { buf := new(bytes.Buffer)