From 4a2b44c4943e3feb2b7fbfe261b75b11039748a3 Mon Sep 17 00:00:00 2001 From: Edan David Date: Tue, 10 Nov 2026 18:21:28 +0200 Subject: [PATCH 1/2] Migrate to encoding/json/v2 JSONSerializer, JSONDeserializer and reqtest.ReplayJSON now use encoding/json/v2. Update examples for v2's case-sensitive field matching and omitempty semantics. Co-Authored-By: Claude Opus 5.5 --- builder_example_test.go | 27 ++++++++++++++++++--------- cookies_example_test.go | 2 +- reqtest/json.go | 5 +++-- serializer.go | 10 +++++++--- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/builder_example_test.go b/builder_example_test.go index 201975b..c1598c5 100644 --- a/builder_example_test.go +++ b/builder_example_test.go @@ -5,7 +5,8 @@ import ( "context" "encoding/binary" "encoding/csv" - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "errors" "fmt" "io" @@ -145,7 +146,7 @@ func ExampleBuilder_ToFile() { } type placeholder struct { - ID int `json:"id,omitempty"` + ID int `json:"id,omitzero"` Title string `json:"title"` Body string `json:"body"` UserID int `json:"userId"` @@ -307,8 +308,8 @@ func ExampleBuilder_Bearer() { } // But our response is accepted when we provide a bearer token var res struct { - Authenticated bool - Token string + Authenticated bool `json:"authenticated"` + Token string `json:"token"` } err = requests. URL("http://httpbin.org/bearer"). @@ -542,8 +543,12 @@ func ExampleBuilder_ErrorJSON() { {"x": 1}`) - var goodJSON struct{ X int } - var errJSON struct{ Error string } + var goodJSON struct { + X int `json:"x"` + } + var errJSON struct { + Error string `json:"error"` + } err := requests. URL("http://example.com/"). Transport(trans). @@ -561,8 +566,12 @@ func ExampleBuilder_ErrorJSON() { {"error": "brewing"}`) - var goodJSON struct{ X int } - var errJSON struct{ Error string } + var goodJSON struct { + X int `json:"x"` + } + var errJSON struct { + Error string `json:"error"` + } err := requests. URL("http://example.com/"). Transport(trans). @@ -676,7 +685,7 @@ func ExampleBuilder_BodyJSON() { // Change the default JSON serializer to indent with two spaces requests.JSONSerializer = func(v any) ([]byte, error) { - return json.MarshalIndent(v, "", " ") + return json.Marshal(v, jsontext.WithIndent(" ")) } // Build a new request using the new indenting serializer diff --git a/cookies_example_test.go b/cookies_example_test.go index 73b0acc..7a3f5c2 100644 --- a/cookies_example_test.go +++ b/cookies_example_test.go @@ -30,7 +30,7 @@ func ExampleNewCookieJar() { } // And we'll see that they're reused on subsequent requests var cookies struct { - Cookies map[string]string + Cookies map[string]string `json:"cookies"` } err = requests. URL("http://httpbin.org/cookies"). diff --git a/reqtest/json.go b/reqtest/json.go index c3c8c44..a91f7ed 100644 --- a/reqtest/json.go +++ b/reqtest/json.go @@ -2,7 +2,8 @@ package reqtest import ( "bytes" - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "fmt" "io" "net/http" @@ -20,7 +21,7 @@ import ( // wrapped marshaling error. func ReplayJSON(code int, obj any) requests.Transport { return requests.RoundTripFunc(func(req *http.Request) (*http.Response, error) { - data, err := json.MarshalIndent(obj, "", " ") + data, err := json.Marshal(obj, jsontext.WithIndent(" ")) if err != nil { return nil, fmt.Errorf("marshal error in ReplayJSON: %v", err) } diff --git a/serializer.go b/serializer.go index f904563..4b09990 100644 --- a/serializer.go +++ b/serializer.go @@ -1,7 +1,7 @@ package requests import ( - "encoding/json" + "encoding/json/v2" ) // Serializer is a function @@ -17,8 +17,12 @@ type Deserializer = func(data []byte, v any) error var ( // JSONSerializer is used by [BodyJSON] and [Builder.BodyJSON]. // The default serializer may be changed in a future version of requests. - JSONSerializer Serializer = json.Marshal + JSONSerializer Serializer = func(v any) ([]byte, error) { + return json.Marshal(v) + } // JSONDeserializer is used by [ToJSON] and [Builder.ToJSON]. // The default deserializer may be changed in a future version of requests. - JSONDeserializer Deserializer = json.Unmarshal + JSONDeserializer Deserializer = func(data []byte, v any) error { + return json.Unmarshal(data, v) + } ) From 12d395972657de25910d348491f8f42ac44cc16c Mon Sep 17 00:00:00 2001 From: Edan David Date: Tue, 10 Nov 2026 18:21:28 +0200 Subject: [PATCH 2/2] Add ToJSONV1, ErrorJSONV1 and BodyJSONV1 Package functions and Builder methods that use encoding/json (v1) for backward compatibility with pre-v2 JSON behavior. Co-Authored-By: Claude Opus 5.5 --- body.go | 9 ++++++ builder_extras.go | 20 ++++++++++++++ handler.go | 9 ++++++ json_v1_test.go | 66 ++++++++++++++++++++++++++++++++++++++++++++ validator_handler.go | 6 ++++ 5 files changed, 110 insertions(+) create mode 100644 json_v1_test.go diff --git a/body.go b/body.go index 1cf3ba1..b1db95c 100644 --- a/body.go +++ b/body.go @@ -2,6 +2,7 @@ package requests import ( "bytes" + jsonv1 "encoding/json" "io" "net/url" "os" @@ -62,6 +63,14 @@ func BodyJSON(v any) BodyGetter { return BodySerializer(JSONSerializer, v) } +// BodyJSONV1 is a [BodySerializer] +// that uses the encoding/json (v1) package to marshal the object. +// Use it to keep the marshaling behavior of earlier versions of requests, +// such as omitempty omitting zero numbers. +func BodyJSONV1(v any) BodyGetter { + return BodySerializer(jsonv1.Marshal, v) +} + // BodyForm is a BodyGetter that builds an encoded form body. func BodyForm(data url.Values) BodyGetter { return func() (r io.ReadCloser, err error) { diff --git a/builder_extras.go b/builder_extras.go index 4fb6d2a..5f55699 100644 --- a/builder_extras.go +++ b/builder_extras.go @@ -150,6 +150,14 @@ func (rb *Builder) BodyJSON(v any) *Builder { HeaderOptional("Content-Type", "application/json") } +// BodyJSONV1 is like [Builder.BodyJSON] +// but marshals the object using [BodyJSONV1]. +func (rb *Builder) BodyJSONV1(v any) *Builder { + return rb. + Body(BodyJSONV1(v)). + HeaderOptional("Content-Type", "application/json") +} + // BodyForm sets the Builder's request body to the encoded form. // It also sets the ContentType to "application/x-www-form-urlencoded". func (rb *Builder) BodyForm(data url.Values) *Builder { @@ -192,6 +200,12 @@ func (rb *Builder) ToJSON(v any) *Builder { return rb.Handle(ToJSON(v)) } +// ToJSONV1 is like [Builder.ToJSON] +// but decodes the response using [ToJSONV1]. +func (rb *Builder) ToJSONV1(v any) *Builder { + return rb.Handle(ToJSONV1(v)) +} + // ToString sets the Builder to write the response body to the provided string pointer. func (rb *Builder) ToString(sp *string) *Builder { return rb.Handle(ToString(sp)) @@ -237,3 +251,9 @@ func (rb *Builder) ToHeaders(h map[string][]string) *Builder { func (rb *Builder) ErrorJSON(v any) *Builder { return rb.AddValidator(ErrorJSON(v)) } + +// ErrorJSONV1 is like [Builder.ErrorJSON] +// but decodes the response using [ToJSONV1]. +func (rb *Builder) ErrorJSONV1(v any) *Builder { + return rb.AddValidator(ErrorJSONV1(v)) +} diff --git a/handler.go b/handler.go index c63d6fa..e1d08d2 100644 --- a/handler.go +++ b/handler.go @@ -3,6 +3,7 @@ package requests import ( "bufio" "bytes" + jsonv1 "encoding/json" "io" "net/http" "os" @@ -57,6 +58,14 @@ func ToJSON(v any) ResponseHandler { return ToDeserializer(JSONDeserializer, v) } +// ToJSONV1 decodes a response as a JSON object +// using the encoding/json (v1) package. +// Use it to keep the unmarshaling behavior of earlier versions of requests, +// such as case-insensitive matching of object keys to struct fields. +func ToJSONV1(v any) ResponseHandler { + return ToDeserializer(jsonv1.Unmarshal, v) +} + // ToString writes the response body to the provided string pointer. func ToString(sp *string) ResponseHandler { return func(res *http.Response) error { diff --git a/json_v1_test.go b/json_v1_test.go new file mode 100644 index 0000000..fa000fc --- /dev/null +++ b/json_v1_test.go @@ -0,0 +1,66 @@ +package requests_test + +import ( + "context" + "errors" + "io" + "testing" + + "github.com/gophertools/requests" + "github.com/gophertools/requests/internal/be" + "github.com/gophertools/requests/reqtest" +) + +func TestJSONV1(t *testing.T) { + t.Parallel() + type obj struct { + Name string + Count int `json:"count,omitempty"` + } + ctx := context.Background() + + t.Run("BodyJSON", func(t *testing.T) { + req, err := requests.New().BodyJSON(obj{Name: "a"}).Request(ctx) + be.NilErr(t, err) + b, err := io.ReadAll(req.Body) + be.NilErr(t, err) + be.Equal(t, `{"Name":"a","count":0}`, string(b)) + }) + t.Run("BodyJSONV1", func(t *testing.T) { + req, err := requests.New().BodyJSONV1(obj{Name: "a"}).Request(ctx) + be.NilErr(t, err) + be.Equal(t, "application/json", req.Header.Get("Content-Type")) + b, err := io.ReadAll(req.Body) + be.NilErr(t, err) + be.Equal(t, `{"Name":"a"}`, string(b)) + }) + t.Run("ToJSON", func(t *testing.T) { + var got obj + err := requests.New(). + Transport(reqtest.ReplayString("HTTP/1.1 200 OK\n\n{\"name\": \"a\"}")). + ToJSON(&got). + Fetch(ctx) + be.NilErr(t, err) + be.Equal(t, "", got.Name) + }) + t.Run("ToJSONV1", func(t *testing.T) { + var got obj + err := requests.New(). + Transport(reqtest.ReplayString("HTTP/1.1 200 OK\n\n{\"name\": \"a\"}")). + ToJSONV1(&got). + Fetch(ctx) + be.NilErr(t, err) + be.Equal(t, "a", got.Name) + }) + t.Run("ErrorJSONV1", func(t *testing.T) { + var good, bad obj + err := requests.New(). + Transport(reqtest.ReplayString("HTTP/1.1 418 I'm a teapot\n\n{\"name\": \"brewing\"}")). + ToJSONV1(&good). + ErrorJSONV1(&bad). + Fetch(ctx) + be.True(t, errors.Is(err, requests.ErrInvalidHandled)) + be.Equal(t, "brewing", bad.Name) + be.Equal(t, "", good.Name) + }) +} diff --git a/validator_handler.go b/validator_handler.go index 27612e1..42762fe 100644 --- a/validator_handler.go +++ b/validator_handler.go @@ -32,3 +32,9 @@ var ErrInvalidHandled = errors.New("handled recovery from invalid response") func ErrorJSON(v any) ResponseHandler { return ValidatorHandler(DefaultValidator, ToJSON(v)) } + +// ErrorJSONV1 is like [ErrorJSON] +// but decodes the response using [ToJSONV1]. +func ErrorJSONV1(v any) ResponseHandler { + return ValidatorHandler(DefaultValidator, ToJSONV1(v)) +}