Repository: vmware/govmomi
Pull request #3529 (2024-08-23 17:56:07)

api: ToString for vim types
## Description

This patch adds a ToString helper function for invoking on any vim type. If the specified value is a primitive type, the returned string is generated using Sprintf. Otherwise the returned string is created first by attempting to marshal the data using the vimtype JSON encoder, and if that fails, the stdlib JSON encoder. If that fails, then `fmt.Sprintf("%v")` is used.

Closes: `NA`

## Type of change

Please mark options that are relevant:

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
- [ ] Build related change

## How Has This Been Tested?

```shell
go test -v -count 1 -run '^TestToString$' ./vim25/types
```

The following screenshot also demonstrates there is 100% code coverage for this new function:

<img width="552" alt="image" src="https://github.com/user-attachments/assets/cd4dbf6c-9985-4147-8bac-821e08e8aa4d">


## Checklist:

- [x] My code follows the `CONTRIBUTION` [guidelines] of this project
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged

[guidelines]: https://github.com/vmware/govmomi/blob/main/CONTRIBUTING.md



Patch:
diff --git a/vim25/types/helpers.go b/vim25/types/helpers.go
index 94fb50df..531b90d8 100644
--- a/vim25/types/helpers.go
+++ b/vim25/types/helpers.go
@@ -17,6 +17,9 @@ limitations under the License.
 package types
 
 import (
+	"bytes"
+	"encoding/json"
+	"fmt"
 	"net/url"
 	"reflect"
 	"strings"
@@ -316,6 +319,78 @@ func (ci VirtualMachineConfigInfo) ToConfigSpec() VirtualMachineConfigSpec {
 	return cs
 }
 
+// ToString returns the string-ified version of the provided input value by
+// first attempting to encode the value to JSON using the vimtype JSON encoder,
+// and if that should fail, using the standard JSON encoder, and if that fails,
+// returning the value formatted with Sprintf("%v").
+//
+// Please note, this function is not intended to replace marshaling the data
+// to JSON using the normal workflows. This function is for when a string-ified
+// version of the data is needed for things like logging.
+func ToString(in AnyType) (s string) {
+	if in == nil {
+		return "null"
+	}
+
+	marshalWithSprintf := func() string {
+		return fmt.Sprintf("%v", in)
+	}
+
+	defer func() {
+		if err := recover(); err != nil {
+			s = marshalWithSprintf()
+		}
+	}()
+
+	rv := reflect.ValueOf(in)
+	switch rv.Kind() {
+
+	case reflect.Bool,
+		reflect.Complex64, reflect.Complex128,
+		reflect.Float32, reflect.Float64:
+
+		return fmt.Sprintf("%v", in)
+
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
+		reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
+		reflect.Uintptr:
+
+		return fmt.Sprintf("%d", in)
+
+	case reflect.String:
+		return in.(string)
+
+	case reflect.Interface, reflect.Pointer:
+		if rv.IsZero() {
+			return "null"
+		}
+		return ToString(rv.Elem().Interface())
+	}
+
+	marshalWithStdlibJSONEncoder := func() string {
+		data, err := json.Marshal(in)
+		if err != nil {
+			return marshalWithSprintf()
+		}
+		return string(data)
+	}
+
+	defer func() {
+		if err := recover(); err != nil {
+			s = marshalWithStdlibJSONEncoder()
+		}
+	}()
+
+	var w bytes.Buffer
+	enc := NewJSONEncoder(&w)
+	if err := enc.Encode(in); err != nil {
+		return marshalWithStdlibJSONEncoder()
+	}
+
+	// Do not include the newline character added by the vimtype JSON encoder.
+	return strings.TrimSuffix(w.String(), "\n")
+}
+
 func init() {
 	// Known 6.5 issue where this event type is sent even though it is internal.
 	// This workaround allows us to unmarshal and avoid NPEs.


Test patch:
diff --git a/vim25/types/helpers_test.go b/vim25/types/helpers_test.go
index f680945e..da01c7bf 100644
--- a/vim25/types/helpers_test.go
+++ b/vim25/types/helpers_test.go
@@ -17,8 +17,13 @@ limitations under the License.
 package types
 
 import (
+	"fmt"
+	"reflect"
+	"slices"
 	"testing"
 
+	"github.com/stretchr/testify/assert"
+
 	"github.com/vmware/govmomi/vim25/xml"
 )
 
@@ -306,3 +311,165 @@ func TestVirtualMachineConfigInfoToConfigSpec(t *testing.T) {
 		})
 	}
 }
