From f4d730b4d9a9ad94d01563e97a786f0d666d3251 Mon Sep 17 00:00:00 2001 From: Dylan Bourque Date: Fri, 22 Apr 2022 12:09:15 -0500 Subject: [PATCH] reworked JSON marshaling support --- json.go | 134 +++++++++++++++++++++++++++++++++++++++------------ json_test.go | 32 ++++++++++-- 2 files changed, 131 insertions(+), 35 deletions(-) diff --git a/json.go b/json.go index d7e0b55..af140bc 100644 --- a/json.go +++ b/json.go @@ -2,7 +2,9 @@ package csproto import ( "bytes" + "encoding/json" "fmt" + "reflect" gogojson "github.com/gogo/protobuf/jsonpb" gogo "github.com/gogo/protobuf/proto" @@ -12,61 +14,131 @@ import ( protov2 "google.golang.org/protobuf/proto" ) -// JSONOptions defines the JSON formatting options -// -// These options are a subset of those available by each of the three supported runtimes. The supported -// options consist of the things that are provided by all 3 runtimes in the same manner. If you need -// the full spectrum of the formatting options you will need to use the appropriate runtime. -type JSONOptions struct { - // If set, generate multi-line output such that each field is prefixed by Indent and terminated - // by a newline - Indent string - // If true, enum fields will be output as integers rather than the enum value names - UseEnumNumbers bool - // If true, include zero-valued fields in the JSON output - EmitZeroValues bool +// JSONMarshaler returns an implementation of the json.Marshaler interface that formats msg to JSON +// using the specified options. +func JSONMarshaler(msg interface{}, opts ...JSONOption) json.Marshaler { + m := jsonMarshaler{ + msg: msg, + } + for _, o := range opts { + o(&m.opts) + } + return &m } -// MarshalJSON formats msg into JSON using the specified formatting options -func MarshalJSON(msg interface{}, opts JSONOptions) ([]byte, error) { +// jsonMarshaler wraps a Protobuf message and satisfies the json.Marshaler interface +type jsonMarshaler struct { + msg interface{} + opts jsonOptions +} + +// compile-time interface check +var _ json.Marshaler = (*jsonMarshaler)(nil) + +// MarshalJSON satisfies the json.Marshaler interface +// +// If the wrapped message is nil, or a non-nil interface value holding nil, this method returns nil. +// If the message satisfies the json.Marshaler interface we delegate to it directly. Otherwise, +// this method calls the appropriate underlying runtime (Gogo vs Google V1 vs Google V2) based on +// the message's actual type. +func (m *jsonMarshaler) MarshalJSON() ([]byte, error) { + if m.msg == nil || reflect.ValueOf(m.msg).IsNil() { + return nil, nil + } + + // call the message's implementation directly, if present + if jm, ok := m.msg.(json.Marshaler); ok { + return jm.MarshalJSON() + } + var buf bytes.Buffer - if m, isGogo := msg.(gogo.Message); isGogo { + // Gogo message? + if msg, isGogo := m.msg.(gogo.Message); isGogo { jm := gogojson.Marshaler{ - Indent: opts.Indent, - EnumsAsInts: opts.UseEnumNumbers, - EmitDefaults: opts.EmitZeroValues, + Indent: m.opts.indent, + EnumsAsInts: m.opts.useEnumNumbers, + EmitDefaults: m.opts.emitZeroValues, } - if err := jm.Marshal(&buf, m); err != nil { + if err := jm.Marshal(&buf, msg); err != nil { return nil, fmt.Errorf("unable to marshal message to JSON: %w", err) } return buf.Bytes(), nil } - if m, isV1 := msg.(protov1.Message); isV1 { + // Google V1 message? + if msg, isV1 := m.msg.(protov1.Message); isV1 { jm := jsonpb.Marshaler{ - Indent: opts.Indent, - EnumsAsInts: opts.UseEnumNumbers, - EmitDefaults: opts.EmitZeroValues, + Indent: m.opts.indent, + EnumsAsInts: m.opts.useEnumNumbers, + EmitDefaults: m.opts.emitZeroValues, } - if err := jm.Marshal(&buf, m); err != nil { + if err := jm.Marshal(&buf, msg); err != nil { return nil, fmt.Errorf("unable to marshal message to JSON: %w", err) } return buf.Bytes(), nil } - if m, isV2 := msg.(protov2.Message); isV2 { + // Google V2 message? + if msg, isV2 := m.msg.(protov2.Message); isV2 { mo := protojson.MarshalOptions{ - Indent: opts.Indent, - UseProtoNames: opts.UseEnumNumbers, - EmitUnpopulated: opts.EmitZeroValues, + Indent: m.opts.indent, + UseProtoNames: m.opts.useEnumNumbers, + EmitUnpopulated: m.opts.emitZeroValues, } - b, err := mo.Marshal(m) + b, err := mo.Marshal(msg) if err != nil { return nil, fmt.Errorf("unable to marshal message to JSON: %w", err) } return b, nil } - return nil, fmt.Errorf("unsupported message type %T", msg) + return nil, fmt.Errorf("unsupported message type %T", m.msg) +} + +// JSONOption defines a function that sets a specific JSON formatting option +type JSONOption func(*jsonOptions) + +// JSONIndent returns a JSONOption that configures the JSON indentation. +// +// Passing an empty string disables indentation. If not empty, indent must consist of only spaces or +// tab characters. +func JSONIndent(indent string) JSONOption { + return func(opts *jsonOptions) { + opts.indent = indent + } +} + +// JSONUseEnumNumbers returns a JSON option that enables or disables outputting integer values rather +// than the enum names for enum fields. +func JSONUseEnumNumbers(useNumbers bool) JSONOption { + return func(opts *jsonOptions) { + opts.useEnumNumbers = useNumbers + } +} + +// JSONIncludeZeroValues returns a JSON option that enables or disables including zero-valued fields +// in the JSON output. +func JSONIncludeZeroValues(emitZeroValues bool) JSONOption { + return func(opts *jsonOptions) { + opts.emitZeroValues = emitZeroValues + } +} + +// jsonOptions defines the JSON formatting options +// +// These options are a subset of those available by each of the three supported runtimes. The supported +// options consist of the things that are provided by all 3 runtimes in the same manner. If you need +// the full spectrum of the formatting options you will need to use the appropriate runtime. +// +// The zero value results in no indentation, enum values using the enum names, and not including +// zero-valued fields in the output. +type jsonOptions struct { + // If set, generate multi-line output such that each field is prefixed by indent and terminated + // by a newline + indent string + // If true, enum fields will be output as integers rather than the enum value names + useEnumNumbers bool + // If true, include zero-valued fields in the JSON output + emitZeroValues bool } diff --git a/json_test.go b/json_test.go index d17bc32..874cec9 100644 --- a/json_test.go +++ b/json_test.go @@ -20,7 +20,7 @@ func TestMarshalJSON(t *testing.T) { ts := timestamppb.Now() expected := fmt.Sprintf(`{"seconds":"%d","nanos":%d}`, ts.GetSeconds(), ts.GetNanos()) - res, err := csproto.MarshalJSON(ts, csproto.JSONOptions{}) + res, err := csproto.JSONMarshaler(ts).MarshalJSON() assert.NoError(t, err) assert.JSONEq(t, expected, string(res)) @@ -29,7 +29,10 @@ func TestMarshalJSON(t *testing.T) { ts := timestamppb.Now() expected := fmt.Sprintf("{\n \"seconds\":\"%d\",\n \"nanos\":%d\n}", ts.GetSeconds(), ts.GetNanos()) - res, err := csproto.MarshalJSON(ts, csproto.JSONOptions{Indent: " "}) + opts := []csproto.JSONOption{ + csproto.JSONIndent(" "), + } + res, err := csproto.JSONMarshaler(ts, opts...).MarshalJSON() assert.NoError(t, err) assert.JSONEq(t, expected, string(res)) @@ -39,7 +42,10 @@ func TestMarshalJSON(t *testing.T) { ts.Nanos = 0 expected := fmt.Sprintf(`{"seconds":"%d"}`, ts.GetSeconds()) - res, err := csproto.MarshalJSON(ts, csproto.JSONOptions{EmitZeroValues: false}) + opts := []csproto.JSONOption{ + csproto.JSONIncludeZeroValues(false), + } + res, err := csproto.JSONMarshaler(ts, opts...).MarshalJSON() assert.NoError(t, err) assert.JSONEq(t, expected, string(res)) @@ -49,7 +55,25 @@ func TestMarshalJSON(t *testing.T) { ts.Nanos = 0 expected := fmt.Sprintf(`{"seconds":"%d","nanos":0}`, ts.GetSeconds()) - res, err := csproto.MarshalJSON(ts, csproto.JSONOptions{EmitZeroValues: true}) + opts := []csproto.JSONOption{ + csproto.JSONIncludeZeroValues(true), + } + res, err := csproto.JSONMarshaler(ts, opts...).MarshalJSON() + + assert.NoError(t, err) + assert.JSONEq(t, expected, string(res)) + }) + t.Run("enable-all", func(t *testing.T) { + ts := timestamppb.Now() + ts.Nanos = 0 + expected := fmt.Sprintf("{\n \"seconds\":\"%d\",\n \"nanos\":0\n}", ts.GetSeconds()) + + opts := []csproto.JSONOption{ + csproto.JSONIndent(" "), + csproto.JSONIncludeZeroValues(true), + csproto.JSONUseEnumNumbers(true), + } + res, err := csproto.JSONMarshaler(ts, opts...).MarshalJSON() assert.NoError(t, err) assert.JSONEq(t, expected, string(res))