Skip to content

Commit 0e7d5db

Browse files
authored
framework import utils (#14729)
2 parents 246cecd + c20d146 commit 0e7d5db

File tree

2 files changed

+375
-0
lines changed

2 files changed

+375
-0
lines changed
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package fwresource
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"regexp"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/hashicorp/terraform-plugin-framework/attr"
11+
"github.com/hashicorp/terraform-plugin-framework/diag"
12+
"github.com/hashicorp/terraform-plugin-framework/resource"
13+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
14+
"github.com/hashicorp/terraform-plugin-framework/types"
15+
transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport"
16+
)
17+
18+
// ParseImportId uses a list of regular expressions to parse a resource's import ID.
19+
// It extracts named capture groups from the regex and converts them to their
20+
// corresponding type-safe attribute values based on the provided resource schema.
21+
// It also handles setting default values (project, region, etc) if they are not
22+
// present in the import ID.
23+
func ParseImportId(
24+
ctx context.Context,
25+
req resource.ImportStateRequest,
26+
resourceSchema schema.Schema,
27+
providerConfig *transport_tpg.Config,
28+
idRegexes []string,
29+
) (map[string]attr.Value, diag.Diagnostics) {
30+
var diags diag.Diagnostics
31+
parsedAttributes := make(map[string]attr.Value)
32+
33+
var matchFound bool
34+
for _, idFormat := range idRegexes {
35+
re, err := regexp.Compile(idFormat)
36+
if err != nil {
37+
diags.AddError(
38+
"Invalid Import Regex",
39+
fmt.Sprintf("Provider developer error: could not compile regex %q. Please report this issue. Error: %s", idFormat, err),
40+
)
41+
// This is a developer error, so we stop immediately.
42+
return nil, diags
43+
}
44+
45+
if match := re.FindStringSubmatch(req.ID); match != nil {
46+
matchFound = true
47+
subexpNames := re.SubexpNames()
48+
for i, valueStr := range match {
49+
// Index 0 is the full match, so we skip it.
50+
if i == 0 {
51+
continue
52+
}
53+
54+
fieldName := subexpNames[i]
55+
if fieldName == "" {
56+
continue
57+
}
58+
59+
// Look up the attribute in the resource's schema.
60+
attribute, ok := resourceSchema.Attributes[fieldName]
61+
if !ok {
62+
diags.AddWarning(
63+
"Unknown Import Field",
64+
fmt.Sprintf("Parsed field %q from import ID but it is not defined in the resource schema.", fieldName),
65+
)
66+
continue
67+
}
68+
69+
// Convert the parsed string value to the correct attr.Value type.
70+
attrVal, conversionDiags := convertToAttrValue(valueStr, attribute)
71+
diags.Append(conversionDiags...)
72+
if conversionDiags.HasError() {
73+
continue
74+
}
75+
parsedAttributes[fieldName] = attrVal
76+
}
77+
// Once a match is found, we stop. The most specific regex should be first.
78+
break
79+
}
80+
}
81+
82+
if !matchFound {
83+
diags.AddError(
84+
"Invalid Import ID",
85+
fmt.Sprintf("Import ID %q doesn't match any of the accepted formats: %v", req.ID, idRegexes),
86+
)
87+
return nil, diags
88+
}
89+
90+
// Handle default values like project, region, and zone.
91+
defaultDiags := addDefaultValues(ctx, parsedAttributes, providerConfig, resourceSchema, idRegexes[0])
92+
diags.Append(defaultDiags...)
93+
94+
return parsedAttributes, diags
95+
}
96+
97+
// convertToAttrValue converts a string to the appropriate attr.Value based on the schema attribute type.
98+
func convertToAttrValue(valueStr string, attr schema.Attribute) (attr.Value, diag.Diagnostics) {
99+
var diags diag.Diagnostics
100+
101+
switch attr.(type) {
102+
case schema.StringAttribute:
103+
return types.StringValue(valueStr), nil
104+
case schema.Int64Attribute:
105+
intVal, err := strconv.ParseInt(valueStr, 10, 64)
106+
if err != nil {
107+
diags.AddError(
108+
"Import Value Conversion Error",
109+
fmt.Sprintf("Failed to parse %q as an integer: %s", valueStr, err),
110+
)
111+
return nil, diags
112+
}
113+
return types.Int64Value(intVal), nil
114+
case schema.BoolAttribute:
115+
boolVal, err := strconv.ParseBool(valueStr)
116+
if err != nil {
117+
diags.AddError(
118+
"Import Value Conversion Error",
119+
fmt.Sprintf("Failed to parse %q as a boolean: %s", valueStr, err),
120+
)
121+
return nil, diags
122+
}
123+
return types.BoolValue(boolVal), nil
124+
case schema.Float64Attribute:
125+
floatVal, err := strconv.ParseFloat(valueStr, 64)
126+
if err != nil {
127+
diags.AddError(
128+
"Import Value Conversion Error",
129+
fmt.Sprintf("Failed to parse %q as a float: %s", valueStr, err),
130+
)
131+
return nil, diags
132+
}
133+
return types.Float64Value(floatVal), nil
134+
default:
135+
// For complex types like List, Object, etc., a simple string conversion is not feasible.
136+
// The assumption is that import IDs will only contain primitive types.
137+
diags.AddError(
138+
"Unsupported Import Attribute Type",
139+
fmt.Sprintf("Importing attributes of type %T is not supported. This is a provider developer issue.", attr),
140+
)
141+
return nil, diags
142+
}
143+
}
144+
145+
// addDefaultValues checks for common provider-level defaults (project, region, zone)
146+
// and adds them to the parsed attributes map if they were not already set from the import ID.
147+
func addDefaultValues(
148+
ctx context.Context,
149+
parsedAttributes map[string]attr.Value,
150+
config *transport_tpg.Config,
151+
resourceSchema schema.Schema,
152+
primaryRegex string,
153+
) diag.Diagnostics {
154+
var diags diag.Diagnostics
155+
156+
defaults := map[string]func(*transport_tpg.Config) (string, error){
157+
"project": func(c *transport_tpg.Config) (string, error) { return c.Project, nil },
158+
"region": func(c *transport_tpg.Config) (string, error) { return c.Region, nil },
159+
"zone": func(c *transport_tpg.Config) (string, error) { return c.Zone, nil },
160+
}
161+
162+
for field, getDefault := range defaults {
163+
// Check if the primary regex expects this field.
164+
if !strings.Contains(primaryRegex, fmt.Sprintf("(?P<%s>", field)) {
165+
continue
166+
}
167+
// Check if the resource schema actually has this attribute.
168+
if _, ok := resourceSchema.Attributes[field]; !ok {
169+
continue
170+
}
171+
// Check if the value was already parsed from the import ID.
172+
if _, ok := parsedAttributes[field]; ok {
173+
continue
174+
}
175+
176+
// Get the default value from the provider configuration.
177+
value, err := getDefault(config)
178+
if err != nil {
179+
diags.AddError(
180+
fmt.Sprintf("Failed to get default value for %s", field),
181+
err.Error(),
182+
)
183+
continue
184+
}
185+
186+
if value != "" {
187+
parsedAttributes[field] = types.StringValue(value)
188+
}
189+
}
190+
191+
return diags
192+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package fwresource
2+
3+
import (
4+
"context"
5+
"reflect"
6+
"strings"
7+
"testing"
8+
9+
"github.com/hashicorp/terraform-plugin-framework/attr"
10+
"github.com/hashicorp/terraform-plugin-framework/resource"
11+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
12+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
13+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
14+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
15+
"github.com/hashicorp/terraform-plugin-framework/types"
16+
transport_tpg "github.com/hashicorp/terraform-provider-google/google/transport"
17+
)
18+
19+
func TestParseImportId(t *testing.T) {
20+
testSchema := schema.Schema{
21+
Attributes: map[string]schema.Attribute{
22+
"project": schema.StringAttribute{
23+
Optional: true,
24+
Computed: true,
25+
PlanModifiers: []planmodifier.String{
26+
stringplanmodifier.UseStateForUnknown(),
27+
},
28+
},
29+
"name": schema.StringAttribute{
30+
Required: true,
31+
},
32+
"zone": schema.StringAttribute{
33+
Required: true,
34+
},
35+
"instance_id": schema.Int64Attribute{
36+
Required: true,
37+
PlanModifiers: []planmodifier.Int64{
38+
int64planmodifier.RequiresReplace(),
39+
},
40+
},
41+
},
42+
}
43+
44+
cases := map[string]struct {
45+
importId string
46+
idRegexes []string
47+
resourceSchema schema.Schema
48+
providerConfig *transport_tpg.Config
49+
expectedAttributes map[string]attr.Value
50+
expectError bool
51+
errorContains string
52+
}{
53+
"successfully parses full resource ID format": {
54+
importId: "projects/my-project/zones/us-central1-a/instances/12345",
55+
idRegexes: []string{
56+
"projects/(?P<project>[^/]+)/zones/(?P<zone>[^/]+)/instances/(?P<instance_id>[^/]+)",
57+
"(?P<project>[^/]+)/(?P<zone>[^/]+)/(?P<instance_id>[^/]+)",
58+
},
59+
resourceSchema: testSchema,
60+
providerConfig: &transport_tpg.Config{},
61+
expectedAttributes: map[string]attr.Value{
62+
"project": types.StringValue("my-project"),
63+
"zone": types.StringValue("us-central1-a"),
64+
"instance_id": types.Int64Value(12345),
65+
},
66+
},
67+
"successfully parses shorter ID format": {
68+
importId: "my-project/us-central1-a/12345",
69+
idRegexes: []string{
70+
"projects/(?P<project>[^/]+)/zones/(?P<zone>[^/]+)/instances/(?P<instance_id>[^/]+)",
71+
"(?P<project>[^/]+)/(?P<zone>[^/]+)/(?P<instance_id>[^/]+)",
72+
},
73+
resourceSchema: testSchema,
74+
providerConfig: &transport_tpg.Config{},
75+
expectedAttributes: map[string]attr.Value{
76+
"project": types.StringValue("my-project"),
77+
"zone": types.StringValue("us-central1-a"),
78+
"instance_id": types.Int64Value(12345),
79+
},
80+
},
81+
"successfully uses provider default for project": {
82+
importId: "us-central1-a/my-instance/12345",
83+
idRegexes: []string{
84+
"projects/(?P<project>[^/]+)/zones/(?P<zone>[^/]+)/instances/(?P<name>[^/]+)/(?P<instance_id>[^/]+)", // Most specific
85+
"(?P<zone>[^/]+)/(?P<name>[^/]+)/(?P<instance_id>[^/]+)",
86+
},
87+
resourceSchema: testSchema,
88+
providerConfig: &transport_tpg.Config{
89+
Project: "default-provider-project",
90+
},
91+
expectedAttributes: map[string]attr.Value{
92+
"project": types.StringValue("default-provider-project"),
93+
"zone": types.StringValue("us-central1-a"),
94+
"name": types.StringValue("my-instance"),
95+
"instance_id": types.Int64Value(12345),
96+
},
97+
},
98+
"returns error for non-matching ID": {
99+
importId: "invalid-id-format",
100+
idRegexes: []string{
101+
"projects/(?P<project>[^/]+)/zones/(?P<zone>[^/]+)/instances/(?P<instance_id>[^/]+)",
102+
},
103+
resourceSchema: testSchema,
104+
providerConfig: &transport_tpg.Config{},
105+
expectError: true,
106+
errorContains: "doesn't match any of the accepted formats",
107+
},
108+
"returns error for value that cannot be converted to type": {
109+
importId: "projects/my-project/zones/us-central1-a/instances/not-an-integer",
110+
idRegexes: []string{
111+
"projects/(?P<project>[^/]+)/zones/(?P<zone>[^/]+)/instances/(?P<instance_id>[^/]+)",
112+
},
113+
resourceSchema: testSchema,
114+
providerConfig: &transport_tpg.Config{},
115+
expectError: true,
116+
errorContains: "Failed to parse \"not-an-integer\" as an integer",
117+
},
118+
"returns error for invalid regex pattern": {
119+
importId: "any/id",
120+
idRegexes: []string{
121+
"projects/(?P<project>[^/]+)/zones/(?P<zone>[^/+", // Invalid regex with unclosed bracket
122+
},
123+
resourceSchema: testSchema,
124+
providerConfig: &transport_tpg.Config{},
125+
expectError: true,
126+
errorContains: "could not compile regex",
127+
},
128+
"warns about field in regex not present in schema": {
129+
importId: "projects/my-project/zones/us-central1-a/instances/12345/extra/field",
130+
idRegexes: []string{
131+
"projects/(?P<project>[^/]+)/zones/(?P<zone>[^/]+)/instances/(?P<instance_id>[^/]+)/extra/(?P<extra_field>[^/]+)",
132+
},
133+
resourceSchema: testSchema,
134+
providerConfig: &transport_tpg.Config{},
135+
// We expect success, but with a warning diagnostic. The valid fields should still be parsed.
136+
expectedAttributes: map[string]attr.Value{
137+
"project": types.StringValue("my-project"),
138+
"zone": types.StringValue("us-central1-a"),
139+
"instance_id": types.Int64Value(12345),
140+
},
141+
},
142+
}
143+
144+
for name, tc := range cases {
145+
t.Run(name, func(t *testing.T) {
146+
ctx := context.Background()
147+
req := resource.ImportStateRequest{
148+
ID: tc.importId,
149+
}
150+
151+
parsedAttributes, diags := ParseImportId(ctx, req, tc.resourceSchema, tc.providerConfig, tc.idRegexes)
152+
153+
if diags.HasError() {
154+
if tc.expectError {
155+
// Check if the error message contains the expected substring.
156+
if tc.errorContains != "" {
157+
found := false
158+
for _, d := range diags.Errors() {
159+
if strings.Contains(d.Detail(), tc.errorContains) {
160+
found = true
161+
break
162+
}
163+
}
164+
if !found {
165+
t.Fatalf("expected error to contain %q, but it did not. Got: %v", tc.errorContains, diags.Errors())
166+
}
167+
}
168+
// Correctly handled an expected error.
169+
return
170+
}
171+
t.Fatalf("unexpected error: %v", diags)
172+
}
173+
174+
if tc.expectError {
175+
t.Fatal("expected an error, but got none")
176+
}
177+
178+
if !reflect.DeepEqual(tc.expectedAttributes, parsedAttributes) {
179+
t.Fatalf("incorrect attributes parsed.\n- got: %v\n- want: %v", parsedAttributes, tc.expectedAttributes)
180+
}
181+
})
182+
}
183+
}

0 commit comments

Comments
 (0)