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()`.
This commit is contained in:
Pieter Noordhuis 2024-10-28 16:19:02 +01:00
parent 5a555de503
commit 4741593579
No known key found for this signature in database
GPG Key ID: 12ACCCC104CF2930
4 changed files with 213 additions and 0 deletions

View File

@ -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
}

View File

@ -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"))
}

View File

@ -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
}

View File

@ -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))
}