+
+type toStringTestCase struct {
+	name     string
+	in       any
+	expected string
+}
+
+func newToStringTestCases[T any](in T, expected string) []toStringTestCase {
+	return newToStringTestCasesWithTestCaseName(
+		in, expected, reflect.TypeOf(in).Name())
+}
+
+func newToStringTestCasesWithTestCaseName[T any](
+	in T, expected, testCaseName string) []toStringTestCase {
+
+	return []toStringTestCase{
+		{
+			name:     testCaseName,
+			in:       in,
+			expected: expected,
+		},
+		{
+			name:     "*" + testCaseName,
+			in:       &[]T{in}[0],
+			expected: expected,
+		},
+		{
+			name:     "(any)(" + testCaseName + ")",
+			in:       (any)(in),
+			expected: expected,
+		},
+		{
+			name:     "(any)(*" + testCaseName + ")",
+			in:       (any)(&[]T{in}[0]),
+			expected: expected,
+		},
+		{
+			name:     "(any)((*" + testCaseName + ")(nil))",
+			in:       (any)((*T)(nil)),
+			expected: "null",
+		},
+	}
+}
+
+type toStringTypeWithErr struct {
+	errOnCall []int
+	callCount *int
+	doPanic   bool
+}
+
+func (t toStringTypeWithErr) String() string {
+	return "{}"
+}
+
+func (t toStringTypeWithErr) MarshalJSON() ([]byte, error) {
+	defer func() {
+		*t.callCount++
+	}()
+	if !slices.Contains(t.errOnCall, *t.callCount) {
+		return []byte{'{', '}'}, nil
+	}
+	if t.doPanic {
+		panic(fmt.Errorf("marshal json panic'd"))
+	}
+	return nil, fmt.Errorf("marshal json failed")
+}
+
+func TestToString(t *testing.T) {
+	const (
+		helloWorld = "Hello, world."
+	)
+
+	testCases := []toStringTestCase{
+		{
+			name:     "nil",
+			in:       nil,
+			expected: "null",
+		},
+	}
+
+	testCases = append(testCases, newToStringTestCases(
+		"Hello, world.", "Hello, world.")...)
+
+	testCases = append(testCases, newToStringTestCasesWithTestCaseName(
+		byte(1), "1", "byte")...)
+	testCases = append(testCases, newToStringTestCasesWithTestCaseName(
+		'a', "97", "rune")...)
+
+	testCases = append(testCases, newToStringTestCases(
+		true, "true")...)
+
+	testCases = append(testCases, newToStringTestCases(
+		complex(float32(1), float32(4)), "(1+4i)")...)
+	testCases = append(testCases, newToStringTestCases(
+		complex(float64(1), float64(4)), "(1+4i)")...)
+
+	testCases = append(testCases, newToStringTestCases(
+		float32(1.1), "1.1")...)
+	testCases = append(testCases, newToStringTestCases(
+		float64(1.1), "1.1")...)
+
+	testCases = append(testCases, newToStringTestCases(
+		int(1), "1")...)
+	testCases = append(testCases, newToStringTestCases(
+		int8(1), "1")...)
+	testCases = append(testCases, newToStringTestCases(
+		int16(1), "1")...)
+	testCases = append(testCases, newToStringTestCases(
+		int32(1), "1")...)
+	testCases = append(testCases, newToStringTestCases(
+		int64(1), "1")...)
+
+	testCases = append(testCases, newToStringTestCases(
+		uint(1), "1")...)
+	testCases = append(testCases, newToStringTestCases(
+		uint8(1), "1")...)
+	testCases = append(testCases, newToStringTestCases(
+		uint16(1), "1")...)
+	testCases = append(testCases, newToStringTestCases(
+		uint32(1), "1")...)
+	testCases = append(testCases, newToStringTestCases(
+		uint64(1), "1")...)
+
+	testCases = append(testCases, newToStringTestCases(
+		VirtualMachineConfigSpec{},
+		`{"_typeName":"VirtualMachineConfigSpec"}`)...)
+	testCases = append(testCases, newToStringTestCasesWithTestCaseName(
+		VirtualMachineConfigSpec{
+			VAppConfig: (*VmConfigSpec)(nil),
+		},
+		`{"_typeName":"VirtualMachineConfigSpec","vAppConfig":null}`,
+		"VirtualMachineConfigSpec w nil iface")...)
+
+	testCases = append(testCases, toStringTestCase{
+		name:     "MarshalJSON returns error on special encode",
+		in:       toStringTypeWithErr{callCount: new(int), errOnCall: []int{0}},
+		expected: "{}",
+	})
+	testCases = append(testCases, toStringTestCase{
+		name:     "MarshalJSON returns error on special and stdlib encode",
+		in:       toStringTypeWithErr{callCount: new(int), errOnCall: []int{0, 1}},
+		expected: "{}",
+	})
+	testCases = append(testCases, toStringTestCase{
+		name:     "MarshalJSON panics on special encode",
+		in:       toStringTypeWithErr{callCount: new(int), doPanic: true, errOnCall: []int{0}},
+		expected: "{}",
+	})
+	testCases = append(testCases, toStringTestCase{
+		name:     "MarshalJSON panics on special and stdlib encode",
+		in:       toStringTypeWithErr{callCount: new(int), doPanic: true, errOnCall: []int{0, 1}},
+		expected: "{}",
+	})
+
+	for i := range testCases {
+		tc := testCases[i]
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			assert.Equal(t, tc.expected, ToString(tc.in))
+		})
+	}
+}