Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 64 additions & 22 deletions test/utils/dump.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,19 @@ import (
"encoding/json"
"fmt"
"io"

"github.com/amp-labs/connectors/common"
"reflect"
)

// DumpJSON dumps the given value as JSON to the given writer.
func DumpJSON(v any, w io.Writer) {
if result, ok := v.(*common.ListObjectMetadataResult); ok {
// Nested errors must be explicitly converted to a string to be displayed.
errorsMap := map[string]string{}

for k, err := range result.Errors {
if err != nil {
errorsMap[k] = err.Error() // convert error to string
} else {
errorsMap[k] = ""
}
}

v = map[string]any{
"Result": result.Result,
"Errors": errorsMap,
}
}
// Convert any error interfaces recursively before encoding.
convertedValue := substituteErrorsToStrings(v)

encoder := json.NewEncoder(w)

// JSON may have URLs with special symbols which shouldn't be escaped. Ex: `&`.
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")

if err := encoder.Encode(v); err != nil {
if err := encoder.Encode(convertedValue); err != nil {
Fail("error marshaling to JSON: %w", "error", err)
}
}
Expand All @@ -48,3 +30,63 @@ func DumpErrorsMap(registry map[string]error, w io.Writer) {
_, _ = w.Write([]byte(fmt.Sprintf("[%v] => %v\n", key, value)))
}
}

// substituteErrorsToStrings recursively converts any Go value into a JSON-safe
// representation (maps, slices, primitives), replacing all errors with strings.
//
// Structs become map[string]any, slices/arrays become []any, maps are preserved
// with converted keys, and all nested values are processed recursively.
func substituteErrorsToStrings(v any) any {
if v == nil {
return nil
}

// Convert errors to their string form.
if err, ok := v.(error); ok {
return err.Error()
}

rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Pointer, reflect.Interface:
if rv.IsNil() {
return nil
}

return substituteErrorsToStrings(rv.Elem().Interface())
case reflect.Struct:
out := make(map[string]any)
rt := rv.Type()

for i := 0; i < rv.NumField(); i++ {
field := rt.Field(i)
if field.PkgPath != "" { // unexported
continue
}
out[field.Name] = substituteErrorsToStrings(rv.Field(i).Interface())
}

return out
case reflect.Map:
out := make(map[string]any)

for _, key := range rv.MapKeys() {
// Only string keys are valid JSON object keys; fallback to fmt.Sprint
k := fmt.Sprint(key.Interface())
out[k] = substituteErrorsToStrings(rv.MapIndex(key).Interface())
}

return out
case reflect.Slice, reflect.Array:
n := rv.Len()
out := make([]any, n)

for i := 0; i < n; i++ {
out[i] = substituteErrorsToStrings(rv.Index(i).Interface())
}

return out
default:
return v
}
}
102 changes: 102 additions & 0 deletions test/utils/dump_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package utils

import (
"errors"
"reflect"
"testing"
)

func TestSubstituteErrorsToStrings(t *testing.T) {
errA := errors.New("A")
errB := errors.New("B")

type fruits struct {
Name string
Warnings []error
}

type box struct {
Content fruits
Weight int
}

tests := []struct {
name string
in any
want any
}{
{
name: "Plain error",
in: errA,
want: "A",
},
{
name: "Struct with error field",
in: struct {
Message string
Error error
}{"x", errA},
want: map[string]any{
"Message": "x",
"Error": "A",
},
},
{
name: "Slice of errors",
in: []error{errA, errB},
want: []any{"A", "B"},
},
{
name: "Map of errors",
in: map[string]error{
"a": errA,
"b": errB,
},
want: map[string]any{
"a": "A",
"b": "B",
},
},
{
name: "Pointer to error",
in: &errA,
want: "A",
},
{
name: "Interface containing error",
in: any(errB),
want: "B",
},
{
name: "Nil input",
in: nil,
want: nil,
},
{
name: "Deeply nested struct with slice of errors",
in: box{
Content: fruits{
Name: "Apples",
Warnings: []error{errors.New("too ripe"), errors.New("bruised")},
},
Weight: 5,
},
want: map[string]any{
"Content": map[string]any{
"Name": "Apples",
"Warnings": []any{"too ripe", "bruised"},
},
"Weight": 5,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := substituteErrorsToStrings(tt.in)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("substituteErrorsToStrings(%#v) = %#v, want %#v", tt.in, got, tt.want)
}
})
}
}