diff --git a/pkg/docs/generate.go b/pkg/docs/generate.go new file mode 100644 index 0000000..feae9a8 --- /dev/null +++ b/pkg/docs/generate.go @@ -0,0 +1,75 @@ +package docs + +import ( + "fmt" + "reflect" + "strings" +) + +func GeneratePropertiesMap(data interface{}) map[string]string { + properties := map[string]string{} + + v := reflect.ValueOf(data) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + t := v.Type() + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + if !field.IsExported() { + continue + } + + propertyTag := field.Tag.Get("property") + options := strings.Split(propertyTag, ",") + name := field.Name + prefix := "" + + if options[0] == "-" { + continue + } + + for _, option := range options { + parts := strings.Split(option, "=") + if len(parts) != 2 { + continue + } + switch parts[0] { + case "name": + name = parts[1] + case "prefix": + prefix = parts[1] + } + } + + if prefix != "" && name != "Tags" { + name = fmt.Sprintf("%s:%s", prefix, name) + } + + descriptionTag := field.Tag.Get("description") + + if name == "Tags" { + originalName := name + name = "tag::" + tagPrefix := "tag:" + if prefix != "" { + tagPrefix = fmt.Sprintf("tag:%s:", prefix) + } + + descriptionTag = fmt.Sprintf( + "This resource has tags with property `%s`. These are key/value pairs that are\n\t"+ + "added as their own property with the prefix of `%s` (e.g. [%sexample: \"value\"]) ", + originalName, tagPrefix, tagPrefix) + + if prefix != "" { + name = fmt.Sprintf("tag:%s::", prefix) + } + } + + properties[name] = descriptionTag + } + + return properties +} diff --git a/pkg/docs/generate_test.go b/pkg/docs/generate_test.go new file mode 100644 index 0000000..b5c4aa9 --- /dev/null +++ b/pkg/docs/generate_test.go @@ -0,0 +1,81 @@ +package docs + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateProperties(t *testing.T) { + type TestResource1 struct { + Name string `description:"The name of the resource"` + Region *string `description:"The region in which the resource resides"` + VpcID string `description:"The VPC ID of the resource" property:"prefix=vpc"` + Tags map[string]string `description:"The tags associated with the resource"` + } + + type TestResource2 struct { + Name string `description:"The name of the resource"` + Region *string `description:"The region in which the resource resides"` + Tags map[string]string `description:"The tags associated with the resource" property:"prefix=ee"` + } + + type TestResource3 struct { + Name string `description:"The name of the resource"` + Ignore string `property:"-"` + Example string `description:"A property rename" property:"name=Delta"` + skipped string //nolint:unused + } + + cases := []struct { + name string + in interface{} + want map[string]string + }{ + { + name: "TestResource1", + in: TestResource1{}, + want: map[string]string{ + "Name": "The name of the resource", + "Region": "The region in which the resource resides", + "vpc:VpcID": "The VPC ID of the resource", + "tag::": "This resource has tags with property `Tags`. These are key/value pairs that are\n\t" + + "added as their own property with the prefix of `tag:` (e.g. [tag:example: \"value\"]) ", + }, + }, + { + name: "TestResource2", + in: TestResource2{}, + want: map[string]string{ + "Name": "The name of the resource", + "Region": "The region in which the resource resides", + "tag:ee::": "This resource has tags with property `Tags`. These are key/value pairs that are\n\t" + + "added as their own property with the prefix of `tag:ee:" + + "` (e.g. [tag:ee:example: \"value\"]) ", + }, + }, + { + name: "TestResource3", + in: TestResource3{}, + want: map[string]string{ + "Name": "The name of the resource", + "Delta": "A property rename", + }, + }, + { + name: "PointerTestResource3", + in: &TestResource3{}, + want: map[string]string{ + "Name": "The name of the resource", + "Delta": "A property rename", + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + have := GeneratePropertiesMap(c.in) + assert.Equal(t, c.want, have) + }) + } +} diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 54ece1e..0236812 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -48,6 +48,12 @@ type Registration struct { // different levels, whereas AWS has simply Account level. Scope Scope + // Resource is the resource type that the lister is going to list. This is a struct that implements the Resource + // interface. This is primarily used to generate documentation by parsing the structs properties and generating + // markdown documentation. + // Note: it is a interface{} because we are going to inspect it, we do not need to actually call any methods on it. + Resource interface{} + // Lister is the lister for the resource type, it is a struct with a method called List that returns a slice // of resources. The lister is responsible for filtering out any resources that should not be deleted because they // are ineligible for deletion. For example, built in resources that cannot be deleted. @@ -120,6 +126,11 @@ func Register(r *Registration) { } } +// GetRegistrations returns all registrations +func GetRegistrations() Registrations { + return registrations +} + // ClearRegistry clears the registry of all registrations // Designed for use for unit tests, not for production code. Only use if you know what you are doing. func ClearRegistry() { diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go index bac9fb1..8f3e7af 100644 --- a/pkg/registry/registry_test.go +++ b/pkg/registry/registry_test.go @@ -140,3 +140,16 @@ func Test_RegisterResourcesWithAlternative(t *testing.T) { assert.Len(t, deprecatedMapping, 1) assert.Equal(t, "test2", deprecatedMapping["test"]) } + +func Test_GetRegistrations(t *testing.T) { + ClearRegistry() + + Register(&Registration{ + Name: "test", + Scope: "test", + Lister: TestLister{}, + }) + + regs := GetRegistrations() + assert.Len(t, regs, 1) +} diff --git a/pkg/types/properties.go b/pkg/types/properties.go index 2357a7f..c1366a9 100644 --- a/pkg/types/properties.go +++ b/pkg/types/properties.go @@ -2,15 +2,24 @@ package types import ( "fmt" + "reflect" "strings" ) +// Properties is a map of key-value pairs. type Properties map[string]string +// NewProperties creates a new Properties map. func NewProperties() Properties { return make(Properties) } +// NewPropertiesFromStruct creates a new Properties map from a struct. +func NewPropertiesFromStruct(data interface{}) Properties { + return NewProperties().SetFromStruct(data) +} + +// String returns a string representation of the Properties map. func (p Properties) String() string { var parts []string for k, v := range p { @@ -20,6 +29,17 @@ func (p Properties) String() string { return fmt.Sprintf("[%s]", strings.Join(parts, ", ")) } +// Get returns the value of a key in the Properties map. +func (p Properties) Get(key string) string { + value, ok := p[key] + if !ok { + return "" + } + + return value +} + +// Set sets a key-value pair in the Properties map. func (p Properties) Set(key string, value interface{}) Properties { if value == nil { return p @@ -57,10 +77,28 @@ func (p Properties) Set(key string, value interface{}) Properties { return p } +// SetWithPrefix sets a key-value pair in the Properties map with a prefix. +func (p Properties) SetWithPrefix(prefix, key string, value interface{}) Properties { + key = strings.TrimSpace(key) + prefix = strings.TrimSpace(prefix) + + if key == "" { + return p + } + + if prefix != "" { + key = fmt.Sprintf("%s:%s", prefix, key) + } + + return p.Set(key, value) +} + +// SetTag sets a tag key-value pair in the Properties map. func (p Properties) SetTag(tagKey *string, tagValue interface{}) Properties { return p.SetTagWithPrefix("", tagKey, tagValue) } +// SetTagWithPrefix sets a tag key-value pair in the Properties map with a prefix. func (p Properties) SetTagWithPrefix(prefix string, tagKey *string, tagValue interface{}) Properties { if tagKey == nil { return p @@ -82,30 +120,7 @@ func (p Properties) SetTagWithPrefix(prefix string, tagKey *string, tagValue int return p.Set(keyStr, tagValue) } -func (p Properties) SetWithPrefix(prefix, key string, value interface{}) Properties { - key = strings.TrimSpace(key) - prefix = strings.TrimSpace(prefix) - - if key == "" { - return p - } - - if prefix != "" { - key = fmt.Sprintf("%s:%s", prefix, key) - } - - return p.Set(key, value) -} - -func (p Properties) Get(key string) string { - value, ok := p[key] - if !ok { - return "" - } - - return value -} - +// Equals compares two Properties maps. func (p Properties) Equals(o Properties) bool { if p == nil && o == nil { return true @@ -132,3 +147,102 @@ func (p Properties) Equals(o Properties) bool { return true } + +// SetFromStruct sets the Properties map from a struct by reading the structs fields +func (p Properties) SetFromStruct(data interface{}) Properties { //nolint:funlen,gocyclo + v := reflect.ValueOf(data) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + t := v.Type() + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + value := v.Field(i) + + if !field.IsExported() { + continue + } + + isSet := false + + switch value.Kind() { + case reflect.Ptr, reflect.Slice, reflect.Map, reflect.Interface, reflect.Chan: + isSet = !value.IsNil() + default: + isSet = value.Interface() != reflect.Zero(value.Type()).Interface() + } + + if !isSet { + continue + } + + propertyTag := field.Tag.Get("property") + options := strings.Split(propertyTag, ",") + name := field.Name + prefix := "" + + if options[0] == "-" { + continue + } + + for _, option := range options { + parts := strings.Split(option, "=") + if len(parts) != 2 { + continue + } + switch parts[0] { + case "name": + name = parts[1] + case "prefix": + prefix = parts[1] + } + } + + if value.Kind() == reflect.Ptr { + value = value.Elem() + } + + switch value.Kind() { + case reflect.Map: + for _, key := range value.MapKeys() { + val := value.MapIndex(key) + if key.Kind() == reflect.Ptr { + key = key.Elem() + } + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + name = key.String() + p.SetTagWithPrefix(prefix, &name, val.Interface()) + } + case reflect.Slice: + for j := 0; j < value.Len(); j++ { + sliceValue := value.Index(j) + if sliceValue.Kind() == reflect.Ptr { + sliceValue = sliceValue.Elem() + } + if sliceValue.Kind() == reflect.Struct { + sliceValueV := reflect.ValueOf(sliceValue.Interface()) + keyField := sliceValueV.FieldByName("Key") + valueField := sliceValueV.FieldByName("Value") + + if keyField.Kind() == reflect.Ptr { + keyField = keyField.Elem() + } + if valueField.Kind() == reflect.Ptr { + valueField = valueField.Elem() + } + + if keyField.IsValid() && valueField.IsValid() { + p.SetTagWithPrefix(prefix, &[]string{keyField.Interface().(string)}[0], valueField.Interface()) + } + } + } + default: + p.SetWithPrefix(prefix, name, value.Interface()) + } + } + + return p +} diff --git a/pkg/types/properties_test.go b/pkg/types/properties_test.go index 0dbbf18..22f6176 100644 --- a/pkg/types/properties_test.go +++ b/pkg/types/properties_test.go @@ -340,6 +340,202 @@ func TestPropertiesSetPropertiesWithPrefix(t *testing.T) { } } +func TestPropertiesSetFromStruct(t *testing.T) { + type testStruct struct { + Name string + Age int + Tags map[string]string + } + + type testStruct2 struct { + Name string `property:"name=name"` + Region *string `property:"name=region"` + Tags *map[string]string `property:"prefix=awesome"` + } + + type keyValue struct { + Key *string + Value *string + } + + type testStruct3 struct { + Name string `property:""` + Age *int `property:""` + IQ *int64 `property:""` + On bool + Off *bool `property:"-"` + Tags []*keyValue `property:""` + } + + type testStruct4 struct { + Omit bool `property:"-"` + Name byte + } + + type testStruct5 struct { + Name string + Tags map[string]*string + } + + type testStruct6 struct { + Name string + Tags map[*string]*string + unexported string + } + + cases := []struct { + name string + s interface{} + want types.Properties + error bool + }{ + { + name: "empty", + s: testStruct{}, + want: types.NewProperties(), + }, + { + name: "simple-byte", + s: testStruct4{ + Name: 'a', + }, + want: types.NewProperties().Set("Name", "97"), + }, + { + name: "from-struct", + s: testStruct3{Name: "testing"}, + want: types.NewPropertiesFromStruct(testStruct3{Name: "testing"}), + }, + { + name: "simple", + s: testStruct{Name: "Alice", Age: 42}, + want: types.NewProperties().Set("Age", 42).Set("Name", "Alice"), + }, + { + name: "simple-pointer", + s: &testStruct{Name: "Alice", Age: 42}, + want: types.NewProperties().Set("Age", 42).Set("Name", "Alice"), + }, + { + name: "complex", + s: testStruct3{ + Name: "Alice", + Age: &[]int{42}[0], + IQ: &[]int64{100}[0], + Off: &[]bool{true}[0], + Tags: []*keyValue{ + {Key: ptr.String("key1"), Value: ptr.String("value1")}, + }, + }, + want: types.NewProperties(). + Set("Name", "Alice"). + Set("Age", 42). + Set("IQ", 100). + SetTag(ptr.String("key1"), "value1"), + }, + { + name: "tags-map", + s: testStruct2{ + Name: "Alice", + Region: ptr.String("us-west-2"), + Tags: &map[string]string{"key": "value"}, + }, + want: types.NewProperties(). + Set("name", "Alice"). + Set("region", "us-west-2"). + SetTagWithPrefix("awesome", &[]string{"key"}[0], "value"), + }, + { + name: "tags-struct", + s: testStruct3{ + Name: "Alice", + Age: &[]int{42}[0], + IQ: &[]int64{100}[0], + On: true, + Tags: []*keyValue{ + {Key: ptr.String("key1"), Value: ptr.String("value1")}, + }, + }, + want: types.NewProperties(). + Set("Name", "Alice"). + Set("Age", 42). + Set("IQ", 100). + Set("On", true). + SetTag(ptr.String("key1"), "value1"), + }, + { + name: "tags-string-pointer", + s: testStruct5{ + Name: "Alice", + Tags: map[string]*string{"key": ptr.String("value")}, + }, + want: types.NewProperties().Set("Name", "Alice").SetTag(ptr.String("key"), "value"), + }, + { + name: "tags-pointer-pointer", + s: testStruct6{ + Name: "Alice", + Tags: map[*string]*string{ptr.String("key"): ptr.String("value")}, + unexported: "hidden", + }, + want: types.NewProperties().Set("Name", "Alice").SetTag(ptr.String("key"), "value"), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := types.NewProperties() + + p.SetFromStruct(tc.s) + + assert.Equal(t, tc.want, p) + }) + } +} + +func BenchmarkNewProperties(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = types.NewProperties(). + Set("Name", "Alice"). + Set("Age", 42). + SetTag(ptr.String("key1"), "value1") + } +} + +func BenchmarkNewPropertiesFromStruct_Simple(b *testing.B) { + type testStruct struct { + Name string + Age int + } + + for i := 0; i < b.N; i++ { + _ = types.NewPropertiesFromStruct(testStruct{Name: "Alice", Age: 42}) + } +} + +func BenchmarkNewPropertiesFromStruct_Complex(b *testing.B) { + type keyValue struct { + Key *string + Value *string + } + + type testStruct struct { + Name string + Age *int + Tags []*keyValue + } + + for i := 0; i < b.N; i++ { + _ = types.NewPropertiesFromStruct(testStruct{ + Name: "Alice", + Age: &[]int{42}[0], + Tags: []*keyValue{ + {Key: ptr.String("key1"), Value: ptr.String("value1")}, + }, + }) + } +} + func getString(value interface{}) string { switch v := value.(type) { case *string: