feat: add locale setting resource support with unifi_setting_locale resource (#34)

* feat: add locale setting resource support with `unifi_setting_locale` resource

* lint
This commit is contained in:
Mateusz Filipowicz
2025-03-01 18:03:58 +01:00
committed by GitHub
parent 273d0daddd
commit f815ffef79
7 changed files with 563 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
package acctest
import (
"fmt"
pt "github.com/filipowm/terraform-provider-unifi/internal/provider/testing"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/plancheck"
"regexp"
"sync"
"testing"
)
var settingLocaleLock = &sync.Mutex{}
func TestAccSettingLocale(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 7.3",
Lock: settingLocaleLock,
Steps: []resource.TestStep{
{
Config: testAccSettingLocaleConfig("America/New_York"),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_locale.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_locale.test", "site", "default"),
resource.TestCheckResourceAttr("unifi_setting_locale.test", "timezone", "America/New_York"),
),
ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_locale.test", plancheck.ResourceActionCreate),
},
pt.ImportStepWithSite("unifi_setting_locale.test"),
{
Config: testAccSettingLocaleConfig("Europe/London"),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_locale.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_locale.test", "site", "default"),
resource.TestCheckResourceAttr("unifi_setting_locale.test", "timezone", "Europe/London"),
),
ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_locale.test", plancheck.ResourceActionUpdate),
},
{
Config: testAccSettingLocaleConfig("UTC"),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_locale.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_locale.test", "site", "default"),
resource.TestCheckResourceAttr("unifi_setting_locale.test", "timezone", "UTC"),
),
ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_locale.test", plancheck.ResourceActionUpdate),
},
},
})
}
func TestAccSettingLocaleInvalid(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 7.3",
Lock: settingLocaleLock,
Steps: []resource.TestStep{
{
Config: testAccSettingLocaleConfig("Invalid/Timezone"),
ExpectError: regexp.MustCompile("must be a valid IANA timezone identifier"),
},
},
})
}
func testAccSettingLocaleConfig(timezone string) string {
return fmt.Sprintf(`
resource "unifi_setting_locale" "test" {
timezone = %q
}
`, timezone)
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/filipowm/terraform-provider-unifi/internal/provider/dns"
"github.com/filipowm/terraform-provider-unifi/internal/provider/settings"
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
"github.com/filipowm/terraform-provider-unifi/internal/utils"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/datasource"
@@ -25,6 +26,10 @@ func NewV2(version string) func() provider.Provider {
}
}
var (
_ provider.Provider = &unifiProvider{}
)
type unifiProvider struct {
version string
}
@@ -64,6 +69,7 @@ func (p *unifiProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp
MarkdownDescription: ProviderAPIURLDescription,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1), // workaround for `required: true`, because it fails on doc generation due to incorrectly detected difference between v1 and v2
validators.HTTPSUrl(),
},
Optional: true,
},
@@ -170,6 +176,7 @@ func (p *unifiProvider) Resources(_ context.Context) []func() resource.Resource
dns.NewDnsRecordResource,
settings.NewAutoSpeedtestResource,
settings.NewCountryResource,
settings.NewLocaleResource,
}
}

View File

@@ -0,0 +1,88 @@
package settings
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/filipowm/go-unifi/unifi"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
)
type localeModel struct {
base.Model
Timezone types.String `tfsdk:"timezone"`
}
func (d *localeModel) AsUnifiModel() (interface{}, diag.Diagnostics) {
diags := diag.Diagnostics{}
model := &unifi.SettingLocale{
ID: d.ID.ValueString(),
Timezone: d.Timezone.ValueString(),
}
return model, diags
}
func (d *localeModel) Merge(other interface{}) diag.Diagnostics {
diags := diag.Diagnostics{}
model, ok := other.(*unifi.SettingLocale)
if !ok {
diags.AddError("Cannot merge", "Cannot merge type that is not *unifi.SettingLocale")
return diags
}
d.ID = types.StringValue(model.ID)
d.Timezone = types.StringValue(model.Timezone)
return diags
}
var (
_ base.ResourceModel = &localeModel{}
_ resource.Resource = &localeResource{}
_ resource.ResourceWithConfigure = &localeResource{}
_ resource.ResourceWithImportState = &localeResource{}
)
type localeResource struct {
*BaseSettingResource[*localeModel]
}
func (r *localeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Manages locale settings for a UniFi site.",
Attributes: map[string]schema.Attribute{
"id": base.ID(),
"site": base.SiteAttribute(),
"timezone": schema.StringAttribute{
Required: true,
MarkdownDescription: "Timezone for the UniFi controller, e.g., `America/Los_Angeles`",
Validators: []validator.String{
validators.Timezone(),
},
},
},
}
}
func NewLocaleResource() resource.Resource {
r := &localeResource{}
r.BaseSettingResource = NewBaseSettingResource(
"unifi_setting_locale",
func() *localeModel { return &localeModel{} },
func(ctx context.Context, client *base.Client, site string) (interface{}, error) {
return client.GetSettingLocale(ctx, site)
},
func(ctx context.Context, client *base.Client, site string, body interface{}) (interface{}, error) {
return client.UpdateSettingLocale(ctx, site, body.(*unifi.SettingLocale))
},
)
return r
}

