diff --git a/docs/data-sources/cdn_distribution.md b/docs/data-sources/cdn_distribution.md index 1f046144c..099a24799 100644 --- a/docs/data-sources/cdn_distribution.md +++ b/docs/data-sources/cdn_distribution.md @@ -51,6 +51,7 @@ Read-Only: - `backend` (Attributes) The configured backend for the distribution (see [below for nested schema](#nestedatt--config--backend)) - `optimizer` (Attributes) Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience. (see [below for nested schema](#nestedatt--config--optimizer)) +- `redirects` (Attributes) A wrapper for a list of redirect rules that allows for redirect settings on a distribution (see [below for nested schema](#nestedatt--config--redirects)) - `regions` (List of String) The configured regions where content will be hosted @@ -74,6 +75,36 @@ Read-Only: - `enabled` (Boolean) + +### Nested Schema for `config.redirects` + +Read-Only: + +- `rules` (Attributes List) A list of redirect rules. The order of rules matters for evaluation (see [below for nested schema](#nestedatt--config--redirects--rules)) + + +### Nested Schema for `config.redirects.rules` + +Read-Only: + +- `description` (String) An optional description for the redirect rule +- `enabled` (Boolean) A toggle to enable or disable the redirect rule. Default to true +- `matchers` (Attributes List) A list of matchers that define when this rule should apply. At least one matcher is required (see [below for nested schema](#nestedatt--config--redirects--rules--matchers)) +- `rule_match_condition` (String) Defines how multiple matchers within this rule are combined (ALL, ANY, NONE). Defaults to ANY. +- `status_code` (Number) The HTTP status code for the redirect. Must be one of 301, 302, 303, 307, or 308. +- `target_url` (String) The target URL to redirect to. Must be a valid URI + + +### Nested Schema for `config.redirects.rules.matchers` + +Read-Only: + +- `value_match_condition` (String) Defines how multiple matchers within this rule are combined (ALL, ANY, NONE). Defaults to ANY. +- `values` (List of String) A list of glob patterns to match against the request path. At least one value is required. Examples: "/shop/*" or "*/img/*" + + + + ### Nested Schema for `domains` diff --git a/docs/resources/cdn_distribution.md b/docs/resources/cdn_distribution.md index baa7971ce..7a424a129 100644 --- a/docs/resources/cdn_distribution.md +++ b/docs/resources/cdn_distribution.md @@ -96,6 +96,7 @@ Optional: - `blocked_countries` (List of String) The configured countries where distribution of content is blocked - `optimizer` (Attributes) Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience. (see [below for nested schema](#nestedatt--config--optimizer)) +- `redirects` (Attributes) A wrapper for a list of redirect rules that allows for redirect settings on a distribution (see [below for nested schema](#nestedatt--config--redirects)) ### Nested Schema for `config.backend` @@ -131,6 +132,42 @@ Optional: - `enabled` (Boolean) + +### Nested Schema for `config.redirects` + +Required: + +- `rules` (Attributes List) A list of redirect rules. The order of rules matters for evaluation (see [below for nested schema](#nestedatt--config--redirects--rules)) + + +### Nested Schema for `config.redirects.rules` + +Required: + +- `matchers` (Attributes List) A list of matchers that define when this rule should apply. At least one matcher is required (see [below for nested schema](#nestedatt--config--redirects--rules--matchers)) +- `status_code` (Number) The HTTP status code for the redirect. Must be one of 301, 302, 303, 307, or 308. +- `target_url` (String) The target URL to redirect to. Must be a valid URI + +Optional: + +- `description` (String) An optional description for the redirect rule +- `enabled` (Boolean) A toggle to enable or disable the redirect rule. Default to true +- `rule_match_condition` (String) Defines how multiple matchers within this rule are combined (ALL, ANY, NONE). Defaults to ANY. + + +### Nested Schema for `config.redirects.rules.matchers` + +Required: + +- `values` (List of String) A list of glob patterns to match against the request path. At least one value is required. Examples: "/shop/*" or "*/img/*" + +Optional: + +- `value_match_condition` (String) Defines how multiple matchers within this rule are combined (ALL, ANY, NONE). Defaults to ANY. + + + + ### Nested Schema for `domains` diff --git a/examples/resources/stackit_cdn_distribution/resource.tf b/examples/resources/stackit_cdn_distribution/resource.tf index 1e3d1dacd..4c37818bf 100644 --- a/examples/resources/stackit_cdn_distribution/resource.tf +++ b/examples/resources/stackit_cdn_distribution/resource.tf @@ -38,6 +38,24 @@ resource "stackit_cdn_distribution" "example_bucket_distribution" { optimizer = { enabled = false } + + redirects = { + rules = [ + { + description = "test redirect" + enabled = true + rule_match_condition = "ANY" + status_code = 302 + target_url = "https://stackit.de/" + matchers = [ + { + values = ["*/otherPath/"] + value_match_condition = "ANY" + } + ] + } + ] + } } } diff --git a/stackit/internal/services/cdn/distribution/datasource.go b/stackit/internal/services/cdn/distribution/datasource.go index f352d863b..8ba61bc81 100644 --- a/stackit/internal/services/cdn/distribution/datasource.go +++ b/stackit/internal/services/cdn/distribution/datasource.go @@ -38,6 +38,9 @@ var dataSourceConfigTypes = map[string]attr.Type{ "optimizer": types.ObjectType{ AttrTypes: optimizerTypes, // Shared from resource.go }, + "redirects": types.ObjectType{ + AttrTypes: redirectsTypes, // Shared from resource.go + }, } type distributionDataSource struct { @@ -199,6 +202,57 @@ func (r *distributionDataSource) Schema(_ context.Context, _ datasource.SchemaRe }, }, }, + "redirects": schema.SingleNestedAttribute{ + Computed: true, + Description: schemaDescriptions["config_redirects"], + Attributes: map[string]schema.Attribute{ + "rules": schema.ListNestedAttribute{ + Description: schemaDescriptions["config_redirects_rules"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "description": schema.StringAttribute{ + Description: schemaDescriptions["config_redirects_rule_description"], + Computed: true, + }, + "enabled": schema.BoolAttribute{ + Computed: true, + Description: schemaDescriptions["config_redirects_rule_enabled"], + }, + "target_url": schema.StringAttribute{ + Computed: true, + Description: schemaDescriptions["config_redirects_rule_target_url"], + }, + "status_code": schema.Int32Attribute{ + Computed: true, + Description: schemaDescriptions["config_redirects_rule_status_code"], + }, + "rule_match_condition": schema.StringAttribute{ + Computed: true, + Description: schemaDescriptions["config_redirects_rule_match_condition"], + }, + "matchers": schema.ListNestedAttribute{ + Description: schemaDescriptions["config_redirects_rule_matchers"], + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "values": schema.ListAttribute{ + Description: schemaDescriptions["config_redirects_rule_matcher_values"], + Computed: true, + ElementType: types.StringType, + }, + "value_match_condition": schema.StringAttribute{ + Description: schemaDescriptions["config_redirects_rule_match_condition"], + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, }, }, }, @@ -300,6 +354,99 @@ func mapDataSourceFields(ctx context.Context, distribution *cdn.Distribution, mo return core.DiagsToError(diags) } + // redirects + redirectsVal := types.ObjectNull(redirectsTypes) + if distribution.Config != nil && distribution.Config.Redirects != nil && distribution.Config.Redirects.Rules != nil { + var tfRules []attr.Value + for _, r := range *distribution.Config.Redirects.Rules { + var tfMatchers []attr.Value + if r.Matchers != nil { + for _, m := range *r.Matchers { + var tfValues []attr.Value + if m.Values != nil { + for _, v := range *m.Values { + tfValues = append(tfValues, types.StringValue(v)) + } + } + tfValuesList, diags := types.ListValue(types.StringType, tfValues) + if diags.HasError() { + return core.DiagsToError(diags) + } + + tfValMatchCond := types.StringNull() + if m.ValueMatchCondition != nil { + tfValMatchCond = types.StringValue(string(*m.ValueMatchCondition)) + } + + tfMatcherObj, diags := types.ObjectValue(matcherTypes, map[string]attr.Value{ + "values": tfValuesList, + "value_match_condition": tfValMatchCond, + }) + if diags.HasError() { + return core.DiagsToError(diags) + } + tfMatchers = append(tfMatchers, tfMatcherObj) + } + } + + tfMatchersList, diags := types.ListValue(types.ObjectType{AttrTypes: matcherTypes}, tfMatchers) + if diags.HasError() { + return core.DiagsToError(diags) + } + + tfDesc := types.StringNull() + if r.Description != nil { + tfDesc = types.StringValue(*r.Description) + } + + tfEnabled := types.BoolNull() + if r.Enabled != nil { + tfEnabled = types.BoolValue(*r.Enabled) + } + + tfTargetUrl := types.StringNull() + if r.TargetUrl != nil { + tfTargetUrl = types.StringValue(*r.TargetUrl) + } + + tfStatusCode := types.Int32Null() + if r.StatusCode != nil { + tfStatusCode = types.Int32Value(int32(*r.StatusCode)) + } + + tfRuleMatchCond := types.StringNull() + if r.RuleMatchCondition != nil { + tfRuleMatchCond = types.StringValue(string(*r.RuleMatchCondition)) + } + + tfRuleObj, diags := types.ObjectValue(redirectRuleTypes, map[string]attr.Value{ + "description": tfDesc, + "enabled": tfEnabled, + "target_url": tfTargetUrl, + "status_code": tfStatusCode, + "rule_match_condition": tfRuleMatchCond, + "matchers": tfMatchersList, + }) + if diags.HasError() { + return core.DiagsToError(diags) + } + tfRules = append(tfRules, tfRuleObj) + } + + tfRulesList, diags := types.ListValue(types.ObjectType{AttrTypes: redirectRuleTypes}, tfRules) + if diags.HasError() { + return core.DiagsToError(diags) + } + + var objDiags diag.Diagnostics + redirectsVal, objDiags = types.ObjectValue(redirectsTypes, map[string]attr.Value{ + "rules": tfRulesList, + }) + if objDiags.HasError() { + return core.DiagsToError(objDiags) + } + } + // Prepare Backend Values var backendValues map[string]attr.Value originRequestHeaders := types.MapNull(types.StringType) @@ -383,6 +530,7 @@ func mapDataSourceFields(ctx context.Context, distribution *cdn.Distribution, mo "regions": modelRegions, "blocked_countries": modelBlockedCountries, "optimizer": optimizerVal, + "redirects": redirectsVal, }) if diags.HasError() { return core.DiagsToError(diags) diff --git a/stackit/internal/services/cdn/distribution/datasource_test.go b/stackit/internal/services/cdn/distribution/datasource_test.go index 5bf117032..fb62b1874 100644 --- a/stackit/internal/services/cdn/distribution/datasource_test.go +++ b/stackit/internal/services/cdn/distribution/datasource_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/stackit-sdk-go/services/cdn" ) @@ -39,13 +40,53 @@ func TestMapDataSourceFields(t *testing.T) { optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ "enabled": types.BoolValue(true), }) + redirectsAttrTypes := configTypes["redirects"].(basetypes.ObjectType).AttrTypes config := types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{ "backend": backend, "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) + redirectsInput := &cdn.RedirectConfig{ + Rules: &[]cdn.RedirectRule{ + { + Description: cdn.PtrString("Test redirect"), + Enabled: cdn.PtrBool(true), + TargetUrl: cdn.PtrString("https://example.com/redirect"), + StatusCode: cdn.RedirectRuleStatusCode(301).Ptr(), + RuleMatchCondition: cdn.MatchCondition("ANY").Ptr(), + Matchers: &[]cdn.Matcher{ + { + Values: &[]string{"/shop/*"}, + ValueMatchCondition: cdn.MatchCondition("ANY").Ptr(), + }, + }, + }, + }, + } + matcherValuesExpected := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("/shop/*"), + }) + matcherValExpected := types.ObjectValueMust(matcherTypes, map[string]attr.Value{ + "values": matcherValuesExpected, + "value_match_condition": types.StringValue("ANY"), + }) + matchersListExpected := types.ListValueMust(types.ObjectType{AttrTypes: matcherTypes}, []attr.Value{matcherValExpected}) + + ruleValExpected := types.ObjectValueMust(redirectRuleTypes, map[string]attr.Value{ + "description": types.StringValue("Test redirect"), + "enabled": types.BoolValue(true), + "target_url": types.StringValue("https://example.com/redirect"), + "status_code": types.Int32Value(301), + "rule_match_condition": types.StringValue("ANY"), + "matchers": matchersListExpected, + }) + rulesListExpected := types.ListValueMust(types.ObjectType{AttrTypes: redirectRuleTypes}, []attr.Value{ruleValExpected}) + redirectsConfigExpected := types.ObjectValueMust(redirectsTypes, map[string]attr.Value{ + "rules": rulesListExpected, + }) emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{}) managedDomain := types.ObjectValueMust(domainTypes, map[string]attr.Value{ "name": types.StringValue("test.stackit-cdn.com"), @@ -132,6 +173,7 @@ func TestMapDataSourceFields(t *testing.T) { "regions": regionsFixture, "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Input: distributionFixture(func(d *cdn.Distribution) { @@ -157,6 +199,7 @@ func TestMapDataSourceFields(t *testing.T) { "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), IsValid: true, @@ -176,6 +219,7 @@ func TestMapDataSourceFields(t *testing.T) { "regions": regionsFixture, "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Input: distributionFixture(func(d *cdn.Distribution) { @@ -192,6 +236,21 @@ func TestMapDataSourceFields(t *testing.T) { }), IsValid: true, }, + "happy_path_with_redirects": { + Expected: expectedModel(func(m *Model) { + m.Config = types.ObjectValueMust(dataSourceConfigTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": redirectsConfigExpected, + }) + }), + Input: distributionFixture(func(d *cdn.Distribution) { + d.Config.Redirects = redirectsInput + }), + IsValid: true, + }, "happy_path_custom_domain": { Expected: expectedModel(func(m *Model) { managedDomain := types.ObjectValueMust(domainTypes, map[string]attr.Value{ diff --git a/stackit/internal/services/cdn/distribution/resource.go b/stackit/internal/services/cdn/distribution/resource.go index 47e36ffa4..774bf6bbb 100644 --- a/stackit/internal/services/cdn/distribution/resource.go +++ b/stackit/internal/services/cdn/distribution/resource.go @@ -9,6 +9,8 @@ import ( "time" "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -17,8 +19,10 @@ import ( "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" @@ -43,29 +47,38 @@ var ( ) var schemaDescriptions = map[string]string{ - "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`distribution_id`\".", - "distribution_id": "CDN distribution ID", - "project_id": "STACKIT project ID associated with the distribution", - "status": "Status of the distribution", - "created_at": "Time when the distribution was created", - "updated_at": "Time when the distribution was last updated", - "errors": "List of distribution errors", - "domains": "List of configured domains for the distribution", - "config": "The distribution configuration", - "config_backend": "The configured backend for the distribution", - "config_regions": "The configured regions where content will be hosted", - "config_backend_type": "The configured backend type. ", - "config_optimizer": "Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience.", - "config_backend_origin_url": "The configured backend type http for the distribution", - "config_backend_origin_request_headers": "The configured type http origin request headers for the backend", - "config_backend_geofencing": "The configured type http to configure countries where content is allowed. A map of URLs to a list of countries", - "config_blocked_countries": "The configured countries where distribution of content is blocked", - "domain_name": "The name of the domain", - "domain_status": "The status of the domain", - "domain_type": "The type of the domain. Each distribution has one domain of type \"managed\", and domains of type \"custom\" may be additionally created by the user", - "domain_errors": "List of domain errors", - "config_backend_bucket_url": "The URL of the bucket (e.g. https://s3.example.com). Required if type is 'bucket'.", - "config_backend_region": "The region where the bucket is hosted. Required if type is 'bucket'.", + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`distribution_id`\".", + "distribution_id": "CDN distribution ID", + "project_id": "STACKIT project ID associated with the distribution", + "status": "Status of the distribution", + "created_at": "Time when the distribution was created", + "updated_at": "Time when the distribution was last updated", + "errors": "List of distribution errors", + "domains": "List of configured domains for the distribution", + "config": "The distribution configuration", + "config_backend": "The configured backend for the distribution", + "config_regions": "The configured regions where content will be hosted", + "config_backend_type": "The configured backend type. ", + "config_optimizer": "Configuration for the Image Optimizer. This is a paid feature that automatically optimizes images to reduce their file size for faster delivery, leading to improved website performance and a better user experience.", + "config_backend_origin_url": "The configured backend type http for the distribution", + "config_backend_origin_request_headers": "The configured type http origin request headers for the backend", + "config_backend_geofencing": "The configured type http to configure countries where content is allowed. A map of URLs to a list of countries", + "config_blocked_countries": "The configured countries where distribution of content is blocked", + "config_redirects": "A wrapper for a list of redirect rules that allows for redirect settings on a distribution", + "config_redirects_rules": "A list of redirect rules. The order of rules matters for evaluation", + "config_redirects_rule_description": "An optional description for the redirect rule", + "config_redirects_rule_enabled": "A toggle to enable or disable the redirect rule. Default to true", + "config_redirects_rule_target_url": "The target URL to redirect to. Must be a valid URI", + "config_redirects_rule_status_code": "The HTTP status code for the redirect. Must be one of 301, 302, 303, 307, or 308.", + "config_redirects_rule_matchers": "A list of matchers that define when this rule should apply. At least one matcher is required", + "config_redirects_rule_matcher_values": "A list of glob patterns to match against the request path. At least one value is required. Examples: \"/shop/*\" or \"*/img/*\"", + "config_redirects_rule_match_condition": "Defines how multiple matchers within this rule are combined (ALL, ANY, NONE). Defaults to ANY.", + "domain_name": "The name of the domain", + "domain_status": "The status of the domain", + "domain_type": "The type of the domain. Each distribution has one domain of type \"managed\", and domains of type \"custom\" may be additionally created by the user", + "domain_errors": "List of domain errors", + "config_backend_bucket_url": "The URL of the bucket (e.g. https://s3.example.com). Required if type is 'bucket'.", + "config_backend_region": "The region where the bucket is hosted. Required if type is 'bucket'.", "config_backend_credentials_access_key_id": "The access key for the bucket. Required if type is 'bucket'.", "config_backend_credentials_secret_access_key": "The secret key for the bucket. Required if type is 'bucket'.", "config_backend_credentials": "The credentials for the bucket. Required if type is 'bucket'.", @@ -83,11 +96,30 @@ type Model struct { Config types.Object `tfsdk:"config"` // the configuration of the distribution } +type matcher struct { + Values []string `tfsdk:"values"` + ValueMatchCondition *string `tfsdk:"value_match_condition"` +} + +type redirectRule struct { + Description *string `tfsdk:"description"` + Enabled *bool `tfsdk:"enabled"` + TargetUrl string `tfsdk:"target_url"` + StatusCode int32 `tfsdk:"status_code"` + Matchers []matcher `tfsdk:"matchers"` + RuleMatchCondition *string `tfsdk:"rule_match_condition"` +} + +type redirectConfig struct { + Rules []redirectRule `tfsdk:"rules"` +} + type distributionConfig struct { - Backend backend `tfsdk:"backend"` // The backend associated with the distribution - Regions *[]string `tfsdk:"regions"` // The regions in which data will be cached - BlockedCountries *[]string `tfsdk:"blocked_countries"` // The countries for which content will be blocked - Optimizer types.Object `tfsdk:"optimizer"` // The optimizer configuration + Backend backend `tfsdk:"backend"` // The backend associated with the distribution + Redirects *redirectConfig `tfsdk:"redirects"` // A wrapper for a list of redirect rules that allows for redirect settings on a distribution + Regions *[]string `tfsdk:"regions"` // The regions in which data will be cached + BlockedCountries *[]string `tfsdk:"blocked_countries"` // The countries for which content will be blocked + Optimizer types.Object `tfsdk:"optimizer"` // The optimizer configuration } type optimizerConfig struct { @@ -95,7 +127,7 @@ type optimizerConfig struct { } type backend struct { - Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" backend is supported + Type string `tfsdk:"type"` // The type of the backend. Currently, only "http" and "bucket" backend is supported OriginURL *string `tfsdk:"origin_url"` // The origin URL of the backend OriginRequestHeaders *map[string]string `tfsdk:"origin_request_headers"` // Request headers that should be added by the CDN distribution to incoming requests Geofencing *map[string][]*string `tfsdk:"geofencing"` // The geofencing is an object mapping multiple alternative origins to country codes. @@ -116,6 +148,9 @@ var configTypes = map[string]attr.Type{ "optimizer": types.ObjectType{ AttrTypes: optimizerTypes, }, + "redirects": types.ObjectType{ + AttrTypes: redirectsTypes, + }, } var optimizerTypes = map[string]attr.Type{ @@ -126,6 +161,32 @@ var geofencingTypes = types.MapType{ElemType: types.ListType{ ElemType: types.StringType, }} +var matcherTypes = map[string]attr.Type{ + "values": types.ListType{ElemType: types.StringType}, + "value_match_condition": types.StringType, +} + +var redirectRuleTypes = map[string]attr.Type{ + "description": types.StringType, + "enabled": types.BoolType, + "target_url": types.StringType, + "status_code": types.Int32Type, + "rule_match_condition": types.StringType, + "matchers": types.ListType{ + ElemType: types.ObjectType{ + AttrTypes: matcherTypes, + }, + }, +} + +var redirectsTypes = map[string]attr.Type{ + "rules": types.ListType{ + ElemType: types.ObjectType{ + AttrTypes: redirectRuleTypes, + }, + }, +} + var backendTypes = map[string]attr.Type{ "type": types.StringType, "origin_url": types.StringType, @@ -183,6 +244,8 @@ func (r *distributionResource) Metadata(_ context.Context, req resource.Metadata func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { backendOptions := []string{"http", "bucket"} + matchCondition := []string{"ANY", "ALL", "NONE"} + statusCode := []int32{301, 302, 303, 307, 308} resp.Schema = schema.Schema{ MarkdownDescription: features.AddBetaDescription("CDN distribution data source schema.", core.Resource), Description: "CDN distribution data source schema.", @@ -267,6 +330,74 @@ func (r *distributionResource) Schema(_ context.Context, _ resource.SchemaReques objectvalidator.AlsoRequires(path.MatchRelative().AtName("enabled")), }, }, + "redirects": schema.SingleNestedAttribute{ + Optional: true, + Description: schemaDescriptions["config_redirects"], + Attributes: map[string]schema.Attribute{ + "rules": schema.ListNestedAttribute{ + Description: schemaDescriptions["config_redirects_rules"], + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "description": schema.StringAttribute{ + Description: schemaDescriptions["config_redirects_rule_description"], + Optional: true, + }, + "enabled": schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: schemaDescriptions["config_redirects_rule_enabled"], + Default: booldefault.StaticBool(true), + }, + "target_url": schema.StringAttribute{ + Required: true, + Description: schemaDescriptions["config_redirects_rule_target_url"], + }, + "status_code": schema.Int32Attribute{ + Required: true, + Description: schemaDescriptions["config_redirects_rule_status_code"], + Validators: []validator.Int32{int32validator.OneOf(statusCode...)}, + }, + "rule_match_condition": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: schemaDescriptions["config_redirects_rule_match_condition"], + Default: stringdefault.StaticString("ANY"), + Validators: []validator.String{stringvalidator.OneOfCaseInsensitive(matchCondition...)}, + }, + "matchers": schema.ListNestedAttribute{ + Description: schemaDescriptions["config_redirects_rule_matchers"], + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "values": schema.ListAttribute{ + Description: schemaDescriptions["config_redirects_rule_matcher_values"], + Required: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + }, + }, + "value_match_condition": schema.StringAttribute{ + Optional: true, + Description: schemaDescriptions["config_redirects_rule_match_condition"], + Default: stringdefault.StaticString("ANY"), + Computed: true, + Validators: []validator.String{stringvalidator.OneOfCaseInsensitive(matchCondition...)}, + }, + }, + }, + }}, + }, + }, + }, + }, "backend": schema.SingleNestedAttribute{ Required: true, Description: schemaDescriptions["config_backend"], @@ -567,6 +698,51 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe blockedCountries = &tempBlockedCountries } + // redirects + var redirectsConfig *cdn.RedirectConfig + if configModel.Redirects != nil { + sdkRules := []cdn.RedirectRule{} + if len(configModel.Redirects.Rules) > 0 { + for _, rule := range configModel.Redirects.Rules { + matchers := []cdn.Matcher{} + for _, matcher := range rule.Matchers { + var matchCond *cdn.MatchCondition + if matcher.ValueMatchCondition != nil { + cond := cdn.MatchCondition(*matcher.ValueMatchCondition) + matchCond = &cond + } + + matchers = append(matchers, cdn.Matcher{ + Values: &matcher.Values, + ValueMatchCondition: matchCond, + }) + } + + var ruleMatchCond *cdn.MatchCondition + if rule.RuleMatchCondition != nil { + cond := cdn.MatchCondition(*rule.RuleMatchCondition) + ruleMatchCond = &cond + } + + statusCode := cdn.RedirectRuleStatusCode(rule.StatusCode) + targetUrl := rule.TargetUrl + + sdkConfigRule := cdn.RedirectRule{ + Description: rule.Description, + Enabled: rule.Enabled, + Matchers: &matchers, + RuleMatchCondition: ruleMatchCond, + StatusCode: &statusCode, + TargetUrl: &targetUrl, + } + sdkRules = append(sdkRules, sdkConfigRule) + } + } + redirectsConfig = &cdn.RedirectConfig{ + Rules: &sdkRules, + } + } + configPatchBackend := &cdn.ConfigPatchBackend{} if configModel.Backend.Type == "http" { @@ -611,6 +787,7 @@ func (r *distributionResource) Update(ctx context.Context, req resource.UpdateRe Backend: configPatchBackend, Regions: ®ions, BlockedCountries: blockedCountries, + Redirects: redirectsConfig, } if !utils.IsUndefined(configModel.Optimizer) { @@ -773,6 +950,99 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model } } + // redirects + redirectsVal := types.ObjectNull(redirectsTypes) + if distribution.Config != nil && distribution.Config.Redirects != nil && distribution.Config.Redirects.Rules != nil { + var tfRules []attr.Value + for _, r := range *distribution.Config.Redirects.Rules { + var tfMatchers []attr.Value + if r.Matchers != nil { + for _, m := range *r.Matchers { + var tfValues []attr.Value + if m.Values != nil { + for _, v := range *m.Values { + tfValues = append(tfValues, types.StringValue(v)) + } + } + tfValuesList, diags := types.ListValue(types.StringType, tfValues) + if diags.HasError() { + return core.DiagsToError(diags) + } + + tfValMatchCond := types.StringNull() + if m.ValueMatchCondition != nil { + tfValMatchCond = types.StringValue(string(*m.ValueMatchCondition)) + } + + tfMatcherObj, diags := types.ObjectValue(matcherTypes, map[string]attr.Value{ + "values": tfValuesList, + "value_match_condition": tfValMatchCond, + }) + if diags.HasError() { + return core.DiagsToError(diags) + } + tfMatchers = append(tfMatchers, tfMatcherObj) + } + } + + tfMatchersList, diags := types.ListValue(types.ObjectType{AttrTypes: matcherTypes}, tfMatchers) + if diags.HasError() { + return core.DiagsToError(diags) + } + + tfDesc := types.StringNull() + if r.Description != nil { + tfDesc = types.StringValue(*r.Description) + } + + tfEnabled := types.BoolNull() + if r.Enabled != nil { + tfEnabled = types.BoolValue(*r.Enabled) + } + + tfTargetUrl := types.StringNull() + if r.TargetUrl != nil { + tfTargetUrl = types.StringValue(*r.TargetUrl) + } + + tfStatusCode := types.Int32Null() + if r.StatusCode != nil { + tfStatusCode = types.Int32Value(int32(*r.StatusCode)) + } + + tfRuleMatchCond := types.StringNull() + if r.RuleMatchCondition != nil { + tfRuleMatchCond = types.StringValue(string(*r.RuleMatchCondition)) + } + + tfRuleObj, diags := types.ObjectValue(redirectRuleTypes, map[string]attr.Value{ + "description": tfDesc, + "enabled": tfEnabled, + "target_url": tfTargetUrl, + "status_code": tfStatusCode, + "rule_match_condition": tfRuleMatchCond, + "matchers": tfMatchersList, + }) + if diags.HasError() { + return core.DiagsToError(diags) + } + tfRules = append(tfRules, tfRuleObj) + } + + tfRulesList, diags := types.ListValue(types.ObjectType{AttrTypes: redirectRuleTypes}, tfRules) + if diags.HasError() { + return core.DiagsToError(diags) + } + + var objDiags diag.Diagnostics + redirectsVal, objDiags = types.ObjectValue(redirectsTypes, map[string]attr.Value{ + "rules": tfRulesList, + }) + if objDiags.HasError() { + return core.DiagsToError(objDiags) + } + } + // blockedCountries var blockedCountries []attr.Value if distribution.Config != nil && distribution.Config.BlockedCountries != nil { @@ -910,6 +1180,7 @@ func mapFields(ctx context.Context, distribution *cdn.Distribution, model *Model "regions": modelRegions, "blocked_countries": modelBlockedCountries, "optimizer": optimizerVal, + "redirects": redirectsVal, }) if diags.HasError() { return core.DiagsToError(diags) @@ -1014,6 +1285,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*cdn.CreateDistribution Backend: backend, BlockedCountries: cfg.BlockedCountries, Optimizer: optimizer, + Redirects: cfg.Redirects, } return payload, nil @@ -1023,6 +1295,7 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) { if model == nil { return nil, errors.New("model cannot be nil") } + if model.Config.IsNull() || model.Config.IsUnknown() { return nil, errors.New("config cannot be nil or unknown") } @@ -1057,6 +1330,53 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) { } } + // redirects + var redirectsConfig *cdn.RedirectConfig + + if configModel.Redirects != nil { + sdkRules := []cdn.RedirectRule{} + + if len(configModel.Redirects.Rules) > 0 { + for _, rule := range configModel.Redirects.Rules { + matchers := []cdn.Matcher{} + for _, matcher := range rule.Matchers { + var matchCond *cdn.MatchCondition + if matcher.ValueMatchCondition != nil { + cond := cdn.MatchCondition(*matcher.ValueMatchCondition) + matchCond = &cond + } + + matchers = append(matchers, cdn.Matcher{ + Values: &matcher.Values, + ValueMatchCondition: matchCond, + }) + } + + var ruleMatchCond *cdn.MatchCondition + if rule.RuleMatchCondition != nil { + cond := cdn.MatchCondition(*rule.RuleMatchCondition) + ruleMatchCond = &cond + } + + statusCode := cdn.RedirectRuleStatusCode(rule.StatusCode) + targerUrl := rule.TargetUrl + + sdkConfigRule := cdn.RedirectRule{ + Description: rule.Description, + Enabled: rule.Enabled, + Matchers: &matchers, + RuleMatchCondition: ruleMatchCond, + StatusCode: &statusCode, + TargetUrl: &targerUrl, + } + sdkRules = append(sdkRules, sdkConfigRule) + } + } + redirectsConfig = &cdn.RedirectConfig{ + Rules: &sdkRules, + } + } + // geofencing geofencing := map[string][]string{} if configModel.Backend.Geofencing != nil { @@ -1080,6 +1400,7 @@ func convertConfig(ctx context.Context, model *Model) (*cdn.Config, error) { Backend: &cdn.ConfigBackend{}, Regions: ®ions, BlockedCountries: &blockedCountries, + Redirects: redirectsConfig, } if configModel.Backend.Type == "http" { diff --git a/stackit/internal/services/cdn/distribution/resource_test.go b/stackit/internal/services/cdn/distribution/resource_test.go index 9c4cda8c3..fe859f0b1 100644 --- a/stackit/internal/services/cdn/distribution/resource_test.go +++ b/stackit/internal/services/cdn/distribution/resource_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/stackit-sdk-go/services/cdn" ) @@ -40,12 +41,40 @@ func TestToCreatePayload(t *testing.T) { optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ "enabled": types.BoolValue(true), }) + + redirectsAttrTypes := configTypes["redirects"].(basetypes.ObjectType).AttrTypes + config := types.ObjectValueMust(configTypes, map[string]attr.Value{ "backend": backend, "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), + }) + + matcherValues := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("/shop/*"), + }) + matcherVal := types.ObjectValueMust(matcherTypes, map[string]attr.Value{ + "values": matcherValues, + "value_match_condition": types.StringValue("ANY"), + }) + matchersList := types.ListValueMust(types.ObjectType{AttrTypes: matcherTypes}, []attr.Value{matcherVal}) + + ruleVal := types.ObjectValueMust(redirectRuleTypes, map[string]attr.Value{ + "description": types.StringValue("Test redirect"), + "enabled": types.BoolValue(true), + "target_url": types.StringValue("https://example.com/redirect"), + "status_code": types.Int32Value(301), + "rule_match_condition": types.StringValue("ANY"), + "matchers": matchersList, }) + rulesList := types.ListValueMust(types.ObjectType{AttrTypes: redirectRuleTypes}, []attr.Value{ruleVal}) + + redirectsConfigVal := types.ObjectValueMust(redirectsTypes, map[string]attr.Value{ + "rules": rulesList, + }) + modelFixture := func(mods ...func(*Model)) *Model { model := &Model{ DistributionId: types.StringValue("test-distribution-id"), @@ -85,6 +114,7 @@ func TestToCreatePayload(t *testing.T) { "regions": regionsFixture, "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Expected: &cdn.CreateDistributionPayload{ @@ -102,6 +132,47 @@ func TestToCreatePayload(t *testing.T) { }, IsValid: true, }, + "happy_path_with_redirects": { + Input: modelFixture(func(m *Model) { + m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": redirectsConfigVal, + }) + }), + Expected: &cdn.CreateDistributionPayload{ + Regions: &[]cdn.Region{"EU", "US"}, + BlockedCountries: &[]string{"XX", "YY", "ZZ"}, + Backend: &cdn.CreateDistributionPayloadBackend{ + HttpBackendCreate: &cdn.HttpBackendCreate{ + Geofencing: &map[string][]string{"https://de.mycoolapp.com": {"DE", "FR"}}, + OriginRequestHeaders: &map[string]string{"testHeader0": "testHeaderValue0", "testHeader1": "testHeaderValue1"}, + OriginUrl: cdn.PtrString("https://www.mycoolapp.com"), + Type: cdn.PtrString("http"), + }, + }, + Redirects: &cdn.RedirectConfig{ + Rules: &[]cdn.RedirectRule{ + { + Description: cdn.PtrString("Test redirect"), + Enabled: cdn.PtrBool(true), + TargetUrl: cdn.PtrString("https://example.com/redirect"), + StatusCode: cdn.RedirectRuleStatusCode(301).Ptr(), + RuleMatchCondition: cdn.MatchCondition("ANY").Ptr(), + Matchers: &[]cdn.Matcher{ + { + Values: &[]string{"/shop/*"}, + ValueMatchCondition: cdn.MatchCondition("ANY").Ptr(), + }, + }, + }, + }, + }, + }, + IsValid: true, + }, "happy_path_bucket": { Input: modelFixture(func(m *Model) { creds := types.ObjectValueMust(backendCredentialsTypes, map[string]attr.Value{ @@ -122,6 +193,7 @@ func TestToCreatePayload(t *testing.T) { "regions": regionsFixture, // reusing the existing one "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Expected: &cdn.CreateDistributionPayload{ @@ -203,12 +275,40 @@ func TestConvertConfig(t *testing.T) { blockedCountries := []attr.Value{types.StringValue("XX"), types.StringValue("YY"), types.StringValue("ZZ")} blockedCountriesFixture := types.ListValueMust(types.StringType, blockedCountries) optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{"enabled": types.BoolValue(true)}) + + redirectsAttrTypes := configTypes["redirects"].(basetypes.ObjectType).AttrTypes + config := types.ObjectValueMust(configTypes, map[string]attr.Value{ "backend": backend, "regions": regionsFixture, "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), + }) + + matcherValues := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("/shop/*"), + }) + matcherVal := types.ObjectValueMust(matcherTypes, map[string]attr.Value{ + "values": matcherValues, + "value_match_condition": types.StringValue("ANY"), }) + matchersList := types.ListValueMust(types.ObjectType{AttrTypes: matcherTypes}, []attr.Value{matcherVal}) + + ruleVal := types.ObjectValueMust(redirectRuleTypes, map[string]attr.Value{ + "description": types.StringValue("Test redirect"), + "enabled": types.BoolValue(true), + "target_url": types.StringValue("https://example.com/redirect"), + "status_code": types.Int32Value(301), + "rule_match_condition": types.StringValue("ANY"), + "matchers": matchersList, + }) + rulesList := types.ListValueMust(types.ObjectType{AttrTypes: redirectRuleTypes}, []attr.Value{ruleVal}) + + redirectsConfigVal := types.ObjectValueMust(redirectsTypes, map[string]attr.Value{ + "rules": rulesList, + }) + modelFixture := func(mods ...func(*Model)) *Model { model := &Model{ DistributionId: types.StringValue("test-distribution-id"), @@ -220,6 +320,7 @@ func TestConvertConfig(t *testing.T) { } return model } + tests := map[string]struct { Input *Model Expected *cdn.Config @@ -253,6 +354,7 @@ func TestConvertConfig(t *testing.T) { "regions": regionsFixture, "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Expected: &cdn.Config{ @@ -275,6 +377,52 @@ func TestConvertConfig(t *testing.T) { }, IsValid: true, }, + "happy_path_with_redirects": { + Input: modelFixture(func(m *Model) { + m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": redirectsConfigVal, // Injetando o mock aqui + }) + }), + Expected: &cdn.Config{ + Backend: &cdn.ConfigBackend{ + HttpBackend: &cdn.HttpBackend{ + OriginRequestHeaders: &map[string]string{ + "testHeader0": "testHeaderValue0", + "testHeader1": "testHeaderValue1", + }, + OriginUrl: cdn.PtrString("https://www.mycoolapp.com"), + Type: cdn.PtrString("http"), + Geofencing: &map[string][]string{ + "https://de.mycoolapp.com": {"DE", "FR"}, + }, + }, + }, + Regions: &[]cdn.Region{"EU", "US"}, + BlockedCountries: &[]string{"XX", "YY", "ZZ"}, + Redirects: &cdn.RedirectConfig{ + Rules: &[]cdn.RedirectRule{ + { + Description: cdn.PtrString("Test redirect"), + Enabled: cdn.PtrBool(true), + TargetUrl: cdn.PtrString("https://example.com/redirect"), + StatusCode: cdn.RedirectRuleStatusCode(301).Ptr(), + RuleMatchCondition: cdn.MatchCondition("ANY").Ptr(), + Matchers: &[]cdn.Matcher{ + { + Values: &[]string{"/shop/*"}, + ValueMatchCondition: cdn.MatchCondition("ANY").Ptr(), + }, + }, + }, + }, + }, + }, + IsValid: true, + }, "happy_path_bucket": { Input: modelFixture(func(m *Model) { creds := types.ObjectValueMust(backendCredentialsTypes, map[string]attr.Value{ @@ -295,6 +443,7 @@ func TestConvertConfig(t *testing.T) { "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Expected: &cdn.Config{ @@ -303,8 +452,6 @@ func TestConvertConfig(t *testing.T) { Type: cdn.PtrString("bucket"), BucketUrl: cdn.PtrString("https://s3.example.com"), Region: cdn.PtrString("eu01"), - // Note: config does not return credentials - }, }, Regions: &[]cdn.Region{"EU", "US"}, @@ -325,6 +472,7 @@ func TestConvertConfig(t *testing.T) { IsValid: false, }, } + for tn, tc := range tests { t.Run(tn, func(t *testing.T) { res, err := convertConfig(context.Background(), tc.Input) @@ -373,11 +521,56 @@ func TestMapFields(t *testing.T) { optimizer := types.ObjectValueMust(optimizerTypes, map[string]attr.Value{ "enabled": types.BoolValue(true), }) + + redirectsAttrTypes := configTypes["redirects"].(basetypes.ObjectType).AttrTypes + config := types.ObjectValueMust(configTypes, map[string]attr.Value{ "backend": backend, "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), + }) + + redirectsInput := &cdn.RedirectConfig{ + Rules: &[]cdn.RedirectRule{ + { + Description: cdn.PtrString("Test redirect"), + Enabled: cdn.PtrBool(true), + TargetUrl: cdn.PtrString("https://example.com/redirect"), + StatusCode: cdn.RedirectRuleStatusCode(301).Ptr(), + RuleMatchCondition: cdn.MatchCondition("ANY").Ptr(), + Matchers: &[]cdn.Matcher{ + { + Values: &[]string{"/shop/*"}, + ValueMatchCondition: cdn.MatchCondition("ANY").Ptr(), + }, + }, + }, + }, + } + + matcherValuesExpected := types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("/shop/*"), + }) + matcherValExpected := types.ObjectValueMust(matcherTypes, map[string]attr.Value{ + "values": matcherValuesExpected, + "value_match_condition": types.StringValue("ANY"), + }) + matchersListExpected := types.ListValueMust(types.ObjectType{AttrTypes: matcherTypes}, []attr.Value{matcherValExpected}) + + ruleValExpected := types.ObjectValueMust(redirectRuleTypes, map[string]attr.Value{ + "description": types.StringValue("Test redirect"), + "enabled": types.BoolValue(true), + "target_url": types.StringValue("https://example.com/redirect"), + "status_code": types.Int32Value(301), + "rule_match_condition": types.StringValue("ANY"), + "matchers": matchersListExpected, + }) + rulesListExpected := types.ListValueMust(types.ObjectType{AttrTypes: redirectRuleTypes}, []attr.Value{ruleValExpected}) + + redirectsConfigExpected := types.ObjectValueMust(redirectsTypes, map[string]attr.Value{ + "rules": rulesListExpected, }) emtpyErrorsList := types.ListValueMust(types.StringType, []attr.Value{}) @@ -459,6 +652,7 @@ func TestMapFields(t *testing.T) { "regions": regionsFixture, "blocked_countries": blockedCountriesFixture, "optimizer": types.ObjectNull(optimizerTypes), + "redirects": types.ObjectNull(redirectsAttrTypes), }) tests := map[string]struct { Input *cdn.Distribution @@ -478,6 +672,7 @@ func TestMapFields(t *testing.T) { "regions": regionsFixture, "optimizer": optimizer, "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Input: distributionFixture(func(d *cdn.Distribution) { @@ -503,6 +698,7 @@ func TestMapFields(t *testing.T) { "regions": regionsFixture, "optimizer": types.ObjectNull(optimizerTypes), "blocked_countries": blockedCountriesFixture, + "redirects": types.ObjectNull(redirectsAttrTypes), }) }), Input: distributionFixture(func(d *cdn.Distribution) { @@ -510,6 +706,21 @@ func TestMapFields(t *testing.T) { }), IsValid: true, }, + "happy_path_with_redirects": { + Expected: expectedModel(func(m *Model) { + m.Config = types.ObjectValueMust(configTypes, map[string]attr.Value{ + "backend": backend, + "regions": regionsFixture, + "optimizer": types.ObjectNull(optimizerTypes), + "blocked_countries": blockedCountriesFixture, + "redirects": redirectsConfigExpected, + }) + }), + Input: distributionFixture(func(d *cdn.Distribution) { + d.Config.Redirects = redirectsInput + }), + IsValid: true, + }, "happy_path_status_error": { Expected: expectedModel(func(m *Model) { m.Status = types.StringValue("ERROR")