Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add project_from_id provider-defined function #7031

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
3 changes: 3 additions & 0 deletions .changelog/10021.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:enhancement
provider: added provider-defined function `project_from_id` for retrieving the project id from a resource's self link or id
```
44 changes: 44 additions & 0 deletions google-beta/functions/element_from_id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package functions

import (
"fmt"
"regexp"

"github.com/hashicorp/terraform-plugin-framework/function"
)

const noMatchesErrorSummary string = "No matches present in the input string"
const ambiguousMatchesWarningSummary string = "Ambiguous input string could contain more than one match"

// ValidateElementFromIdArguments is reusable validation logic used in provider-defined functions that use the getElementFromId function
func ValidateElementFromIdArguments(input string, regex *regexp.Regexp, pattern string, resp *function.RunResponse) {
submatches := regex.FindAllStringSubmatchIndex(input, -1)

// Zero matches means unusable input; error returned
if len(submatches) == 0 {
resp.Diagnostics.AddArgumentError(
0,
noMatchesErrorSummary,
fmt.Sprintf("The input string \"%s\" doesn't contain the expected pattern \"%s\".", input, pattern),
)
}

// >1 matches means input usable but not ideal; issue warning
if len(submatches) > 1 {
resp.Diagnostics.AddArgumentWarning(
0,
ambiguousMatchesWarningSummary,
fmt.Sprintf("The input string \"%s\" contains more than one match for the pattern \"%s\". Terraform will use the first found match.", input, pattern),
)
}
}

// GetElementFromId is reusable logic that is used in multiple provider-defined functions for pulling elements out of self links and ids of resources and data sources
func GetElementFromId(input string, regex *regexp.Regexp, template string) string {
submatches := regex.FindAllStringSubmatchIndex(input, -1)
submatch := submatches[0] // Take the only / left-most submatch
dst := []byte{}
return string(regex.ExpandString(dst, template, input, submatch))
}
97 changes: 97 additions & 0 deletions google-beta/functions/element_from_id_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package functions_test

import (
"regexp"
"testing"

"github.com/hashicorp/terraform-plugin-framework/function"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
tpg_functions "github.com/hashicorp/terraform-provider-google-beta/google-beta/functions"
)

func TestFunctionInternals_ValidateElementFromIdArguments(t *testing.T) {

// Values here are matched to test case values below
regex := regexp.MustCompile("two/(?P<Element>[^/]+)/")
pattern := "two/{two}/"

cases := map[string]struct {
Input string
ExpectedElement string
ExpectError bool
ExpectWarning bool
}{
"it sets an error in diags if no match is found": {
Input: "one/element-1/three/element-3",
ExpectError: true,
},
"it sets a warning in diags if more than one match is found": {
Input: "two/element-2/two/element-2/two/element-2",
ExpectedElement: "element-2",
ExpectWarning: true,
},
}

for tn, tc := range cases {
t.Run(tn, func(t *testing.T) {

// Arrange
resp := function.RunResponse{
Result: function.NewResultData(basetypes.StringValue{}),
}

// Act
tpg_functions.ValidateElementFromIdArguments(tc.Input, regex, pattern, &resp)

// Assert
if resp.Diagnostics.HasError() && !tc.ExpectError {
t.Fatalf("Unexpected error(s) were set in response diags: %s", resp.Diagnostics.Errors())
}
if !resp.Diagnostics.HasError() && tc.ExpectError {
t.Fatal("Expected error(s) to be set in response diags, but there were none.")
}
if (resp.Diagnostics.WarningsCount() > 0) && !tc.ExpectWarning {
t.Fatalf("Unexpected warning(s) were set in response diags: %s", resp.Diagnostics.Warnings())
}
if (resp.Diagnostics.WarningsCount() == 0) && tc.ExpectWarning {
t.Fatal("Expected warning(s) to be set in response diags, but there were none.")
}
})
}
}

func TestFunctionInternals_GetElementFromId(t *testing.T) {

// Values here are matched to test case values below
regex := regexp.MustCompile("two/(?P<Element>[^/]+)/")
template := "$Element"

cases := map[string]struct {
Input string
ExpectedElement string
}{
"it can pull out a value from a string using a regex with a submatch": {
Input: "one/element-1/two/element-2/three/element-3",
ExpectedElement: "element-2",
},
"it will pull out the first value from a string with more than one submatch": {
Input: "one/element-1/two/element-2/two/not-this-one/three/element-3",
ExpectedElement: "element-2",
},
}

for tn, tc := range cases {
t.Run(tn, func(t *testing.T) {

// Act
result := tpg_functions.GetElementFromId(tc.Input, regex, template)

// Assert
if result != tc.ExpectedElement {
t.Fatalf("Expected function logic to retrieve %s from input %s, got %s", tc.ExpectedElement, tc.Input, result)
}
})
}
}
3 changes: 0 additions & 3 deletions google-beta/functions/main.go

This file was deleted.

61 changes: 61 additions & 0 deletions google-beta/functions/project_from_id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package functions

import (
"context"
"regexp"

"github.com/hashicorp/terraform-plugin-framework/function"
)

var _ function.Function = ProjectFromIdFunction{}

func NewProjectFromIdFunction() function.Function {
return &ProjectFromIdFunction{}
}

type ProjectFromIdFunction struct{}

func (f ProjectFromIdFunction) Metadata(ctx context.Context, req function.MetadataRequest, resp *function.MetadataResponse) {
resp.Name = "project_from_id"
}

func (f ProjectFromIdFunction) Definition(ctx context.Context, req function.DefinitionRequest, resp *function.DefinitionResponse) {
resp.Definition = function.Definition{
Summary: "Returns the project within a provided resource's id, resource URI, self link, or full resource name.",
Description: "Takes a single string argument, which should be a resource's id, resource URI, self link, or full resource name. This function will either return the project name from the input string or raise an error due to no project being present in the string. The function uses the presence of \"projects/{{project}}/\" in the input string to identify the project name, e.g. when the function is passed the id \"projects/my-project/zones/us-central1-c/instances/my-instance\" as an argument it will return \"my-project\".",
Parameters: []function.Parameter{
function.StringParameter{
Name: "id",
Description: "A string of a resource's id, resource URI, self link, or full resource name. For example, \"projects/my-project/zones/us-central1-c/instances/my-instance\", \"https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-c/instances/my-instance\" and \"//gkehub.googleapis.com/projects/my-project/locations/us-central1/memberships/my-membership\" are valid values",
},
},
Return: function.StringReturn{},
}
}

func (f ProjectFromIdFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) {
// Load arguments from function call
var arg0 string
resp.Diagnostics.Append(req.Arguments.GetArgument(ctx, 0, &arg0)...)

if resp.Diagnostics.HasError() {
return
}

// Prepare how we'll identify project id from input string
regex := regexp.MustCompile("projects/(?P<ProjectId>[^/]+)/") // Should match the pattern below
template := "$ProjectId" // Should match the submatch identifier in the regex
pattern := "projects/{project}/" // Human-readable pseudo-regex pattern used in errors and warnings

// Validate input
ValidateElementFromIdArguments(arg0, regex, pattern, resp)
if resp.Diagnostics.HasError() {
return
}

// Get and return element from input string
projectId := GetElementFromId(arg0, regex, template)
resp.Diagnostics.Append(resp.Result.Set(ctx, projectId)...)
}
115 changes: 115 additions & 0 deletions google-beta/functions/project_from_id_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package functions

import (
"context"
"fmt"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/function"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
)

func TestFunctionRun_project_from_id(t *testing.T) {
t.Parallel()

projectId := "my-project"

// Happy path inputs
validId := fmt.Sprintf("projects/%s/zones/us-central1-c/instances/my-instance", projectId)
validSelfLink := fmt.Sprintf("https://www.googleapis.com/compute/v1/%s", validId)
validOpStyleResourceName := fmt.Sprintf("//gkehub.googleapis.com/projects/%s/locations/us-central1/memberships/my-membership", projectId)

// Unhappy path inputs
repetitiveInput := fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/projects/not-this-1/projects/not-this-2/instances/my-instance", projectId) // Multiple /projects/{{project}}/
invalidInput := "zones/us-central1-c/instances/my-instance"

testCases := map[string]struct {
request function.RunRequest
expected function.RunResponse
}{
"it returns the expected output value when given a valid resource id input": {
request: function.RunRequest{
Arguments: function.NewArgumentsData([]attr.Value{types.StringValue(validId)}),
},
expected: function.RunResponse{
Result: function.NewResultData(types.StringValue(projectId)),
},
},
"it returns the expected output value when given a valid resource self_link input": {
request: function.RunRequest{
Arguments: function.NewArgumentsData([]attr.Value{types.StringValue(validSelfLink)}),
},
expected: function.RunResponse{
Result: function.NewResultData(types.StringValue(projectId)),
},
},
"it returns the expected output value when given a valid OP style resource name input": {
request: function.RunRequest{
Arguments: function.NewArgumentsData([]attr.Value{types.StringValue(validOpStyleResourceName)}),
},
expected: function.RunResponse{
Result: function.NewResultData(types.StringValue(projectId)),
},
},
"it returns a warning and the first submatch when given repetitive input": {
request: function.RunRequest{
Arguments: function.NewArgumentsData([]attr.Value{types.StringValue(repetitiveInput)}),
},
expected: function.RunResponse{
Result: function.NewResultData(types.StringValue(projectId)),
Diagnostics: diag.Diagnostics{
diag.NewArgumentWarningDiagnostic(
0,
ambiguousMatchesWarningSummary,
fmt.Sprintf("The input string \"%s\" contains more than one match for the pattern \"projects/{project}/\". Terraform will use the first found match.", repetitiveInput),
),
},
},
},
"it returns an error when given input with no submatches": {
request: function.RunRequest{
Arguments: function.NewArgumentsData([]attr.Value{types.StringValue(invalidInput)}),
},
expected: function.RunResponse{
Result: function.NewResultData(types.StringNull()),
Diagnostics: diag.Diagnostics{
diag.NewArgumentErrorDiagnostic(
0,
noMatchesErrorSummary,
fmt.Sprintf("The input string \"%s\" doesn't contain the expected pattern \"projects/{project}/\".", invalidInput),
),
},
},
},
}

for name, testCase := range testCases {
tn, tc := name, testCase

t.Run(tn, func(t *testing.T) {
t.Parallel()

// Arrange
got := function.RunResponse{
Result: function.NewResultData(basetypes.StringValue{}),
}

// Act
NewProjectFromIdFunction().Run(context.Background(), tc.request, &got)

// Assert
if diff := cmp.Diff(got.Result, tc.expected.Result); diff != "" {
t.Errorf("unexpected diff between expected and received result: %s", diff)
}
if diff := cmp.Diff(got.Diagnostics, tc.expected.Diagnostics); diff != "" {
t.Errorf("unexpected diff between expected and received diagnostics: %s", diff)
}
})
}
}
Loading