diff --git a/changelog/@unreleased/pr-353.v2.yml b/changelog/@unreleased/pr-353.v2.yml new file mode 100644 index 00000000..0a71106b --- /dev/null +++ b/changelog/@unreleased/pr-353.v2.yml @@ -0,0 +1,7 @@ +type: improvement +improvement: + description: > + codecs.JSON recognizes objects implementing Marshaler and Unmarshaler. + Added utility types for anonymous functions implementing JSON encoding. + links: + - https://github.com/palantir/conjure-go-runtime/pull/353 diff --git a/conjure-go-contract/codecs/json.go b/conjure-go-contract/codecs/json.go index 998eb8da..e013a6ac 100644 --- a/conjure-go-contract/codecs/json.go +++ b/conjure-go-contract/codecs/json.go @@ -15,10 +15,12 @@ package codecs import ( - "fmt" + "bytes" + "encoding/json" "io" "github.com/palantir/pkg/safejson" + werror "github.com/palantir/witchcraft-go-error" ) const ( @@ -37,14 +39,29 @@ func (codecJSON) Accept() string { } func (codecJSON) Decode(r io.Reader, v interface{}) error { - if err := safejson.Decoder(r).Decode(v); err != nil { - return fmt.Errorf("failed to decode JSON-encoded value: %s", err.Error()) + switch vv := v.(type) { + case jsonDecoder: + return werror.Convert(vv.DecodeJSON(r)) + case json.Unmarshaler: + data, err := io.ReadAll(r) + if err != nil { + return werror.Convert(err) + } + return werror.Convert(vv.UnmarshalJSON(data)) + default: + return werror.Convert(safejson.Decoder(r).Decode(v)) } - return nil } func (c codecJSON) Unmarshal(data []byte, v interface{}) error { - return safejson.Unmarshal(data, v) + switch vv := v.(type) { + case json.Unmarshaler: + return werror.Convert(vv.UnmarshalJSON(data)) + case jsonDecoder: + return werror.Convert(vv.DecodeJSON(bytes.NewReader(data))) + default: + return werror.Convert(safejson.Unmarshal(data, v)) + } } func (codecJSON) ContentType() string { @@ -52,12 +69,32 @@ func (codecJSON) ContentType() string { } func (codecJSON) Encode(w io.Writer, v interface{}) error { - if err := safejson.Encoder(w).Encode(v); err != nil { - return fmt.Errorf("failed to JSON-encode value: %s", err.Error()) + switch vv := v.(type) { + case jsonEncoder: + return werror.Convert(vv.EncodeJSON(w)) + case json.Marshaler: + out, err := vv.MarshalJSON() + if err != nil { + return werror.Convert(err) + } + _, err = w.Write(out) + return werror.Convert(err) + default: + return werror.Convert(safejson.Encoder(w).Encode(v)) } - return nil } func (c codecJSON) Marshal(v interface{}) ([]byte, error) { - return safejson.Marshal(v) + switch vv := v.(type) { + case json.Marshaler: + out, err := vv.MarshalJSON() + return out, werror.Convert(err) + case jsonEncoder: + data := bytes.NewBuffer(nil) // TODO: Use bytesbuffer pool + err := vv.EncodeJSON(data) + return data.Bytes(), werror.Convert(err) + default: + out, err := safejson.Marshal(v) + return out, werror.Convert(err) + } } diff --git a/conjure-go-contract/codecs/json_test.go b/conjure-go-contract/codecs/json_test.go new file mode 100644 index 00000000..e60435ce --- /dev/null +++ b/conjure-go-contract/codecs/json_test.go @@ -0,0 +1,62 @@ +// Copyright (c) 2022 Palantir Technologies. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package codecs_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/palantir/conjure-go-runtime/v2/conjure-go-contract/codecs" + "github.com/stretchr/testify/require" +) + +func TestJSON_UsesMethodsWhenImplemented(t *testing.T) { + t.Run("Marshal_Unmarshal", func(t *testing.T) { + obj := map[string]testJSONObject{} + err := codecs.JSON.Unmarshal([]byte(`{"key":null}`), &obj) + require.NoError(t, err) + require.Contains(t, obj, "key") + require.Equal(t, `null`, string(obj["key"].data)) + data, err := codecs.JSON.Marshal(obj) + require.NoError(t, err) + require.Equal(t, `{"key":null}`, string(data)) + }) + t.Run("Encode_Decode", func(t *testing.T) { + obj := map[string]testJSONObject{} + r := strings.NewReader(`{"key":"abc"}`) + err := codecs.JSON.Decode(r, &obj) + require.NoError(t, err) + require.Contains(t, obj, "key") + require.Equal(t, `"abc"`, string(obj["key"].data)) + out := bytes.Buffer{} + err = codecs.JSON.Encode(&out, obj) + require.NoError(t, err) + require.Equal(t, "{\"key\":\"abc\"}\n", out.String()) + }) +} + +type testJSONObject struct { + data []byte +} + +func (t testJSONObject) MarshalJSON() ([]byte, error) { + return t.data, nil +} + +func (t *testJSONObject) UnmarshalJSON(data []byte) error { + t.data = data + return nil +} diff --git a/conjure-go-contract/codecs/json_types.go b/conjure-go-contract/codecs/json_types.go new file mode 100644 index 00000000..b496e156 --- /dev/null +++ b/conjure-go-contract/codecs/json_types.go @@ -0,0 +1,63 @@ +// Copyright (c) 2022 Palantir Technologies. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package codecs + +import ( + "io" +) + +// jsonDecoder is an interface which may be implemented by objects passed to the Decode and Unmarshal methods. +// If implemented, the standard json.Decoder is bypassed. +type jsonDecoder interface { + DecodeJSON(r io.Reader) error +} + +// JSONDecoderFunc is an alias type which implements jsonDecoder. +// It can be used to declare anonymous/dynamic unmarshal logic. +type JSONDecoderFunc func(r io.Reader) error + +func (f JSONDecoderFunc) DecodeJSON(r io.Reader) error { + return f(r) +} + +// JSONUnmarshalFunc is an alias type which implements json.Unmarshaler. +// It can be used to declare anonymous/dynamic unmarshal logic. +type JSONUnmarshalFunc func([]byte) error + +func (f JSONUnmarshalFunc) UnmarshalJSON(data []byte) error { + return f(data) +} + +// jsonEncoder is an interface which may be implemented by objects passed to the Encode and Marshal methods. +// If implemented, the standard json.Encoder is bypassed. +type jsonEncoder interface { + EncodeJSON(w io.Writer) error +} + +// JSONEncoderFunc is an alias type which implements jsonEncoder. +// It can be used to declare anonymous/dynamic marshal logic. +type JSONEncoderFunc func(w io.Writer) error + +func (f JSONEncoderFunc) EncodeJSON(w io.Writer) error { + return f(w) +} + +// JSONMarshalFunc is an alias type which implements json.Marshaler. +// It can be used to declare anonymous/dynamic marshal logic. +type JSONMarshalFunc func() ([]byte, error) + +func (f JSONMarshalFunc) MarshalJSON() ([]byte, error) { + return f() +}