Skip to content

Commit

Permalink
Add AllowUnknownFields().
Browse files Browse the repository at this point in the history
  • Loading branch information
jmalloc committed Apr 25, 2023
1 parent a281213 commit 8d68699
Show file tree
Hide file tree
Showing 11 changed files with 151 additions and 40 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
3 changes: 3 additions & 0 deletions internal/jsonx/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Package jsonx contains utilities for dealing with JSON and the encoding/json
// package.
package jsonx
25 changes: 25 additions & 0 deletions internal/jsonx/error.go
Original file line number Diff line number Diff line change
@@ -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 "<field name>"`)
return strings.HasPrefix(err.Error(), "json:")
}
}
47 changes: 47 additions & 0 deletions internal/jsonx/unmarshal.go
Original file line number Diff line number Diff line change
@@ -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)
39 changes: 11 additions & 28 deletions json.go
Original file line number Diff line number Diff line change
@@ -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 "<field name>"`)
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
}
}
13 changes: 6 additions & 7 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
Expand Down Expand Up @@ -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)),
Expand Down
14 changes: 14 additions & 0 deletions request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,20 @@ var _ = Describe("type Request", func() {
Expect(rpcErr.Code()).To(Equal(InvalidParametersCode))
Expect(rpcErr.Unwrap()).To(MatchError("<error>"))
})

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(&params, AllowUnknownFields(true))
Expect(err).ShouldNot(HaveOccurred())
Expect(params.Value).To(Equal(123))
})
})
})
})
Expand Down
6 changes: 4 additions & 2 deletions response.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"fmt"
"io"
"unicode"

"github.com/dogmatiq/harpy/internal/jsonx"
)

// Response is an interface for a JSON-RPC response object.
Expand Down Expand Up @@ -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)
}

Expand Down
4 changes: 3 additions & 1 deletion transport/httptransport/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -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)
}

Expand Down

0 comments on commit 8d68699

Please sign in to comment.