View File

@@ -0,0 +1,93 @@
package validators
import (
"context"
"fmt"
"strings"
"time"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
)
// Timezone returns a validator which ensures that the string value is a valid IANA timezone identifier
// according to the time.LoadLocation function.
func Timezone() validator.String {
return timezoneValidator{}
}
type timezoneValidator struct{}
func (v timezoneValidator) Description(_ context.Context) string {
return "must be a valid IANA timezone identifier (e.g., 'America/New_York')"
}
func (v timezoneValidator) MarkdownDescription(ctx context.Context) string {
return v.Description(ctx)
}
func (v timezoneValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
value := req.ConfigValue
if !base.IsDefined(value) {
return
}
val := value.ValueString()
// Check for empty string
if val == "" {
resp.Diagnostics.Append(
validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
v.Description(ctx),
"Timezone cannot be empty. Use a valid IANA timezone identifier like 'America/New_York'",
),
)
return
}
// Check for proper case (IANA timezone identifiers are case-sensitive)
// Regions should start with uppercase
if val[0] >= 'a' && val[0] <= 'z' {
resp.Diagnostics.Append(
validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
v.Description(ctx),
fmt.Sprintf("%q has incorrect case. IANA timezone regions should start with uppercase (e.g., 'America/New_York')", val),
),
)
return
}
// Try to load the timezone location
_, err := time.LoadLocation(val)
if err != nil {
// For better error messages, check common mistakes
if strings.Contains(val, "UTC") && val != "UTC" {
resp.Diagnostics.Append(
validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
v.Description(ctx),
fmt.Sprintf("%q is not a valid timezone. For UTC offset use the standard 'UTC' timezone instead.", val),
),
)
} else if strings.Contains(val, " ") {
resp.Diagnostics.Append(
validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
v.Description(ctx),
fmt.Sprintf("%q is not a valid timezone. Timezones should not contain spaces.", val),
),
)
} else {
resp.Diagnostics.Append(
validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
v.Description(ctx),
fmt.Sprintf("%q is not a valid IANA timezone identifier. Use a value like 'America/New_York'", val),
),
)
}
}
}

View File

@@ -0,0 +1,85 @@
package validators
import (
"context"
"testing"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
)
func TestTimezoneValidator(t *testing.T) {
t.Parallel()
type testCase struct {
val types.String
expectError bool
}
tests := map[string]testCase{
"unknown": {
val: types.StringUnknown(),
},
"null": {
val: types.StringNull(),
},
"valid-america": {
val: types.StringValue("America/Los_Angeles"),
},
"valid-europe": {
val: types.StringValue("Europe/London"),
},
"valid-asia": {
val: types.StringValue("Asia/Tokyo"),
},
"valid-australia": {
val: types.StringValue("Australia/Sydney"),
},
"valid-utc": {
val: types.StringValue("UTC"),
},
"invalid-with-space": {
val: types.StringValue("America/New York"),
expectError: true,
},
"invalid-nonexistent": {
val: types.StringValue("NonExistent/Timezone"),
expectError: true,
},
"invalid-empty-string": {
val: types.StringValue(""),
expectError: true,
},
"invalid-just-region": {
val: types.StringValue("America"),
expectError: true,
},
"invalid-lowercase": {
val: types.StringValue("america/los_angeles"),
expectError: true,
},
"invalid-utc-offset": {
val: types.StringValue("UTC+01:00"),
expectError: true,
},
}
for name, test := range tests {
name, test := name, test
t.Run(name, func(t *testing.T) {
t.Parallel()
request := validator.StringRequest{
ConfigValue: test.val,
}
response := validator.StringResponse{}
Timezone().ValidateString(context.Background(), request, &response)
if !response.Diagnostics.HasError() && test.expectError {
t.Fatal("expected error, got no error")
}
if response.Diagnostics.HasError() && !test.expectError {
t.Fatalf("got unexpected error: %s", response.Diagnostics)
}
})
}
}

