diff --git a/docs/functions/collection_filter.md b/docs/functions/collection_filter.md new file mode 100644 index 0000000..79dbf09 --- /dev/null +++ b/docs/functions/collection_filter.md @@ -0,0 +1,124 @@ +--- +page_title: "collection_filter function - helpers" +subcategory: "Collection Functions" +description: |- + Filter collection of objects. +--- + +# Function: collection_filter + +Filter collection of objects. + +The function `collection_filter` can be used, as indicated by the name, to quickly filter a collection of values. +Terraform does not offer out-of-the-box such functionality in a direct way, with the only reasonable solution of loop +through the collection and perform the filtering. + +In the current version the function is able to filter collection of primitives (number, bool, string) and objects, with +the last one able to also filter by a nested attribute. The filter right now is only using an "equal" check operation, +however, we might put some effort in the future to support different operators. + + +## Example Usage + +```terraform +locals { + test_object_collection = [ + { key1 = "value1", key2 = true, key3 = 3, key4 = null }, + { key1 = "value2", key2 = false, key3 = 0, key4 = {} }, + { key1 = "value3", key2 = true, key3 = 5, key4 = null }, + { key1 = "value4", key2 = false, key3 = 1, key4 = { key5 = "value5", key6 = true } }, + ] + + test_string_array = ["value1", "value2", "value3", "value1"] + + test_number_array = [5, 8, 3, 5] + + test_bool_array = [true, false, true] +} + +# Expected return: +# [ +# { key1 = "value1", key2 = true, key3 = 3, key4 = null }, +# ] +output "test_match_object_string_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key1", "value1") +} + +# Expected return: +# [ +# { key1 = "value1", key2 = true, key3 = 3, key4 = null }, +# { key1 = "value3", key2 = true, key3 = 5, key4 = null }, +# ] +output "test_match_object_bool_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key2", true) +} + +# Expected return: +# [ +# { key1 = "value3", key2 = true, key3 = 5, key4 = null }, +# ] +output "test_match_object_number_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key3", 5) +} + +# Expected return: +# [ +# { key1 = "value1", key2 = true, key3 = 3, key4 = null }, +# { key1 = "value3", key2 = true, key3 = 5, key4 = null }, +# ] +output "test_match_object_null_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key4", null) +} + +# Expected return: +# [ +# { key1 = "value4", key2 = false, key3 = 1, key4 = { key5 = "value5", key6 = true } }, +# ] +output "test_match_object_string_nested_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key4.key5", "value5") +} + +# Expected return: +# [ ] +output "test_no_match_object_string_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key1", "new_value") +} + +# Expected return: +# ["value1", "value1"] +output "test_match_string_array" { + value = provider::helpers::collection_filter(local.test_string_array, "", "value1") +} + +# Expected return: +# [5, 5] +output "test_match_number_array" { + value = provider::helpers::collection_filter(local.test_number_array, "", 5) +} + +# Expected return: +# [false] +output "test_match_bool_array" { + value = provider::helpers::collection_filter(local.test_bool_array, "", false) +} +``` + +## Signature + + +```text +collection_filter(collection dynamic, key string, value dynamic) dynamic +``` + +## Arguments + + +1. `collection` (Dynamic) The collection of objects to filter +1. `key` (String) The key from the object to filter by +1. `value` (Dynamic, Nullable) The value used to compare against + + +## Return Type + +The signature shows a dynamic type of return because in order to support multiple types of collections the return must +be specified in such way. You can always expect a collection of the same type as used in the input. diff --git a/docs/functions/os_get_env.md b/docs/functions/os_get_env.md new file mode 100644 index 0000000..62c96ad --- /dev/null +++ b/docs/functions/os_get_env.md @@ -0,0 +1,43 @@ +--- +page_title: "os_get_env function - helpers" +subcategory: "OS Functions" +description: |- + Get an environment variable +--- + +# Function: os_get_env + +Get an environment variable + +The function `os_get_env` retrieves the value of an environment variable and provides a fallback value if the +environment variable is not set. + +## Example Usage + +```terraform +output "tf_log" { + value = { + test_value_as_is = provider::helpers::os_get_env("TF_LOG") + test_value_with_fallback = provider::helpers::os_get_env("TF_ENV", "test") + } +} +``` + +## Signature + + +```text +os_get_env(name string, fallback string...) string +``` + +## Arguments + + +1. `name` (String) The name of the environment variable to get + +1. `fallback` (Variadic, String) The fallback value to use if the environment variable is not set + +## Return Type + +The return type of `os_get_env` is a string representing the value of the environment variable or the fallback value. +You can use terraform type conversion functions to convert the string to other types if needed. diff --git a/examples/functions/collection_filter/function.tf b/examples/functions/collection_filter/function.tf new file mode 100644 index 0000000..0f6e471 --- /dev/null +++ b/examples/functions/collection_filter/function.tf @@ -0,0 +1,80 @@ +locals { + test_object_collection = [ + { key1 = "value1", key2 = true, key3 = 3, key4 = null }, + { key1 = "value2", key2 = false, key3 = 0, key4 = {} }, + { key1 = "value3", key2 = true, key3 = 5, key4 = null }, + { key1 = "value4", key2 = false, key3 = 1, key4 = { key5 = "value5", key6 = true } }, + ] + + test_string_array = ["value1", "value2", "value3", "value1"] + + test_number_array = [5, 8, 3, 5] + + test_bool_array = [true, false, true] +} + +# Expected return: +# [ +# { key1 = "value1", key2 = true, key3 = 3, key4 = null }, +# ] +output "test_match_object_string_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key1", "value1") +} + +# Expected return: +# [ +# { key1 = "value1", key2 = true, key3 = 3, key4 = null }, +# { key1 = "value3", key2 = true, key3 = 5, key4 = null }, +# ] +output "test_match_object_bool_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key2", true) +} + +# Expected return: +# [ +# { key1 = "value3", key2 = true, key3 = 5, key4 = null }, +# ] +output "test_match_object_number_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key3", 5) +} + +# Expected return: +# [ +# { key1 = "value1", key2 = true, key3 = 3, key4 = null }, +# { key1 = "value3", key2 = true, key3 = 5, key4 = null }, +# ] +output "test_match_object_null_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key4", null) +} + +# Expected return: +# [ +# { key1 = "value4", key2 = false, key3 = 1, key4 = { key5 = "value5", key6 = true } }, +# ] +output "test_match_object_string_nested_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key4.key5", "value5") +} + +# Expected return: +# [ ] +output "test_no_match_object_string_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key1", "new_value") +} + +# Expected return: +# ["value1", "value1"] +output "test_match_string_array" { + value = provider::helpers::collection_filter(local.test_string_array, "", "value1") +} + +# Expected return: +# [5, 5] +output "test_match_number_array" { + value = provider::helpers::collection_filter(local.test_number_array, "", 5) +} + +# Expected return: +# [false] +output "test_match_bool_array" { + value = provider::helpers::collection_filter(local.test_bool_array, "", false) +} diff --git a/examples/functions/os_get_env/function.tf b/examples/functions/os_get_env/function.tf new file mode 100644 index 0000000..af1817b --- /dev/null +++ b/examples/functions/os_get_env/function.tf @@ -0,0 +1,6 @@ +output "tf_log" { + value = { + test_value_as_is = provider::helpers::os_get_env("TF_LOG") + test_value_with_fallback = provider::helpers::os_get_env("TF_ENV", "test") + } +} diff --git a/go.mod b/go.mod index ce0b06a..b9cc532 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/hashicorp/terraform-plugin-go v0.25.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.11.0 + golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f ) require ( @@ -70,17 +71,16 @@ require ( github.com/zclconf/go-cty v1.15.0 // indirect go.abhg.dev/goldmark/frontmatter v0.2.0 // indirect golang.org/x/crypto v0.29.0 // indirect - golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect - golang.org/x/mod v0.21.0 // indirect - golang.org/x/net v0.30.0 // indirect + golang.org/x/mod v0.22.0 // indirect + golang.org/x/net v0.31.0 // indirect golang.org/x/sync v0.9.0 // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.20.0 // indirect - golang.org/x/tools v0.22.0 // indirect + golang.org/x/tools v0.27.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect - google.golang.org/grpc v1.67.1 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect + google.golang.org/grpc v1.68.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect gopkg.in/yaml.v2 v2.3.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9dd17d2..00fa150 100644 --- a/go.sum +++ b/go.sum @@ -214,9 +214,13 @@ golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= +golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -224,6 +228,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -260,6 +266,7 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= @@ -267,12 +274,18 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 h1:zciRKQ4kBpFgpfC5QQCVtnnNAcLIqweL7plyZRQHVpI= google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0= +google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/provider/collection_filter_function.go b/internal/provider/collection_filter_function.go new file mode 100644 index 0000000..e92b4c3 --- /dev/null +++ b/internal/provider/collection_filter_function.go @@ -0,0 +1,155 @@ +package provider + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "terraform-provider-helpers/internal/validators/dynamicvalidator" +) + +type CollectionFilterFunction struct{} + +var _ function.Function = &CollectionFilterFunction{} + +func NewCollectionFilterFunction() function.Function { + return &CollectionFilterFunction{} +} + +func (o CollectionFilterFunction) Metadata(_ context.Context, _ function.MetadataRequest, resp *function.MetadataResponse) { + resp.Name = "collection_filter" +} + +func (o CollectionFilterFunction) Definition(_ context.Context, _ function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + Summary: "Filter collection of objects.", + Description: "Filter a collection of objects using a simple comparison.", + + Parameters: []function.Parameter{ + function.DynamicParameter{ + Name: "collection", + Description: "The collection of objects to filter", + AllowNullValue: false, + AllowUnknownValues: false, + Validators: []function.DynamicParameterValidator{ + dynamicvalidator.ElementsOfSameTypeValidator{}, + }, + }, + function.StringParameter{ + Name: "key", + Description: "The key from the object to filter by", + AllowNullValue: false, + AllowUnknownValues: false, + }, + function.DynamicParameter{ + Name: "value", + Description: "The value used to compare against", + AllowNullValue: true, + AllowUnknownValues: false, + }, + }, + + Return: function.DynamicReturn{}, + } +} + +func (o CollectionFilterFunction) Run(ctx context.Context, request function.RunRequest, resp *function.RunResponse) { + var collection types.Dynamic + var value types.Dynamic + var key string + var filteredTypes []attr.Type + var filteredValues []attr.Value + + if err := request.Arguments.Get(ctx, &collection, &key, &value); err != nil { + resp.Error = err + return + } + + // cast validation of the collection parameter is done by the ElementsOfSameTypeValidator + collectionParsed, _ := collection.UnderlyingValue().(types.Tuple) + + var valueParsed tftypes.Value + var valueCastErr error + if value.IsNull() { + valueParsed = tftypes.NewValue(tftypes.DynamicPseudoType, nil) + } else if valueParsed, valueCastErr = value.UnderlyingValue().ToTerraformValue(ctx); valueCastErr != nil { + resp.Error = function.ConcatFuncErrors(resp.Error, function.NewFuncError(valueCastErr.Error())) + return + } + + elements := collectionParsed.Elements() + elementTypes := collectionParsed.ElementTypes(ctx) + //baseType := elementTypes[0].TerraformType(ctx) + + for i, elem := range elements { + found := false + targetValue := elem + + if elemAsObject, isObject := elem.(types.Object); isObject { + attrs := elemAsObject.Attributes() + flattenObject := FlatObjectMap(ctx, attrs) + if targetValue, found = flattenObject[key]; !found { + continue + } + } + + targetTFValue, toTFErr := targetValue.ToTerraformValue(ctx) + if toTFErr != nil { + resp.Error = function.ConcatFuncErrors(resp.Error, function.NewFuncError(toTFErr.Error())) + return + } + + if targetTFValue.Equal(valueParsed) { + filteredTypes = append(filteredTypes, elementTypes[i]) + filteredValues = append(filteredValues, elem) + } + } + + resultList := basetypes.NewTupleValueMust(filteredTypes, filteredValues) + if err := resp.Result.Set(ctx, basetypes.NewDynamicValue(resultList)); err != nil { + resp.Error = err + return + } +} + +// FlatObjectMap recursively flattens a map of attr.Value objects into a map of interface{} objects. +// It will only flatten values that are of type types.Object. +func FlatObjectMap(ctx context.Context, elements map[string]attr.Value) map[string]attr.Value { + flatMap := make(map[string]attr.Value) + for key, value := range elements { + if obj, ok := value.(types.Object); ok { + nestedMap := FlatObjectMap(ctx, obj.Attributes()) + for nestedKey, nestedValue := range nestedMap { + flatMap[key+"."+nestedKey] = nestedValue + } + } else { + flatMap[key] = value + } + } + return flatMap +} + +func MapLookup(ctx context.Context, m map[string]attr.Value, ks ...string) (r attr.Value, err error) { + var ok bool + var match interface{} + var nestedMap map[string]attr.Value + + if len(ks) == 0 { // degenerate input + return nil, fmt.Errorf("NestedMapLookup needs at least one key") + } + if r, ok = m[ks[0]]; !ok { + return nil, fmt.Errorf("key not found; remaining keys: %v", ks) + } else if len(ks) == 1 { // we've reached the final key + return r, nil + } else if r.Type(ctx).Equal(types.DynamicType) && !r.(types.Dynamic).UnderlyingValue().Type(ctx).Equal(types.TupleType{}) { + nestedMap = r.(types.Dynamic).UnderlyingValue().(types.Object).Attributes() + return r, nil + } else if nestedMap, ok = match.(map[string]attr.Value); !ok { + return nil, fmt.Errorf("malformed structure at %#v", match) + } else { // 1+ more keys + return MapLookup(ctx, nestedMap, ks[1:]...) + } +} diff --git a/internal/provider/collection_filter_function_test.go b/internal/provider/collection_filter_function_test.go new file mode 100644 index 0000000..d85f817 --- /dev/null +++ b/internal/provider/collection_filter_function_test.go @@ -0,0 +1,240 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + "testing" +) + +func TestCollectionFilterFunction(t *testing.T) { + t.Parallel() + + mockLocals := `locals { + test_object_collection = [ + { key1 = "value1", key2 = true, key3 = 3, key4 = null }, + { key1 = "value2", key2 = false, key3 = 0, key4 = {} }, + { key1 = "value3", key2 = true, key3 = 5, key4 = null }, + { key1 = "value4", key2 = false, key3 = 1, key4 = { key5 = "value5", key6 = true } }, + ] + + test_string_array = ["value1", "value2", "value3", "value1"] + + test_number_array = [5, 8, 3, 5] + + test_bool_array = [true, false, true] + }` + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_8_0), + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // test filter array of objects + { + Config: mockLocals + ` + + output "test_match_object_value_string" { + value = provider::helpers::collection_filter(local.test_object_collection, "key1", "value1") + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("test_match_object_value_string", knownvalue.ListSizeExact(1)), + statecheck.ExpectKnownOutputValue("test_match_object_value_string", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "key1": knownvalue.StringExact("value1"), + "key2": knownvalue.Bool(true), + "key3": knownvalue.Int64Exact(3), + "key4": knownvalue.Null(), + }), + })), + }, + }, + { + Config: mockLocals + ` + + output "test_match_object_bool_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key2", true) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("test_match_object_bool_value", knownvalue.ListSizeExact(2)), + statecheck.ExpectKnownOutputValue("test_match_object_bool_value", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "key1": knownvalue.StringExact("value1"), + "key2": knownvalue.Bool(true), + "key3": knownvalue.Int64Exact(3), + "key4": knownvalue.Null(), + }), + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "key1": knownvalue.StringExact("value3"), + "key2": knownvalue.Bool(true), + "key3": knownvalue.Int64Exact(5), + "key4": knownvalue.Null(), + }), + })), + }, + }, + { + Config: mockLocals + ` + + output "test_match_object_number_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key3", 5) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("test_match_object_number_value", knownvalue.ListSizeExact(1)), + statecheck.ExpectKnownOutputValue("test_match_object_number_value", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "key1": knownvalue.StringExact("value3"), + "key2": knownvalue.Bool(true), + "key3": knownvalue.Int64Exact(5), + "key4": knownvalue.Null(), + }), + })), + }, + }, + { + Config: mockLocals + ` + + output "test_match_object_null_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key4", null) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("test_match_null", knownvalue.ListSizeExact(2)), + statecheck.ExpectKnownOutputValue("test_match_null", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "key1": knownvalue.StringExact("value1"), + "key2": knownvalue.Bool(true), + "key3": knownvalue.Int64Exact(3), + "key4": knownvalue.Null(), + }), + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "key1": knownvalue.StringExact("value3"), + "key2": knownvalue.Bool(true), + "key3": knownvalue.Int64Exact(5), + "key4": knownvalue.Null(), + }), + })), + }, + }, + { + Config: mockLocals + ` + + output "test_match_object_string_nested_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key4.key5", "value5") + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("test_match_object_string_nested_value", knownvalue.ListSizeExact(1)), + statecheck.ExpectKnownOutputValue("test_match_object_string_nested_value", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "key1": knownvalue.StringExact("value4"), + "key2": knownvalue.Bool(false), + "key3": knownvalue.Int64Exact(1), + "key4": knownvalue.ObjectExact(map[string]knownvalue.Check{ + "key5": knownvalue.StringExact("value5"), + "key6": knownvalue.Bool(true), + }), + }), + })), + }, + }, + { + Config: mockLocals + ` + + output "test_no_match_object_string_value" { + value = provider::helpers::collection_filter(local.test_object_collection, "key1", "new_value") + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("test_no_match_object_string_value", knownvalue.ListSizeExact(0)), + }, + }, + + // test filter array of strings + { + Config: mockLocals + ` + + output "test_match_string_array" { + value = provider::helpers::collection_filter(local.test_string_array, "", "value2") + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("test_match_string_array", knownvalue.ListSizeExact(1)), + statecheck.ExpectKnownOutputValue("test_match_string_array", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("value2"), + })), + }, + }, + { + Config: mockLocals + ` + + output "test_match_string_array" { + value = provider::helpers::collection_filter(local.test_string_array, "", "value1") + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("test_match_string_array", knownvalue.ListSizeExact(2)), + statecheck.ExpectKnownOutputValue("test_match_string_array", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("value1"), + knownvalue.StringExact("value1"), + })), + }, + }, + + // test filter array of numbers + { + Config: mockLocals + ` + + output "test_match_number_array" { + value = provider::helpers::collection_filter(local.test_number_array, "", 3) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("test_match_number_array", knownvalue.ListSizeExact(1)), + statecheck.ExpectKnownOutputValue("test_match_number_array", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.Int64Exact(3), + })), + }, + }, + { + Config: mockLocals + ` + + output "test_match_number_array" { + value = provider::helpers::collection_filter(local.test_number_array, "", 5) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("test_match_number_array", knownvalue.ListSizeExact(2)), + statecheck.ExpectKnownOutputValue("test_match_number_array", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.Int64Exact(5), + knownvalue.Int64Exact(5), + })), + }, + }, + + // test filter bool array + { + Config: mockLocals + ` + + output "test_match_bool_array" { + value = provider::helpers::collection_filter(local.test_bool_array, "", false) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("test_match_bool_array", knownvalue.ListSizeExact(1)), + statecheck.ExpectKnownOutputValue("test_match_bool_array", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.Bool(false), + })), + }, + }, + { + Config: mockLocals + ` + + output "test_match_bool_array" { + value = provider::helpers::collection_filter(local.test_bool_array, "", true) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("test_match_bool_array", knownvalue.ListSizeExact(2)), + statecheck.ExpectKnownOutputValue("test_match_bool_array", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.Bool(true), + knownvalue.Bool(true), + })), + }, + }, + }, + }) +} diff --git a/internal/provider/object_set_value_function.go b/internal/provider/object_set_value_function.go index f437c04..3a797cb 100644 --- a/internal/provider/object_set_value_function.go +++ b/internal/provider/object_set_value_function.go @@ -24,6 +24,7 @@ func (c ObjectSetValueFunction) Metadata(_ context.Context, _ function.MetadataR func (c ObjectSetValueFunction) Definition(_ context.Context, _ function.DefinitionRequest, resp *function.DefinitionResponse) { resp.Definition = function.Definition{ Summary: "Sets a value in an Object or creates a new key with the value", + Parameters: []function.Parameter{ function.DynamicParameter{ Name: "object", @@ -53,6 +54,7 @@ func (c ObjectSetValueFunction) Definition(_ context.Context, _ function.Definit }, }, }, + Return: function.DynamicReturn{}, } } @@ -63,8 +65,8 @@ func (c ObjectSetValueFunction) Run(ctx context.Context, req function.RunRequest var value types.Dynamic var operation string - resp.Error = function.ConcatFuncErrors(req.Arguments.Get(ctx, &object, &key, &value, &operation)) - if resp.Error != nil { + if err := req.Arguments.Get(ctx, &object, &key, &value, &operation); err != nil { + resp.Error = err return } @@ -76,17 +78,13 @@ func (c ObjectSetValueFunction) Run(ctx context.Context, req function.RunRequest if _, ok := attrValues[key]; ok { isCurrentValueNull = attrValues[key].IsNull() || attrValues[key].Equal(basetypes.NewStringValue("")) - //asNull := attrValues[key].IsNull() - //asString := attrValues[key].Equal(basetypes.NewStringValue("")) - //isCurrentValueNull = asNull || asString == "" - //isCurrentValueNull = attrValues[key].IsNull() || attrValues[key].String() == "" keyExists = true } attrTypes[key] = value.UnderlyingValue().Type(ctx) attrValues[key] = value.UnderlyingValue() - var result basetypes.ObjectValue + var result types.Object var diags diag.Diagnostics isWriteSafeOp := operation == "write_safe" && keyExists && isCurrentValueNull diff --git a/internal/provider/os_get_env_function.go b/internal/provider/os_get_env_function.go new file mode 100644 index 0000000..a1bfdd0 --- /dev/null +++ b/internal/provider/os_get_env_function.go @@ -0,0 +1,64 @@ +package provider + +import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/types" + "os" +) + +var _ function.Function = &OsGetEnvFunction{} + +type OsGetEnvFunction struct{} + +func NewOsGetEnvFunction() function.Function { + return &OsGetEnvFunction{} +} + +func (o OsGetEnvFunction) Metadata(_ context.Context, _ function.MetadataRequest, resp *function.MetadataResponse) { + resp.Name = "os_get_env" +} + +func (o OsGetEnvFunction) Definition(_ context.Context, _ function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + Summary: "Get an environment variable", + Description: "Retrieve a single environment variable from the current process environment or use a fallback value if the environment variable is not set.", + + Parameters: []function.Parameter{ + function.StringParameter{ + Name: "name", + Description: "The name of the environment variable to get", + AllowNullValue: false, + AllowUnknownValues: false, + }, + }, + VariadicParameter: function.StringParameter{ + Name: "fallback", + Description: "The fallback value to use if the environment variable is not set", + AllowNullValue: false, + AllowUnknownValues: false, + }, + + Return: function.StringReturn{}, + } +} + +func (o OsGetEnvFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { + var name, result string + var fallback types.Tuple + + resp.Error = function.ConcatFuncErrors(req.Arguments.Get(ctx, &name, &fallback)) + if resp.Error != nil { + return + } + + if value, ok := os.LookupEnv(name); ok { + result = value + } else if len(fallback.Elements()) > 0 { + // assign element 0 to result if fallback is set + result = fallback.Elements()[0].(types.String).ValueString() + } + + resp.Error = resp.Result.Set(ctx, result) + return +} diff --git a/internal/provider/os_get_env_function_test.go b/internal/provider/os_get_env_function_test.go new file mode 100644 index 0000000..8a0b3ff --- /dev/null +++ b/internal/provider/os_get_env_function_test.go @@ -0,0 +1,59 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + "os" + "testing" +) + +func TestOsGetEnvFunction(t *testing.T) { + t.Parallel() + + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_8_0), + }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PreConfig: func() { + if err := os.Setenv("TF_ENV", "dev"); err != nil { + t.Fatal(err) + } + }, + // test function without fallback + Config: `output "tf_env" { value = provider::helpers::os_get_env("TF_ENV") }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("tf_env", knownvalue.StringExact("dev")), + }, + }, + { + // test function with fallback + Config: `output "tf_env" { value = provider::helpers::os_get_env("TF_ENV", "testing") }`, + PreConfig: func() { + if err := os.Unsetenv("TF_ENV"); err != nil { + t.Fatal(err) + } + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("tf_env", knownvalue.StringExact("testing")), + }, + }, + { + // test function with empty result + Config: `output "tf_env" { value = provider::helpers::os_get_env("TF_ENV") }`, + PreConfig: func() { + if err := os.Unsetenv("TF_ENV"); err != nil { + t.Fatal(err) + } + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownOutputValue("tf_env", knownvalue.StringExact("")), + }, + }, + }, + }) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 734a643..4f99b46 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -49,6 +49,8 @@ func (h *HelpersProvider) Resources(ctx context.Context) []func() resource.Resou func (h *HelpersProvider) Functions(_ context.Context) []func() function.Function { return []func() function.Function{ NewObjectSetValueFunction, + NewCollectionFilterFunction, + NewOsGetEnvFunction, } } diff --git a/internal/provider/validators.go b/internal/provider/validators.go new file mode 100644 index 0000000..4f504f6 --- /dev/null +++ b/internal/provider/validators.go @@ -0,0 +1 @@ +package provider diff --git a/internal/utils/xslices/validate.go b/internal/utils/xslices/validate.go new file mode 100644 index 0000000..1a3e385 --- /dev/null +++ b/internal/utils/xslices/validate.go @@ -0,0 +1,14 @@ +package xslices + +import "golang.org/x/exp/constraints" + +type SingleParamReturnBoolFunc[E any] func(E) bool + +func Every[E constraints.Ordered | interface{}](s1 []E, f SingleParamReturnBoolFunc[E]) bool { + for _, v := range s1 { + if !f(v) { + return false + } + } + return true +} diff --git a/internal/validators/dynamicvalidator/elements_type_of.go b/internal/validators/dynamicvalidator/elements_type_of.go new file mode 100644 index 0000000..94283f6 --- /dev/null +++ b/internal/validators/dynamicvalidator/elements_type_of.go @@ -0,0 +1,46 @@ +package dynamicvalidator + +import ( + "context" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatorfuncerr" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/types" + "terraform-provider-helpers/internal/utils/xslices" +) + +var _ function.DynamicParameterValidator = ElementsOfSameTypeValidator{} + +type ElementsOfSameTypeValidator struct{} + +func (v ElementsOfSameTypeValidator) ValidateParameterDynamic(ctx context.Context, req function.DynamicParameterValidatorRequest, resp *function.DynamicParameterValidatorResponse) { + if req.Value.IsNull() || req.Value.IsUnknown() { + return + } + + errorMsg := "value must be a list of elements of the same type" + + inputValue, ok := req.Value.UnderlyingValue().(types.Tuple) + if !ok { + resp.Error = validatorfuncerr.InvalidParameterValueMatchFuncError( + req.ArgumentPosition, + errorMsg, + inputValue.String(), + ) + } + + elementTypes := inputValue.ElementTypes(ctx) + funcEvery := func(element attr.Type) bool { + targetType := elementTypes[0].TerraformType(ctx) + return element.TerraformType(ctx).Is(targetType) + } + + if everyCheck := xslices.Every[attr.Type](elementTypes, funcEvery); !everyCheck { + resp.Error = validatorfuncerr.InvalidParameterValueMatchFuncError( + req.ArgumentPosition, + errorMsg, + inputValue.String(), + ) + } + +} diff --git a/main.go b/main.go index f0b1305..da1698b 100644 --- a/main.go +++ b/main.go @@ -10,16 +10,6 @@ import ( "terraform-provider-helpers/internal/provider" ) -// Run "go generate" to format example terraform files and generate the docs for the registry/website - -// If you do not have Terraform installed, you can remove the formatting command, but its suggested to -// ensure the documentation is formatted properly. -//go:generate terraform fmt -recursive ./examples/ - -// Run the docs generation tool, check its repository for more information on how it works and how docs -// can be customized. -//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate -provider-name helpers - var ( // these will be set by the goreleaser configuration // to appropriate values for the compiled binary. diff --git a/templates/functions/collection_filter.md.tmpl b/templates/functions/collection_filter.md.tmpl new file mode 100644 index 0000000..fcfd1b7 --- /dev/null +++ b/templates/functions/collection_filter.md.tmpl @@ -0,0 +1,41 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "Collection Functions" +description: |- + {{ .Summary | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Type | title }}: {{.Name}} + +{{ .Summary | trimspace }} + +The function `collection_filter` can be used, as indicated by the name, to quickly filter a collection of values. +Terraform does not offer out-of-the-box such functionality in a direct way, with the only reasonable solution of loop +through the collection and perform the filtering. + +In the current version the function is able to filter collection of primitives (number, bool, string) and objects, with +the last one able to also filter by a nested attribute. The filter right now is only using an "equal" check operation, +however, we might put some effort in the future to support different operators. + + +{{ if .HasExample -}} +## Example Usage + +{{tffile .ExampleFile }} +{{- end }} + +## Signature + +{{ .FunctionSignatureMarkdown }} + +## Arguments + +{{ .FunctionArgumentsMarkdown }} +{{ if .HasVariadic -}} +{{ .FunctionVariadicArgumentMarkdown }} +{{- end }} + +## Return Type + +The signature shows a dynamic type of return because in order to support multiple types of collections the return must +be specified in such way. You can always expect a collection of the same type as used in the input. diff --git a/templates/functions/os_get_env.md.tmpl b/templates/functions/os_get_env.md.tmpl new file mode 100644 index 0000000..cea729d --- /dev/null +++ b/templates/functions/os_get_env.md.tmpl @@ -0,0 +1,35 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "OS Functions" +description: |- + {{ .Summary | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Type | title }}: {{.Name}} + +{{ .Summary | trimspace }} + +The function `os_get_env` retrieves the value of an environment variable and provides a fallback value if the +environment variable is not set. + +{{ if .HasExample -}} +## Example Usage + +{{tffile .ExampleFile }} +{{- end }} + +## Signature + +{{ .FunctionSignatureMarkdown }} + +## Arguments + +{{ .FunctionArgumentsMarkdown }} +{{ if .HasVariadic -}} +{{ .FunctionVariadicArgumentMarkdown }} +{{- end }} + +## Return Type + +The return type of `{{.Name}}` is a string representing the value of the environment variable or the fallback value. +You can use terraform type conversion functions to convert the string to other types if needed. diff --git a/tools/tools.go b/tools/tools.go index 2c4f8fb..cc1c561 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -6,3 +6,11 @@ import ( // Documentation generation _ "github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs" ) + +// Format Terraform code for use in documentation. +// If you do not have Terraform installed, you can remove the formatting command, but it is suggested +// to ensure the documentation is formatted properly. +//go:generate terraform fmt -recursive ../examples/ + +// Generate documentation. +//go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate -provider-dir .. -provider-name helpers