Skip to content

Commit

Permalink
Add libs/dyn/jsonsaver (#1862)
Browse files Browse the repository at this point in the history
## Changes

This package can be used to marshal a `dyn.Value` as JSON and retain the
ordering of keys in a mapping. Unlike the default behavior of
`json.Marshal,` the output does not encode HTML characters.

Otherwise, this is no different from using `JSON.Marshal` with
`v.AsAny().`

## Tests

Unit tests.
  • Loading branch information
pietern authored Oct 29, 2024
1 parent 20fd401 commit fa25b92
Show file tree
Hide file tree
Showing 5 changed files with 273 additions and 0 deletions.
4 changes: 4 additions & 0 deletions libs/dyn/dynassert/assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,7 @@ func PanicsWithError(t assert.TestingT, errString string, f func(), msgAndArgs .
func NotPanics(t assert.TestingT, f func(), msgAndArgs ...interface{}) bool {
return assert.NotPanics(t, f, msgAndArgs...)
}

func JSONEq(t assert.TestingT, expected string, actual string, msgAndArgs ...interface{}) bool {
return assert.JSONEq(t, expected, actual, msgAndArgs...)
}
39 changes: 39 additions & 0 deletions libs/dyn/jsonsaver/encoder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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)

// By default, json.Encoder escapes HTML characters, converting symbols like '<' to '\u003c'.
// This behavior helps prevent XSS attacks when JSON is embedded within HTML.
// However, we disable this feature since we're not dealing with HTML context.
// Keeping the escapes enabled would result in unnecessary differences when processing JSON payloads
// that already contain escaped characters.
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
}
41 changes: 41 additions & 0 deletions libs/dyn/jsonsaver/encoder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package jsonsaver

import (
"testing"

assert "github.com/databricks/cli/libs/dyn/dynassert"
)

func TestEncoder_MarshalNoEscape(t *testing.T) {
out, err := marshalNoEscape("1 < 2")
if !assert.NoError(t, err) {
return
}

// Confirm the output.
assert.JSONEq(t, `"1 < 2"`, string(out))

// Confirm that HTML escaping is disabled.
assert.NotContains(t, string(out), "\\u003c")

// Confirm that the encoder writes a trailing newline.
assert.Contains(t, string(out), "\n")
}

func TestEncoder_MarshalIndentNoEscape(t *testing.T) {
out, err := marshalIndentNoEscape([]string{"1 < 2", "2 < 3"}, "", " ")
if !assert.NoError(t, err) {
return
}

// Confirm the output.
assert.JSONEq(t, `["1 < 2", "2 < 3"]`, string(out))

// Confirm that HTML escaping is disabled.
assert.NotContains(t, string(out), "\\u003c")

// Confirm that the encoder performs indenting and writes a trailing newline.
assert.Contains(t, string(out), "[\n")
assert.Contains(t, string(out), " \"1 < 2\",\n")
assert.Contains(t, string(out), "]\n")
}
89 changes: 89 additions & 0 deletions libs/dyn/jsonsaver/marshal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package jsonsaver

import (
"bytes"
"fmt"

"github.com/databricks/cli/libs/dyn"
)

// Marshal is a version of [json.Marshal] for [dyn.Value].
//
// Objects in the output retain the order of keys as they appear in the underlying [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].
//
// Objects in the output retain the order of keys as they appear in the underlying [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(',')
}
// Require keys to be strings.
if pair.Key.Kind() != dyn.KindString {
return fmt.Errorf("map key must be a string, got %s", pair.Key.Kind())
}
// 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
}
100 changes: 100 additions & 0 deletions libs/dyn/jsonsaver/marshal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package jsonsaver

import (
"testing"

"github.com/databricks/cli/libs/dyn"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)

func TestMarshal_String(t *testing.T) {
b, err := Marshal(dyn.V("string"))
if assert.NoError(t, err) {
assert.JSONEq(t, `"string"`, string(b))
}
}

func TestMarshal_Bool(t *testing.T) {
b, err := Marshal(dyn.V(true))
if assert.NoError(t, err) {
assert.JSONEq(t, `true`, string(b))
}
}

func TestMarshal_Int(t *testing.T) {
b, err := Marshal(dyn.V(42))
if assert.NoError(t, err) {
assert.JSONEq(t, `42`, string(b))
}
}

func TestMarshal_Float(t *testing.T) {
b, err := Marshal(dyn.V(42.1))
if assert.NoError(t, err) {
assert.JSONEq(t, `42.1`, string(b))
}
}

func TestMarshal_Time(t *testing.T) {
b, err := Marshal(dyn.V(dyn.MustTime("2021-01-01T00:00:00Z")))
if assert.NoError(t, err) {
assert.JSONEq(t, `"2021-01-01T00:00:00Z"`, string(b))
}
}

func TestMarshal_Map(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))
if assert.NoError(t, err) {
assert.JSONEq(t, `{"key1":"value1","key2":"value2"}`, string(b))
}
}

func TestMarshal_Sequence(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))
if assert.NoError(t, err) {
assert.JSONEq(t, `["value1","value2"]`, string(b))
}
}

func TestMarshal_Complex(t *testing.T) {
map1 := dyn.NewMapping()
map1.Set(dyn.V("str1"), dyn.V("value1"))
map1.Set(dyn.V("str2"), dyn.V("value2"))

seq1 := []dyn.Value{}
seq1 = append(seq1, dyn.V("value1"))
seq1 = append(seq1, dyn.V("value2"))

root := dyn.NewMapping()
root.Set(dyn.V("map1"), dyn.V(map1))
root.Set(dyn.V("seq1"), dyn.V(seq1))

// Marshal without indent.
b, err := Marshal(dyn.V(root))
if assert.NoError(t, err) {
assert.Equal(t, `{"map1":{"str1":"value1","str2":"value2"},"seq1":["value1","value2"]}`+"\n", string(b))
}

// Marshal with indent.
b, err = MarshalIndent(dyn.V(root), "", " ")
if assert.NoError(t, err) {
assert.Equal(t, `{
"map1": {
"str1": "value1",
"str2": "value2"
},
"seq1": [
"value1",
"value2"
]
}`+"\n", string(b))
}
}

0 comments on commit fa25b92

Please sign in to comment.