View File

@@ -0,0 +1,92 @@
package validators
import (
"context"
"fmt"
"net/url"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
)
// URL returns a validator which ensures that the string value is a valid URL.
func URL() validator.String {
return urlValidator{requireHTTPS: false}
}
// HTTPSUrl returns a validator which ensures that the string value is a valid HTTPS URL.
func HTTPSUrl() validator.String {
return urlValidator{requireHTTPS: true}
}
type urlValidator struct {
requireHTTPS bool
}
func (v urlValidator) Description(_ context.Context) string {
if v.requireHTTPS {
return "must be a valid HTTPS URL"
}
return "must be a valid URL"
}
func (v urlValidator) MarkdownDescription(ctx context.Context) string {
return v.Description(ctx)
}
func (v urlValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
value := req.ConfigValue
if !base.IsDefined(value) {
return
}
val := value.ValueString()
parsedURL, err := url.Parse(val)
if err != nil {
resp.Diagnostics.Append(
validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
v.Description(ctx),
fmt.Sprintf("%q is not a valid URL: %s", val, err),
),
)
return
}
// Check if URL has a scheme
if parsedURL.Scheme == "" {
resp.Diagnostics.Append(
validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
v.Description(ctx),
fmt.Sprintf("%q is missing a scheme (e.g., http:// or https://)", val),
),
)
return
}
// Check if HTTPS is required
if v.requireHTTPS && parsedURL.Scheme != "https" {
resp.Diagnostics.Append(
validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
v.Description(ctx),
fmt.Sprintf("%q must use HTTPS scheme", val),
),
)
return
}
// Check if URL has a host
if parsedURL.Host == "" {
resp.Diagnostics.Append(
validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
v.Description(ctx),
fmt.Sprintf("%q is missing a host", val),
),
)
}
}

View File

@@ -0,0 +1,128 @@
package validators
import (
"context"
"testing"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
)
func TestURLValidator(t *testing.T) {
t.Parallel()
type testCase struct {
val types.String
expectError bool
}
tests := map[string]testCase{
"unknown": {
val: types.StringUnknown(),
},
"null": {
val: types.StringNull(),
},
"valid-http": {
val: types.StringValue("http://example.com"),
},
"valid-https": {
val: types.StringValue("https://example.com"),
},
"valid-with-path": {
val: types.StringValue("https://example.com/path"),
},
"valid-with-query": {
val: types.StringValue("https://example.com/path?query=value"),
},
"valid-with-port": {
val: types.StringValue("https://example.com:8443"),
},
"invalid-no-scheme": {
val: types.StringValue("example.com"),
expectError: true,
},
"invalid-no-host": {
val: types.StringValue("https://"),
expectError: true,
},
"invalid-malformed": {
val: types.StringValue("htt ps://example.com"),
expectError: true,
},
}
for name, test := range tests {
name, test := name, test
t.Run(name, func(t *testing.T) {
t.Parallel()
request := validator.StringRequest{
ConfigValue: test.val,
}
response := validator.StringResponse{}
URL().ValidateString(context.Background(), request, &response)
if !response.Diagnostics.HasError() && test.expectError {
t.Fatal("expected error, got no error")
}
if response.Diagnostics.HasError() && !test.expectError {
t.Fatalf("got unexpected error: %s", response.Diagnostics)
}
})
}
}
func TestHTTPSURLValidator(t *testing.T) {
t.Parallel()
type testCase struct {
val types.String
expectError bool
}
tests := map[string]testCase{
"unknown": {
val: types.StringUnknown(),
},
"null": {
val: types.StringNull(),
},
"valid-https": {
val: types.StringValue("https://example.com"),
},
"valid-with-path": {
val: types.StringValue("https://example.com/path"),
},
"invalid-http": {
val: types.StringValue("http://example.com"),
expectError: true,
},
"invalid-no-scheme": {
val: types.StringValue("example.com"),
expectError: true,
},
"invalid-no-host": {
val: types.StringValue("https://"),
expectError: true,
},
}
for name, test := range tests {
name, test := name, test
t.Run(name, func(t *testing.T) {
t.Parallel()
request := validator.StringRequest{
ConfigValue: test.val,
}
response := validator.StringResponse{}
HTTPSUrl().ValidateString(context.Background(), request, &response)
if !response.Diagnostics.HasError() && test.expectError {
t.Fatal("expected error, got no error")
}
if response.Diagnostics.HasError() && !test.expectError {
t.Fatalf("got unexpected error: %s", response.Diagnostics)
}
})
}
}