diff --git a/resource/composed/composed.go b/resource/composed/composed.go index 26e54a0..57495d8 100644 --- a/resource/composed/composed.go +++ b/resource/composed/composed.go @@ -42,6 +42,38 @@ func New() *Unstructured { return &Unstructured{unstructured.Unstructured{Object: make(map[string]any)}} } +// To converts a unstructured composed resource to the provided object. +func To(un *Unstructured, obj interface{}) error { + rt, ok := obj.(runtime.Object) + if !ok { + return errors.New("object is not a compatible runtime.Object") + } + + // Get known GVKs for the runtime object type + knownGVKs, _, err := Scheme.ObjectKinds(rt) + if err != nil { + return errors.Errorf("could not retrieve GVKs for the provided object: %v", err) + } + + // Check if GVK is known as we should not try to convert it if it doesn't match + gvkMatches := false + for _, knownGVK := range knownGVKs { + if knownGVK == un.GetObjectKind().GroupVersionKind() { + gvkMatches = true + } + } + + if !gvkMatches { + return errors.Errorf("GVK %v is not known by the scheme for the provided object type", un.GetObjectKind().GroupVersionKind()) + } + + err = runtime.DefaultUnstructuredConverter.FromUnstructured(un.Object, obj) + if err != nil { + return err + } + return nil +} + // From creates a new unstructured composed resource from the supplied object. func From(o runtime.Object) (*Unstructured, error) { // If the supplied object is already unstructured content, avoid a JSON diff --git a/resource/composed/composed_test.go b/resource/composed/composed_test.go index fd952d6..13c5ceb 100644 --- a/resource/composed/composed_test.go +++ b/resource/composed/composed_test.go @@ -17,6 +17,7 @@ limitations under the License. package composed import ( + "errors" "fmt" "testing" @@ -201,3 +202,167 @@ func TestFrom(t *testing.T) { }) } } + +func ExampleTo() { + // Add all v1beta1 types to the scheme so that From can automatically + // determine their apiVersion and kind. + v1beta1.AddToScheme(Scheme) + + // Create a unstructured object as we would receive by the function (observed/desired). + ub := &Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ + "apiVersion": v1beta1.CRDGroupVersion.String(), + "kind": v1beta1.Bucket_Kind, + "metadata": map[string]any{ + "name": "cool-bucket", + }, + "spec": map[string]any{ + "forProvider": map[string]any{ + "region": "us-east-2", + }, + }, + "status": map[string]any{ + "observedGeneration": float64(0), + }, + }}} + + // Create a strongly typed object from the unstructured object. + sb := &v1beta1.Bucket{} + err := To(ub, sb) + if err != nil { + panic(err) + } + // Now you have a strongly typed Bucket object. + objectLock := true + sb.Spec.ForProvider.ObjectLockEnabled = &objectLock +} + +// Test the To function +func TestTo(t *testing.T) { + v1beta1.AddToScheme(Scheme) + type args struct { + un *Unstructured + obj interface{} + } + type want struct { + obj interface{} + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "SuccessfulConversion": { + reason: "A valid unstructured object should convert to a structured object without errors", + args: args{ + un: &Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ + "apiVersion": v1beta1.CRDGroupVersion.String(), + "kind": v1beta1.Bucket_Kind, + "metadata": map[string]any{ + "name": "cool-bucket", + }, + "spec": map[string]any{ + "forProvider": map[string]any{ + "region": "us-east-2", + }, + }, + "status": map[string]any{ + "observedGeneration": float64(0), + }, + }}}, + obj: &v1beta1.Bucket{}, + }, + want: want{ + obj: &v1beta1.Bucket{ + TypeMeta: metav1.TypeMeta{ + Kind: v1beta1.Bucket_Kind, + APIVersion: v1beta1.CRDGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "cool-bucket", + }, + Spec: v1beta1.BucketSpec{ + ForProvider: v1beta1.BucketParameters{ + Region: ptr.To[string]("us-east-2"), + }, + }, + }, + err: nil, + }, + }, + "InvalidGVK": { + reason: "An unstructured object with mismatched GVK should result in an error", + args: args{ + un: &Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "test.example.io", + "kind": "Unknown", + "metadata": map[string]any{ + "name": "cool-bucket", + }, + "spec": map[string]any{ + "forProvider": map[string]any{ + "region": "us-east-2", + }, + }, + "status": map[string]any{ + "observedGeneration": float64(0), + }, + }}}, + obj: &v1beta1.Bucket{}, + }, + want: want{ + obj: &v1beta1.Bucket{}, + err: errors.New("GVK /test.example.io, Kind=Unknown is not known by the scheme for the provided object type"), + }, + }, + "NoRuntimeObject": { + reason: "Should only convert to a object if the object is a runtime.Object", + args: args{ + un: &Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ + "apiVersion": v1beta1.CRDGroupVersion.String(), + "kind": v1beta1.Bucket_Kind, + "metadata": map[string]any{ + "name": "cool-bucket", + }, + }}}, + obj: "not-a-runtime-object", + }, + want: want{ + obj: string("not-a-runtime-object"), + err: errors.New("object is not a compatible runtime.Object"), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := To(tc.args.un, tc.args.obj) + + // Compare the resulting object with the expected one + if diff := cmp.Diff(tc.want.obj, tc.args.obj); diff != "" { + t.Errorf("\n%s\nTo(...): -want, +got:\n%s", tc.reason, diff) + } + // Compare the error with the expected error + if diff := cmp.Diff(tc.want.err, err, EquateErrors()); diff != "" { + t.Errorf("\n%s\nTo(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} + +// EquateErrors returns true if the supplied errors are of the same type and +// produce identical strings. This mirrors the error comparison behaviour of +// https://github.com/go-test/deep, +// +// This differs from cmpopts.EquateErrors, which does not test for error strings +// and instead returns whether one error 'is' (in the errors.Is sense) the +// other. +func EquateErrors() cmp.Option { + return cmp.Comparer(func(a, b error) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return a.Error() == b.Error() + }) +}