From 47415935793f0fb0f12225bccbc38749dad1d136 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 28 Oct 2024 16:19:02 +0100 Subject: [PATCH] Add `libs/dyn/jsonsaver` This package can be used to marshal a `dyn.Value` as JSON and retain the ordering of keys in a mapping. The output does not encode HTML characters as opposed to the default behavior of `json.Marshal`. Otherwise this is no different from using `json.Marshal` with `v.AsAny()`. --- libs/dyn/jsonsaver/encoder.go | 33 ++++++++++++ libs/dyn/jsonsaver/encoder_test.go | 39 ++++++++++++++ libs/dyn/jsonsaver/saver.go | 83 ++++++++++++++++++++++++++++++ libs/dyn/jsonsaver/saver_test.go | 58 +++++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 libs/dyn/jsonsaver/encoder.go create mode 100644 libs/dyn/jsonsaver/encoder_test.go create mode 100644 libs/dyn/jsonsaver/saver.go create mode 100644 libs/dyn/jsonsaver/saver_test.go diff --git a/libs/dyn/jsonsaver/encoder.go b/libs/dyn/jsonsaver/encoder.go new file mode 100644 index 000000000..f26e774ae --- /dev/null +++ b/libs/dyn/jsonsaver/encoder.go @@ -0,0 +1,33 @@ +package jsonsaver + +import ( + "bytes" + "encoding/json" +) + +// The encoder type encapsulates a [json.Encoder] and its target buffer. +// Escaping of HTML characters in the output is disabled. +type encoder struct { + *json.Encoder + *bytes.Buffer +} + +func newEncoder() encoder { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + return encoder{enc, &buf} +} + +func marshalNoEscape(v any) ([]byte, error) { + enc := newEncoder() + err := enc.Encode(v) + return enc.Bytes(), err +} + +func marshalIndentNoEscape(v any, prefix, indent string) ([]byte, error) { + enc := newEncoder() + enc.SetIndent(prefix, indent) + err := enc.Encode(v) + return enc.Bytes(), err +} diff --git a/libs/dyn/jsonsaver/encoder_test.go b/libs/dyn/jsonsaver/encoder_test.go new file mode 100644 index 000000000..101481d39 --- /dev/null +++ b/libs/dyn/jsonsaver/encoder_test.go @@ -0,0 +1,39 @@ +package jsonsaver + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncoder_marshalNoEscape(t *testing.T) { + out, err := marshalNoEscape("1 < 2") + require.NoError(t, err) + + // Confirm the output. + assert.JSONEq(t, `"1 < 2"`, string(out)) + + // Confirm that HTML escaping is disabled. + assert.False(t, strings.Contains(string(out), "\\u003c")) + + // Confirm that the encoder writes a trailing newline. + assert.True(t, strings.HasSuffix(string(out), "\n")) +} + +func TestEncoder_marshalIndentNoEscape(t *testing.T) { + out, err := marshalIndentNoEscape([]string{"1 < 2", "2 < 3"}, "", " ") + require.NoError(t, err) + + // Confirm the output. + assert.JSONEq(t, `["1 < 2", "2 < 3"]`, string(out)) + + // Confirm that HTML escaping is disabled. + assert.False(t, strings.Contains(string(out), "\\u003c")) + + // Confirm that the encoder performs indenting and writes a trailing newline. + assert.True(t, strings.HasPrefix(string(out), "[\n")) + assert.True(t, strings.Contains(string(out), " \"1 < 2\",\n")) + assert.True(t, strings.HasSuffix(string(out), "]\n")) +} diff --git a/libs/dyn/jsonsaver/saver.go b/libs/dyn/jsonsaver/saver.go new file mode 100644 index 000000000..538776e6a --- /dev/null +++ b/libs/dyn/jsonsaver/saver.go @@ -0,0 +1,83 @@ +package jsonsaver + +import ( + "bytes" + "fmt" + + "github.com/databricks/cli/libs/dyn" +) + +// Marshal is a version of [json.Marshal] for [dyn.Value]. +// +// The output does not escape HTML characters in strings. +func Marshal(v dyn.Value) ([]byte, error) { + return marshalNoEscape(wrap{v}) +} + +// MarshalIndent is a version of [json.MarshalIndent] for [dyn.Value]. +// +// The output does not escape HTML characters in strings. +func MarshalIndent(v dyn.Value, prefix, indent string) ([]byte, error) { + return marshalIndentNoEscape(wrap{v}, prefix, indent) +} + +// Wrapper type for [dyn.Value] to expose the [json.Marshaler] interface. +type wrap struct { + v dyn.Value +} + +// MarshalJSON implements the [json.Marshaler] interface for the [dyn.Value] wrapper type. +func (w wrap) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + if err := marshalValue(&buf, w.v); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// marshalValue recursively writes JSON for a [dyn.Value] to the buffer. +func marshalValue(buf *bytes.Buffer, v dyn.Value) error { + switch v.Kind() { + case dyn.KindString, dyn.KindBool, dyn.KindInt, dyn.KindFloat, dyn.KindTime, dyn.KindNil: + out, err := marshalNoEscape(v.AsAny()) + if err != nil { + return err + } + + // The encoder writes a trailing newline, so we need to remove it + // to avoid adding extra newlines when embedding this JSON. + out = out[:len(out)-1] + buf.Write(out) + case dyn.KindMap: + buf.WriteByte('{') + for i, pair := range v.MustMap().Pairs() { + if i > 0 { + buf.WriteByte(',') + } + // Marshal the key + if err := marshalValue(buf, pair.Key); err != nil { + return err + } + buf.WriteByte(':') + // Marshal the value + if err := marshalValue(buf, pair.Value); err != nil { + return err + } + } + buf.WriteByte('}') + case dyn.KindSequence: + buf.WriteByte('[') + for i, item := range v.MustSequence() { + if i > 0 { + buf.WriteByte(',') + } + if err := marshalValue(buf, item); err != nil { + return err + } + } + buf.WriteByte(']') + default: + return fmt.Errorf("unsupported kind: %d", v.Kind()) + } + return nil +} diff --git a/libs/dyn/jsonsaver/saver_test.go b/libs/dyn/jsonsaver/saver_test.go new file mode 100644 index 000000000..7355dce8c --- /dev/null +++ b/libs/dyn/jsonsaver/saver_test.go @@ -0,0 +1,58 @@ +package jsonsaver + +import ( + "testing" + + "github.com/databricks/cli/libs/dyn" + "github.com/stretchr/testify/require" +) + +func TestMarshalString(t *testing.T) { + b, err := Marshal(dyn.V("string")) + require.NoError(t, err) + require.JSONEq(t, `"string"`, string(b)) +} + +func TestMarshalBool(t *testing.T) { + b, err := Marshal(dyn.V(true)) + require.NoError(t, err) + require.JSONEq(t, `true`, string(b)) +} + +func TestMarshalInt(t *testing.T) { + b, err := Marshal(dyn.V(42)) + require.NoError(t, err) + require.JSONEq(t, `42`, string(b)) +} + +func TestMarshalFloat(t *testing.T) { + b, err := Marshal(dyn.V(42.1)) + require.NoError(t, err) + require.JSONEq(t, `42.1`, string(b)) +} + +func TestMarshalTime(t *testing.T) { + b, err := Marshal(dyn.V(dyn.MustTime("2021-01-01T00:00:00Z"))) + require.NoError(t, err) + require.JSONEq(t, `"2021-01-01T00:00:00Z"`, string(b)) +} + +func TestMarshalMap(t *testing.T) { + m := dyn.NewMapping() + m.Set(dyn.V("key1"), dyn.V("value1")) + m.Set(dyn.V("key2"), dyn.V("value2")) + + b, err := Marshal(dyn.V(m)) + require.NoError(t, err) + require.JSONEq(t, `{"key1":"value1","key2":"value2"}`, string(b)) +} + +func TestMarshalSequence(t *testing.T) { + var s []dyn.Value + s = append(s, dyn.V("value1")) + s = append(s, dyn.V("value2")) + + b, err := Marshal(dyn.V(s)) + require.NoError(t, err) + require.JSONEq(t, `["value1","value2"]`, string(b)) +}