diff --git a/CHANGELOG.md b/CHANGELOG.md index 1492333..6ad7112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ The format is based on [Keep a Changelog], and this project adheres to [keep a changelog]: https://keepachangelog.com/en/1.0.0/ [semantic versioning]: https://semver.org/spec/v2.0.0.html +## [0.8.1] - 2023-04-25 + +### Added + +- Add `UnmarshalOption` type +- Add `AllowUnknownFields()` option + +### Changed + +- Change `Request.UnmarshalParameters()` to accept unmarshaling options +- Change `Error.UnmarshalData()` to accept unmarshaling options +- Change `httptransport.Client.Call()` to accept unmarshaling options + ## [0.8.0] - 2022-12-02 This release removes Harpy's dependency on the deprecated diff --git a/error.go b/error.go index 2dbb174..6e9ee05 100644 --- a/error.go +++ b/error.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "sync" + + "github.com/dogmatiq/harpy/internal/jsonx" ) // Error is a Go error that describes a JSON-RPC error. @@ -139,13 +141,13 @@ func (e Error) MarshalData() (_ json.RawMessage, ok bool, _ error) { // UnmarshalData unmarshals the user-defined data into v. // // ok is false if there is no user-defined data associated with the error. -func (e Error) UnmarshalData(v any) (ok bool, _ error) { +func (e Error) UnmarshalData(v any, options ...UnmarshalOption) (ok bool, _ error) { data, ok, err := e.MarshalData() if !ok || err != nil { return false, err } - return true, json.Unmarshal(data, v) + return true, jsonx.Unmarshal(data, v, options...) } // Error returns the error message. diff --git a/error_test.go b/error_test.go index 90a4641..04784a8 100644 --- a/error_test.go +++ b/error_test.go @@ -158,6 +158,27 @@ var _ = Describe("type Error", func() { _, err := e.UnmarshalData(&v) Expect(err).To(MatchError("json: cannot unmarshal string into Go value of type int")) }) + + It("supports the AllowUnknownFields() option", func() { + type In struct { + Foo int `json:"foo"` + Bar int `json:"bar"` + } + type Out struct { + Foo int `json:"foo"` + } + + in := In{1, 2} + out := Out{1} + + e := NewError(100, WithData(in)) + + var v Out + ok, err := e.UnmarshalData(&v, AllowUnknownFields(true)) + Expect(err).ShouldNot(HaveOccurred()) + Expect(ok).To(BeTrue()) + Expect(v).To(Equal(out)) + }) }) Describe("func Error()", func() { diff --git a/internal/jsonx/doc.go b/internal/jsonx/doc.go new file mode 100644 index 0000000..c995be9 --- /dev/null +++ b/internal/jsonx/doc.go @@ -0,0 +1,3 @@ +// Package jsonx contains utilities for dealing with JSON and the encoding/json +// package. +package jsonx diff --git a/internal/jsonx/error.go b/internal/jsonx/error.go new file mode 100644 index 0000000..74c6e01 --- /dev/null +++ b/internal/jsonx/error.go @@ -0,0 +1,25 @@ +package jsonx + +import ( + "encoding/json" + "strings" +) + +// IsParseError returns true if err indicates a JSON parse failure of some kind. +func IsParseError(err error) bool { + switch err.(type) { + case nil: + return false + case *json.SyntaxError: + return true + case *json.UnmarshalTypeError: + return true + default: + // Unfortunately, some JSON errors do not have distinct types. For + // example, when parsing using a decoder with DisallowUnknownFields() + // enabled an unexpected field is reported using the equivalent of: + // + // errors.New(`json: unknown field ""`) + return strings.HasPrefix(err.Error(), "json:") + } +} diff --git a/internal/jsonx/unmarshal.go b/internal/jsonx/unmarshal.go new file mode 100644 index 0000000..53a2b2d --- /dev/null +++ b/internal/jsonx/unmarshal.go @@ -0,0 +1,47 @@ +package jsonx + +import ( + "bytes" + "encoding/json" + "io" +) + +// Decode unmarshals JSON content from r into v. +func Decode[O ~UnmarshalOption]( + r io.Reader, + v any, + options ...O, +) error { + var opts UnmarshalOptions + for _, fn := range options { + fn(&opts) + } + + dec := json.NewDecoder(r) + if !opts.AllowUnknownFields { + dec.DisallowUnknownFields() + } + + return dec.Decode(&v) +} + +// Unmarshal unmarshals JSON content from data into v. +func Unmarshal[O ~UnmarshalOption]( + data []byte, + v any, + options ...O, +) error { + return Decode( + bytes.NewReader(data), + v, + options..., + ) +} + +// UnmarshalOptions is a set of options that control how JSON is unmarshaled. +type UnmarshalOptions struct { + AllowUnknownFields bool +} + +// UnmarshalOption is a function that changes the behavior of JSON unmarshaling. +type UnmarshalOption = func(*UnmarshalOptions) diff --git a/json.go b/json.go index bf300d1..8279ed3 100644 --- a/json.go +++ b/json.go @@ -1,35 +1,18 @@ package harpy import ( - "encoding/json" - "io" - "strings" + "github.com/dogmatiq/harpy/internal/jsonx" ) -// isJSONError returns true if err indicates a JSON parse failure of some kind. -func isJSONError(err error) bool { - switch err.(type) { - case nil: - return false - case *json.SyntaxError: - return true - case *json.UnmarshalTypeError: - return true - default: - // Unfortunately, some JSON errors do not have distinct types. For - // example, when parsing using a decoder with DisallowUnknownFields() - // enabled an unexpected field is reported using the equivalent of: - // - // errors.New(`json: unknown field ""`) - return strings.HasPrefix(err.Error(), "json:") - } -} +// UnmarshalOption is an option that changes the behavior of JSON unmarshaling. +type UnmarshalOption func(*jsonx.UnmarshalOptions) -// unmarshalJSON unmarshals JSON content from r into v. The main reason for this -// function is to disallow unknown fields. -func unmarshalJSON(r io.Reader, v any) error { - dec := json.NewDecoder(r) - dec.DisallowUnknownFields() - - return dec.Decode(&v) +// AllowUnknownFields is an UnmarshalOption that controls whether parameters, +// results and error data may contain unknown fields. +// +// Unknown fields are disallowed by default. +func AllowUnknownFields(allow bool) UnmarshalOption { + return func(opts *jsonx.UnmarshalOptions) { + opts.AllowUnknownFields = allow + } } diff --git a/request.go b/request.go index a850791..c8dc9ba 100644 --- a/request.go +++ b/request.go @@ -7,6 +7,8 @@ import ( "fmt" "io" "unicode" + + "github.com/dogmatiq/harpy/internal/jsonx" ) // jsonRPCVersion is the version that must appear in the "jsonrpc" field of @@ -187,11 +189,8 @@ func (r Request) ValidateClientSide() (err Error, ok bool) { // If v implements the Validatable interface, it calls v.Validate() after // unmarshaling successfully. If validation fails it wraps the validation error // in the appropriate native JSON-RPC error. -func (r Request) UnmarshalParameters(v any) error { - dec := json.NewDecoder(bytes.NewReader(r.Parameters)) - dec.DisallowUnknownFields() - - if err := dec.Decode(v); err != nil { +func (r Request) UnmarshalParameters(v any, options ...UnmarshalOption) error { + if err := jsonx.Unmarshal(r.Parameters, v, options...); err != nil { return InvalidParameters( WithCause(err), ) @@ -365,9 +364,9 @@ func unmarshalBatchRequest(r *bufio.Reader) (RequestSet, error) { // unmarshalJSONForRequest unmarshals JSON content from r into v. If the JSON // cannot be parsed it returns a JSON-RPC error with the "parse error" code. func unmarshalJSONForRequest(r io.Reader, v any) error { - err := unmarshalJSON(r, v) + err := jsonx.Decode[jsonx.UnmarshalOption](r, v) - if isJSONError(err) { + if jsonx.IsParseError(err) { return NewErrorWithReservedCode( ParseErrorCode, WithCause(fmt.Errorf("unable to parse request: %w", err)), diff --git a/request_test.go b/request_test.go index 93f7963..e6c81e0 100644 --- a/request_test.go +++ b/request_test.go @@ -437,6 +437,20 @@ var _ = Describe("type Request", func() { Expect(rpcErr.Code()).To(Equal(InvalidParametersCode)) Expect(rpcErr.Unwrap()).To(MatchError("")) }) + + It("supports the AllowUnknownFields() option", func() { + req := Request{ + Version: "2.0", + Parameters: []byte(`{"Value":123, "Unknown": 456}`), + } + + var params struct { + Value int + } + err := req.UnmarshalParameters(¶ms, AllowUnknownFields(true)) + Expect(err).ShouldNot(HaveOccurred()) + Expect(params.Value).To(Equal(123)) + }) }) }) }) diff --git a/response.go b/response.go index 98ca676..da168aa 100644 --- a/response.go +++ b/response.go @@ -8,6 +8,8 @@ import ( "fmt" "io" "unicode" + + "github.com/dogmatiq/harpy/internal/jsonx" ) // Response is an interface for a JSON-RPC response object. @@ -353,9 +355,9 @@ func unmarshalBatchResponse(r *bufio.Reader) (ResponseSet, error) { // unmarshalJSONForResponse unmarshals JSON content from r into v. func unmarshalJSONForResponse(r io.Reader, v any) error { - err := unmarshalJSON(r, v) + err := jsonx.Decode[jsonx.UnmarshalOption](r, v) - if isJSONError(err) { + if jsonx.IsParseError(err) { return fmt.Errorf("unable to parse response: %w", err) } diff --git a/transport/httptransport/client.go b/transport/httptransport/client.go index b48e9cb..c1b305e 100644 --- a/transport/httptransport/client.go +++ b/transport/httptransport/client.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "github.com/dogmatiq/harpy" + "github.com/dogmatiq/harpy/internal/jsonx" ) // Client is a HTTP-based JSON-RPC client. @@ -32,6 +33,7 @@ func (c *Client) Call( ctx context.Context, method string, params, result any, + options ...harpy.UnmarshalOption, ) error { requestID := atomic.AddUint32(&c.prevID, 1) req, err := harpy.NewCallRequest( @@ -101,7 +103,7 @@ func (c *Client) Call( ) } - if err := json.Unmarshal(res.Result, result); err != nil { + if err := jsonx.Unmarshal(res.Result, result, options...); err != nil { return fmt.Errorf("unable to process JSON-RPC response (%s): unable to unmarshal result: %w", method, err) }