From 2fc4fb37d2eedda4b6a66181f03ee8f17035ff65 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Thu, 16 Oct 2025 17:36:40 +0800 Subject: [PATCH 1/8] tag filter support --- .../azureappconfiguration.go | 15 +-- .../azureappconfiguration_test.go | 4 +- azureappconfiguration/options.go | 32 ++++++ azureappconfiguration/settings_client.go | 9 +- azureappconfiguration/utils.go | 29 ++++- azureappconfiguration/utils_test.go | 108 ++++++++++++++++++ 6 files changed, 182 insertions(+), 15 deletions(-) diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index f34886e..d1ce6f9 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -55,8 +55,8 @@ type AzureAppConfiguration struct { // Settings used for refresh scenarios sentinelETags map[WatchedSetting]*azcore.ETag watchAll bool - kvETags map[Selector][]*azcore.ETag - ffETags map[Selector][]*azcore.ETag + kvETags map[selectorKey][]*azcore.ETag + ffETags map[selectorKey][]*azcore.ETag keyVaultRefs map[string]string // unversioned Key Vault references kvRefreshTimer refresh.Condition secretRefreshTimer refresh.Condition @@ -121,7 +121,7 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg.kvRefreshTimer = refresh.NewTimer(options.RefreshOptions.Interval) azappcfg.watchedSettings = normalizedWatchedSettings(options.RefreshOptions.WatchedSettings) azappcfg.sentinelETags = make(map[WatchedSetting]*azcore.ETag) - azappcfg.kvETags = make(map[Selector][]*azcore.ETag) + azappcfg.kvETags = make(map[selectorKey][]*azcore.ETag) if len(options.RefreshOptions.WatchedSettings) == 0 { azappcfg.watchAll = true } @@ -137,7 +137,7 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg.ffSelectors = getFeatureFlagSelectors(deduplicateSelectors(options.FeatureFlagOptions.Selectors)) if options.FeatureFlagOptions.RefreshOptions.Enabled { azappcfg.ffRefreshTimer = refresh.NewTimer(options.FeatureFlagOptions.RefreshOptions.Interval) - azappcfg.ffETags = make(map[Selector][]*azcore.ETag) + azappcfg.ffETags = make(map[selectorKey][]*azcore.ETag) } } @@ -759,7 +759,7 @@ func deduplicateSelectors(selectors []Selector) []Selector { } // Create a map to track unique selectors - seen := make(map[Selector]struct{}) + seen := make(map[selectorKey]struct{}) var result []Selector // Process the selectors in reverse order to maintain the behavior @@ -771,8 +771,9 @@ func deduplicateSelectors(selectors []Selector) []Selector { } // Check if we've seen this selector before - if _, exists := seen[selectors[i]]; !exists { - seen[selectors[i]] = struct{}{} + key := selectors[i].comparableKey() + if _, exists := seen[key]; !exists { + seen[key] = struct{}{} result = append(result, selectors[i]) } } diff --git a/azureappconfiguration/azureappconfiguration_test.go b/azureappconfiguration/azureappconfiguration_test.go index f68d0ba..5b55c0f 100644 --- a/azureappconfiguration/azureappconfiguration_test.go +++ b/azureappconfiguration/azureappconfiguration_test.go @@ -128,7 +128,7 @@ func TestLoadFeatureFlags_Success(t *testing.T) { {Key: toPtr(".appconfig.featureflag/Beta"), Value: &value1, ContentType: toPtr(featureFlagContentType)}, {Key: toPtr(".appconfig.featureflag/Alpha"), Value: &value2, ContentType: toPtr(featureFlagContentType)}, }, - pageETags: map[Selector][]*azcore.ETag{}, + pageETags: map[selectorKey][]*azcore.ETag{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) @@ -1545,7 +1545,7 @@ func TestLoadFeatureFlags_TracingUpdated(t *testing.T) { ContentType: toPtr(featureFlagContentType), }, }, - pageETags: map[Selector][]*azcore.ETag{}, + pageETags: map[selectorKey][]*azcore.ETag{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index d1f0533..ac71541 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -6,6 +6,8 @@ package azureappconfiguration import ( "context" "net/url" + "sort" + "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -79,6 +81,36 @@ type Selector struct { // SnapshotName specifies the name of the snapshot to retrieve. // If SnapshotName is used in a selector, no key and label filter should be used for it. Otherwise, an error will be returned. SnapshotName string + + // TagFilter specifies which tags to retrieve from Azure App Configuration. + // Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. + // Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. + TagFilter []string +} + +// comparableKey returns a comparable representation of the Selector that can be used as a map key. +// This method creates a deterministic string representation by sorting the TagFilter slice. +func (s Selector) comparableKey() selectorKey { + // Create a copy and sort the TagFilter to ensure deterministic comparison + tagFilter := make([]string, len(s.TagFilter)) + copy(tagFilter, s.TagFilter) + sort.Strings(tagFilter) + + return selectorKey{ + KeyFilter: s.KeyFilter, + LabelFilter: s.LabelFilter, + SnapshotName: s.SnapshotName, + TagFilter: strings.Join(tagFilter, ","), + } +} + +// selectorKey is a comparable version of Selector that can be used as a map key. +// It represents the same selector information but with TagFilter as a sorted, comma-separated string. +type selectorKey struct { + KeyFilter string + LabelFilter string + SnapshotName string + TagFilter string // Sorted, comma-separated representation of the original TagFilter slice } // KeyValueRefreshOptions contains optional parameters to configure the behavior of key-value settings refresh diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index ca3bbc9..3dce25d 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -19,7 +19,7 @@ import ( type settingsResponse struct { settings []azappconfig.Setting watchedETags map[WatchedSetting]*azcore.ETag - pageETags map[Selector][]*azcore.ETag + pageETags map[selectorKey][]*azcore.ETag } type selectorSettingsClient struct { @@ -36,7 +36,7 @@ type watchedSettingClient struct { } type pageETagsClient struct { - pageETags map[Selector][]*azcore.ETag + pageETags map[selectorKey][]*azcore.ETag client *azappconfig.Client tracingOptions tracing.Options } @@ -61,12 +61,13 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp } settings := make([]azappconfig.Setting, 0) - pageETags := make(map[Selector][]*azcore.ETag) + pageETags := make(map[selectorKey][]*azcore.ETag) for _, filter := range s.selectors { if filter.SnapshotName == "" { selector := azappconfig.SettingSelector{ KeyFilter: to.Ptr(filter.KeyFilter), LabelFilter: to.Ptr(filter.LabelFilter), + // Todo: TagsFilter : filter.TagFilter Fields: azappconfig.AllSettingFields(), } @@ -82,7 +83,7 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp } } - pageETags[filter] = eTags + pageETags[filter.comparableKey()] = eTags } else { snapshot, err := s.client.GetSnapshot(ctx, filter.SnapshotName, nil) if err != nil { diff --git a/azureappconfiguration/utils.go b/azureappconfiguration/utils.go index 6bee613..d0edcc2 100644 --- a/azureappconfiguration/utils.go +++ b/azureappconfiguration/utils.go @@ -77,8 +77,8 @@ func verifyOptions(options *Options) error { func verifySelectors(selectors []Selector) error { for _, selector := range selectors { if selector.SnapshotName != "" { - if selector.KeyFilter != "" || selector.LabelFilter != "" { - return fmt.Errorf("key and label filters should not be used if snapshot name is provided") + if selector.KeyFilter != "" || selector.LabelFilter != "" || selector.TagFilter != nil { + return fmt.Errorf("key, label and tag filters should not be used if snapshot name is provided") } } else { if selector.KeyFilter == "" { @@ -88,6 +88,31 @@ func verifySelectors(selectors []Selector) error { if strings.Contains(selector.LabelFilter, "*") || strings.Contains(selector.LabelFilter, ",") { return fmt.Errorf("label filter cannot contain '*' or ','") } + + if err := validateTagFilters(selector.TagFilter); err != nil { + return err + } + } + } + + return nil +} + +// validateTagFilters validates that each tag filter follows the required format "tagName=tagValue" +// and ensures no more than 5 tag filters are provided. +func validateTagFilters(tagFilters []string) error { + if len(tagFilters) > 5 { + return fmt.Errorf("up to 5 tag filters can be provided, got %d", len(tagFilters)) + } + + for _, tagFilter := range tagFilters { + if tagFilter == "" { + return fmt.Errorf("tag filter cannot be empty") + } + + parts := strings.Split(tagFilter, "=") + if len(parts) != 2 || parts[0] == "" { + return fmt.Errorf("invalid tag filter: %s. Tag filter must follow the format \"tagName=tagValue\"", tagFilter) } } diff --git a/azureappconfiguration/utils_test.go b/azureappconfiguration/utils_test.go index 160b7f7..9009b11 100644 --- a/azureappconfiguration/utils_test.go +++ b/azureappconfiguration/utils_test.go @@ -186,6 +186,34 @@ func TestVerifySelectors(t *testing.T) { }, expectedError: true, }, + { + name: "valid tag filter", + selectors: []Selector{ + {KeyFilter: "app*", LabelFilter: "prod", TagFilter: []string{"environment=production", "team=backend"}}, + }, + expectedError: false, + }, + { + name: "invalid tag filter format", + selectors: []Selector{ + {KeyFilter: "app*", LabelFilter: "prod", TagFilter: []string{"invalid_format"}}, + }, + expectedError: true, + }, + { + name: "too many tag filters", + selectors: []Selector{ + {KeyFilter: "app*", LabelFilter: "prod", TagFilter: []string{"tag1=val1", "tag2=val2", "tag3=val3", "tag4=val4", "tag5=val5", "tag6=val6"}}, + }, + expectedError: true, + }, + { + name: "tag filter with snapshot (should fail)", + selectors: []Selector{ + {SnapshotName: "my-snapshot", TagFilter: []string{"environment=production"}}, + }, + expectedError: true, + }, } for _, test := range tests { @@ -200,6 +228,86 @@ func TestVerifySelectors(t *testing.T) { } } +func TestValidateTagFilters(t *testing.T) { + tests := []struct { + name string + tagFilters []string + expectedError bool + errorContains string + }{ + { + name: "empty tag filters", + tagFilters: []string{}, + expectedError: false, + }, + { + name: "valid single tag filter", + tagFilters: []string{"environment=production"}, + expectedError: false, + }, + { + name: "valid multiple tag filters", + tagFilters: []string{"environment=production", "team=backend", "version=1.0"}, + expectedError: false, + }, + { + name: "valid tag filter with numbers and special chars", + tagFilters: []string{"tag=value\\,with\\,commas"}, + expectedError: false, + }, + { + name: "too many tag filters (more than 5)", + tagFilters: []string{"tag1=value1", "tag2=value2", "tag3=value3", "tag4=value4", "tag5=value5", "tag6=value6"}, + expectedError: true, + errorContains: "up to 5 tag filters can be provided", + }, + { + name: "empty tag filter string", + tagFilters: []string{""}, + expectedError: true, + errorContains: "tag filter cannot be empty", + }, + { + name: "invalid format - no equals sign", + tagFilters: []string{"environmentproduction"}, + expectedError: true, + errorContains: "Tag filter must follow the format", + }, + { + name: "invalid format - empty tag name", + tagFilters: []string{"=production"}, + expectedError: true, + errorContains: "Tag filter must follow the format", + }, + { + name: "invalid format - only equals sign", + tagFilters: []string{"="}, + expectedError: true, + errorContains: "Tag filter must follow the format", + }, + { + name: "mixed valid and invalid tag filters", + tagFilters: []string{"environment=production", "invalid_format"}, + expectedError: true, + errorContains: "Tag filter must follow the format", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateTagFilters(test.tagFilters) + if test.expectedError { + assert.Error(t, err) + if test.errorContains != "" { + assert.Contains(t, err.Error(), test.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + func TestReverse(t *testing.T) { tests := []struct { name string From 5dbb58b653726f49a2056424352191cdaf1cce94 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Mon, 20 Oct 2025 17:06:57 +0800 Subject: [PATCH 2/8] update dependency --- .../azureappconfiguration.go | 2 +- .../azureappconfiguration_test.go | 145 +++++++++++++++++- azureappconfiguration/client_manager.go | 2 +- azureappconfiguration/failover_test.go | 2 +- azureappconfiguration/go.mod | 2 +- azureappconfiguration/go.sum | 4 +- .../internal/tracing/tracing.go | 4 +- azureappconfiguration/options.go | 2 +- azureappconfiguration/refresh_test.go | 2 +- azureappconfiguration/settings_client.go | 8 +- azureappconfiguration/snapshot_test.go | 2 +- 11 files changed, 157 insertions(+), 18 deletions(-) diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index d1ce6f9..ce33b5a 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -33,7 +33,7 @@ import ( "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tree" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" decoder "github.com/go-viper/mapstructure/v2" "golang.org/x/sync/errgroup" ) diff --git a/azureappconfiguration/azureappconfiguration_test.go b/azureappconfiguration/azureappconfiguration_test.go index 5b55c0f..bb534cd 100644 --- a/azureappconfiguration/azureappconfiguration_test.go +++ b/azureappconfiguration/azureappconfiguration_test.go @@ -16,7 +16,7 @@ import ( "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/fm" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -1601,3 +1601,146 @@ func TestLoadFeatureFlags_TracingUpdated(t *testing.T) { // Verify max variants is included assert.Contains(t, correlationCtx, tracing.FFMaxVariantsKey+"=3") } + +func TestLoadKeyValues_WithTagFilter(t *testing.T) { + ctx := context.Background() + mockClient := new(mockSettingsClient) + + // Create mock settings with different tags + value1 := "value1" + value3 := "value3" + value4 := "value4" + + mockResponse := &settingsResponse{ + settings: []azappconfig.Setting{ + { + Key: toPtr("app:key1"), + Value: &value1, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("backend"), + }, + }, + { + Key: toPtr("app:key3"), + Value: &value3, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("frontend"), + }, + }, + { + Key: toPtr("app:key4"), + Value: &value4, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("backend"), + "feature": toPtr("new"), + }, + }, + }, + pageETags: map[selectorKey][]*azcore.ETag{}, + } + + mockClient.On("getSettings", ctx).Return(mockResponse, nil) + + // Test with single tag filter + azappcfg := &AzureAppConfiguration{ + clientManager: &configurationClientManager{ + staticClient: &configurationClientWrapper{client: &azappconfig.Client{}}, + }, + kvSelectors: []Selector{ + { + KeyFilter: "*", + TagFilter: []string{"env=production"}, + }, + }, + keyValues: make(map[string]any), + } + + err := azappcfg.loadKeyValues(ctx, mockClient) + assert.NoError(t, err) + + // Should load keys with env=production tag (key1, key3, key4) + assert.Equal(t, &value1, azappcfg.keyValues["app:key1"]) + assert.Equal(t, &value3, azappcfg.keyValues["app:key3"]) + assert.Equal(t, &value4, azappcfg.keyValues["app:key4"]) + assert.NotContains(t, azappcfg.keyValues, "app:key2") // staging env, should be filtered out +} + +func TestLoadKeyValues_WithMultipleTagFilters(t *testing.T) { + ctx := context.Background() + mockClient := new(mockSettingsClient) + + value1 := "value1" + value4 := "value4" + + mockResponse := &settingsResponse{ + settings: []azappconfig.Setting{ + { + Key: toPtr("app:key1"), + Value: &value1, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("backend"), + }, + }, + { + Key: toPtr("app:key4"), + Value: &value4, + Tags: map[string]*string{ + "env": toPtr("production"), + "team": toPtr("backend"), + "feature": toPtr("new"), + }, + }, + }, + pageETags: map[selectorKey][]*azcore.ETag{}, + } + + mockClient.On("getSettings", ctx).Return(mockResponse, nil) + + // Test with multiple tag filters (must match ALL) + azappcfg := &AzureAppConfiguration{ + clientManager: &configurationClientManager{ + staticClient: &configurationClientWrapper{client: &azappconfig.Client{}}, + }, + kvSelectors: []Selector{ + { + KeyFilter: "*", + TagFilter: []string{"env=production", "team=backend"}, + }, + }, + keyValues: make(map[string]any), + } + + err := azappcfg.loadKeyValues(ctx, mockClient) + assert.NoError(t, err) + + // Should load only keys that match BOTH env=production AND team=backend (key1, key4) + assert.Equal(t, &value1, azappcfg.keyValues["app:key1"]) + assert.Equal(t, &value4, azappcfg.keyValues["app:key4"]) +} + +func TestSelectorComparableKey_WithTagFilter(t *testing.T) { + // Test that selectors with same TagFilter (but different order) produce the same comparable key + selector1 := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilter: []string{"env=production", "team=backend"}, + } + + selector2 := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilter: []string{"team=backend", "env=production"}, // Different order + } + + key1 := selector1.comparableKey() + key2 := selector2.comparableKey() + + // Should produce the same comparable key due to sorting + assert.Equal(t, key1, key2) + assert.Equal(t, "env=production,team=backend", key1.TagFilter) + assert.Equal(t, "env=production,team=backend", key2.TagFilter) +} diff --git a/azureappconfiguration/client_manager.go b/azureappconfiguration/client_manager.go index 1b125fc..4ffdb97 100644 --- a/azureappconfiguration/client_manager.go +++ b/azureappconfiguration/client_manager.go @@ -17,7 +17,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" ) // configurationClientManager handles creation and management of app configuration clients diff --git a/azureappconfiguration/failover_test.go b/azureappconfiguration/failover_test.go index 2b06e05..c389cc5 100644 --- a/azureappconfiguration/failover_test.go +++ b/azureappconfiguration/failover_test.go @@ -14,7 +14,7 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) diff --git a/azureappconfiguration/go.mod b/azureappconfiguration/go.mod index 260945b..def7c87 100644 --- a/azureappconfiguration/go.mod +++ b/azureappconfiguration/go.mod @@ -2,7 +2,7 @@ module github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration go 1.24.0 -require github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0 +require github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.0.0 require ( github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect diff --git a/azureappconfiguration/go.sum b/azureappconfiguration/go.sum index af0f2ef..292ddcf 100644 --- a/azureappconfiguration/go.sum +++ b/azureappconfiguration/go.sum @@ -2,8 +2,8 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 h1:Hr5FTipp7SL07o2FvoVOX9HR github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= -github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0 h1:uU4FujKFQAz31AbWOO3INV9qfIanHeIUSsGhRlcJJmg= -github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig v1.2.0/go.mod h1:qr3M3Oy6V98VR0c5tCHKUpaeJTRQh6KYzJewRtFWqfc= +github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.0.0 h1:K7LqZL3VW+DElZhW+5tY/cp2RRFrB3W45WUG/9fhhls= +github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2 v2.0.0/go.mod h1:4IPby+BYf0rPMnMur/mNtowysFd4NoEW5U1vhrkhARA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= diff --git a/azureappconfiguration/internal/tracing/tracing.go b/azureappconfiguration/internal/tracing/tracing.go index ff4cb75..2c30ccf 100644 --- a/azureappconfiguration/internal/tracing/tracing.go +++ b/azureappconfiguration/internal/tracing/tracing.go @@ -45,8 +45,8 @@ const ( LoadBalancingEnabledTag = "LB" // Feature flag usage tracing - FMGoVerEnv = "MS_FEATURE_MANAGEMENT_GO_VERSION" - FMGoVerKey = "FMGoVer" + FMGoVerEnv = "MS_FEATURE_MANAGEMENT_GO_VERSION" + FMGoVerKey = "FMGoVer" FeatureFilterTypeKey = "Filter" CustomFilterKey = "CSTM" TimeWindowFilterKey = "TIME" diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index ac71541..fe531fd 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -11,7 +11,7 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" ) // Options contains optional parameters to configure the behavior of an Azure App Configuration provider. diff --git a/azureappconfiguration/refresh_test.go b/azureappconfiguration/refresh_test.go index b0fe243..76274a0 100644 --- a/azureappconfiguration/refresh_test.go +++ b/azureappconfiguration/refresh_test.go @@ -14,7 +14,7 @@ import ( "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/refresh" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index 3dce25d..2b96411 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -13,7 +13,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" ) type settingsResponse struct { @@ -67,7 +67,7 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp selector := azappconfig.SettingSelector{ KeyFilter: to.Ptr(filter.KeyFilter), LabelFilter: to.Ptr(filter.LabelFilter), - // Todo: TagsFilter : filter.TagFilter + TagsFilter: filter.TagFilter, Fields: azappconfig.AllSettingFields(), } @@ -168,10 +168,6 @@ func (c *watchedSettingClient) checkIfETagChanged(ctx context.Context) (bool, er } func (c *pageETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) { - if c.tracingOptions.Enabled { - ctx = policy.WithHTTPHeader(ctx, tracing.CreateCorrelationContextHeader(ctx, c.tracingOptions)) - } - for selector, pageETags := range c.pageETags { s := azappconfig.SettingSelector{ KeyFilter: to.Ptr(selector.KeyFilter), diff --git a/azureappconfiguration/snapshot_test.go b/azureappconfiguration/snapshot_test.go index 4506e91..d8ed62e 100644 --- a/azureappconfiguration/snapshot_test.go +++ b/azureappconfiguration/snapshot_test.go @@ -9,7 +9,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" - "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig" + "github.com/Azure/azure-sdk-for-go/sdk/data/azappconfig/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) From 20094e6da215c59abdb734b9e9cc9a243720edc3 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Mon, 20 Oct 2025 17:23:26 +0800 Subject: [PATCH 3/8] update --- azureappconfiguration/settings_client.go | 2 ++ azureappconfiguration/utils.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index 2b96411..e46ccaa 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "log" + "strings" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -172,6 +173,7 @@ func (c *pageETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) s := azappconfig.SettingSelector{ KeyFilter: to.Ptr(selector.KeyFilter), LabelFilter: to.Ptr(selector.LabelFilter), + TagsFilter: strings.Split(selector.TagFilter, ","), Fields: azappconfig.AllSettingFields(), } diff --git a/azureappconfiguration/utils.go b/azureappconfiguration/utils.go index d0edcc2..3af8180 100644 --- a/azureappconfiguration/utils.go +++ b/azureappconfiguration/utils.go @@ -77,7 +77,7 @@ func verifyOptions(options *Options) error { func verifySelectors(selectors []Selector) error { for _, selector := range selectors { if selector.SnapshotName != "" { - if selector.KeyFilter != "" || selector.LabelFilter != "" || selector.TagFilter != nil { + if selector.KeyFilter != "" || selector.LabelFilter != "" || len(selector.TagFilter) > 0 { return fmt.Errorf("key, label and tag filters should not be used if snapshot name is provided") } } else { From 8548694f673c46c32bccd162aa56e3d38014fda2 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Wed, 22 Oct 2025 16:07:14 +0800 Subject: [PATCH 4/8] update --- .../azureappconfiguration_test.go | 61 ++++++++++++++++++- azureappconfiguration/options.go | 11 ++-- azureappconfiguration/settings_client.go | 9 ++- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/azureappconfiguration/azureappconfiguration_test.go b/azureappconfiguration/azureappconfiguration_test.go index bb534cd..2c3fdc4 100644 --- a/azureappconfiguration/azureappconfiguration_test.go +++ b/azureappconfiguration/azureappconfiguration_test.go @@ -1741,6 +1741,63 @@ func TestSelectorComparableKey_WithTagFilter(t *testing.T) { // Should produce the same comparable key due to sorting assert.Equal(t, key1, key2) - assert.Equal(t, "env=production,team=backend", key1.TagFilter) - assert.Equal(t, "env=production,team=backend", key2.TagFilter) + assert.Equal(t, `["env=production","team=backend"]`, key1.TagFilter) + assert.Equal(t, `["env=production","team=backend"]`, key2.TagFilter) +} + +func TestSelectorComparableKey_WithSpecialCharacters(t *testing.T) { + // Test that selectors handle special characters in tag values correctly + selector := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilter: []string{ + `env=prod,staging`, // Comma in value + `description="test,with,quotes"`, // Quotes and commas + `path=c:\windows\system32`, // Backslashes + `json={"key":"value"}`, // JSON in value + }, + } + + key := selector.comparableKey() + + // Verify JSON encoding handles all special characters properly + expected := `["description=\"test,with,quotes\"","env=prod,staging","json={\"key\":\"value\"}","path=c:\\windows\\system32"]` + assert.Equal(t, expected, key.TagFilter) +} + +func TestSelectorComparableKey_WithEmptyAndNilTagFilter(t *testing.T) { + // Test empty TagFilter + selector1 := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilter: []string{}, + } + + key1 := selector1.comparableKey() + assert.Equal(t, "[]", key1.TagFilter) + + // Test nil TagFilter (should be handled the same as empty) + selector2 := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilter: nil, + } + + key2 := selector2.comparableKey() + assert.Equal(t, "[]", key2.TagFilter) +} + +func TestSelectorComparableKey_Deterministic(t *testing.T) { + // Test that the same selector always produces the same key + selector := Selector{ + KeyFilter: "app*", + LabelFilter: "prod", + TagFilter: []string{"z=last", "a=first", "m=middle"}, + } + + key1 := selector.comparableKey() + key2 := selector.comparableKey() + + assert.Equal(t, key1, key2) + assert.Equal(t, `["a=first","m=middle","z=last"]`, key1.TagFilter) // Should be sorted } diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index fe531fd..f5dee53 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -5,9 +5,9 @@ package azureappconfiguration import ( "context" + "encoding/json" "net/url" "sort" - "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -96,21 +96,24 @@ func (s Selector) comparableKey() selectorKey { copy(tagFilter, s.TagFilter) sort.Strings(tagFilter) + // Use JSON encoding for robust serialization that handles all special characters + tagFilterJSON, _ := json.Marshal(tagFilter) // Marshal of []string should never fail + return selectorKey{ KeyFilter: s.KeyFilter, LabelFilter: s.LabelFilter, SnapshotName: s.SnapshotName, - TagFilter: strings.Join(tagFilter, ","), + TagFilter: string(tagFilterJSON), } } // selectorKey is a comparable version of Selector that can be used as a map key. -// It represents the same selector information but with TagFilter as a sorted, comma-separated string. +// It represents the same selector information but with TagFilter as a sorted, JSON-encoded string. type selectorKey struct { KeyFilter string LabelFilter string SnapshotName string - TagFilter string // Sorted, comma-separated representation of the original TagFilter slice + TagFilter string // Sorted, JSON-encoded representation of the original TagFilter slice } // KeyValueRefreshOptions contains optional parameters to configure the behavior of key-value settings refresh diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index e46ccaa..e80d483 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -5,10 +5,10 @@ package azureappconfiguration import ( "context" + "encoding/json" "errors" "fmt" "log" - "strings" "github.com/Azure/AppConfiguration-GoProvider/azureappconfiguration/internal/tracing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -173,10 +173,15 @@ func (c *pageETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) s := azappconfig.SettingSelector{ KeyFilter: to.Ptr(selector.KeyFilter), LabelFilter: to.Ptr(selector.LabelFilter), - TagsFilter: strings.Split(selector.TagFilter, ","), Fields: azappconfig.AllSettingFields(), } + tagFilters := make([]string, 0) + if selector.TagFilter != "" { + json.Unmarshal([]byte(selector.TagFilter), &tagFilters) + s.TagsFilter = tagFilters + } + conditions := make([]azcore.MatchConditions, 0) for _, eTag := range pageETags { conditions = append(conditions, azcore.MatchConditions{IfNoneMatch: eTag}) From 197f0bdd9d426808c0e233b7268ce8de249599d2 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Thu, 23 Oct 2025 16:17:07 +0800 Subject: [PATCH 5/8] dedup tagFilters --- azureappconfiguration/azureappconfiguration_test.go | 4 ++-- azureappconfiguration/options.go | 12 +++++++++--- azureappconfiguration/settings_client.go | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/azureappconfiguration/azureappconfiguration_test.go b/azureappconfiguration/azureappconfiguration_test.go index 2c3fdc4..98a7da6 100644 --- a/azureappconfiguration/azureappconfiguration_test.go +++ b/azureappconfiguration/azureappconfiguration_test.go @@ -1774,7 +1774,7 @@ func TestSelectorComparableKey_WithEmptyAndNilTagFilter(t *testing.T) { } key1 := selector1.comparableKey() - assert.Equal(t, "[]", key1.TagFilter) + assert.Equal(t, "null", key1.TagFilter) // Test nil TagFilter (should be handled the same as empty) selector2 := Selector{ @@ -1784,7 +1784,7 @@ func TestSelectorComparableKey_WithEmptyAndNilTagFilter(t *testing.T) { } key2 := selector2.comparableKey() - assert.Equal(t, "[]", key2.TagFilter) + assert.Equal(t, "null", key2.TagFilter) } func TestSelectorComparableKey_Deterministic(t *testing.T) { diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index f5dee53..f0331b8 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -91,9 +91,15 @@ type Selector struct { // comparableKey returns a comparable representation of the Selector that can be used as a map key. // This method creates a deterministic string representation by sorting the TagFilter slice. func (s Selector) comparableKey() selectorKey { - // Create a copy and sort the TagFilter to ensure deterministic comparison - tagFilter := make([]string, len(s.TagFilter)) - copy(tagFilter, s.TagFilter) + // Deduplicate TagFilter + unique := make(map[string]struct{}, len(s.TagFilter)) + var tagFilter []string + for _, tag := range s.TagFilter { + if _, exists := unique[tag]; !exists { + unique[tag] = struct{}{} + tagFilter = append(tagFilter, tag) + } + } sort.Strings(tagFilter) // Use JSON encoding for robust serialization that handles all special characters diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index e80d483..33c1eb1 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -177,7 +177,7 @@ func (c *pageETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) } tagFilters := make([]string, 0) - if selector.TagFilter != "" { + if selector.TagFilter != "" && selector.TagFilter != "null" { json.Unmarshal([]byte(selector.TagFilter), &tagFilters) s.TagsFilter = tagFilters } From 99e82dfafd851a71462d73aa8b3266025867b884 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Thu, 23 Oct 2025 16:41:52 +0800 Subject: [PATCH 6/8] rename --- .../azureappconfiguration.go | 10 ++++---- .../azureappconfiguration_test.go | 8 +++---- azureappconfiguration/options.go | 24 +++++++++---------- azureappconfiguration/settings_client.go | 6 ++--- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/azureappconfiguration/azureappconfiguration.go b/azureappconfiguration/azureappconfiguration.go index ce33b5a..82cdc03 100644 --- a/azureappconfiguration/azureappconfiguration.go +++ b/azureappconfiguration/azureappconfiguration.go @@ -55,8 +55,8 @@ type AzureAppConfiguration struct { // Settings used for refresh scenarios sentinelETags map[WatchedSetting]*azcore.ETag watchAll bool - kvETags map[selectorKey][]*azcore.ETag - ffETags map[selectorKey][]*azcore.ETag + kvETags map[comparableSelector][]*azcore.ETag + ffETags map[comparableSelector][]*azcore.ETag keyVaultRefs map[string]string // unversioned Key Vault references kvRefreshTimer refresh.Condition secretRefreshTimer refresh.Condition @@ -121,7 +121,7 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg.kvRefreshTimer = refresh.NewTimer(options.RefreshOptions.Interval) azappcfg.watchedSettings = normalizedWatchedSettings(options.RefreshOptions.WatchedSettings) azappcfg.sentinelETags = make(map[WatchedSetting]*azcore.ETag) - azappcfg.kvETags = make(map[selectorKey][]*azcore.ETag) + azappcfg.kvETags = make(map[comparableSelector][]*azcore.ETag) if len(options.RefreshOptions.WatchedSettings) == 0 { azappcfg.watchAll = true } @@ -137,7 +137,7 @@ func Load(ctx context.Context, authentication AuthenticationOptions, options *Op azappcfg.ffSelectors = getFeatureFlagSelectors(deduplicateSelectors(options.FeatureFlagOptions.Selectors)) if options.FeatureFlagOptions.RefreshOptions.Enabled { azappcfg.ffRefreshTimer = refresh.NewTimer(options.FeatureFlagOptions.RefreshOptions.Interval) - azappcfg.ffETags = make(map[selectorKey][]*azcore.ETag) + azappcfg.ffETags = make(map[comparableSelector][]*azcore.ETag) } } @@ -759,7 +759,7 @@ func deduplicateSelectors(selectors []Selector) []Selector { } // Create a map to track unique selectors - seen := make(map[selectorKey]struct{}) + seen := make(map[comparableSelector]struct{}) var result []Selector // Process the selectors in reverse order to maintain the behavior diff --git a/azureappconfiguration/azureappconfiguration_test.go b/azureappconfiguration/azureappconfiguration_test.go index 98a7da6..30832c2 100644 --- a/azureappconfiguration/azureappconfiguration_test.go +++ b/azureappconfiguration/azureappconfiguration_test.go @@ -128,7 +128,7 @@ func TestLoadFeatureFlags_Success(t *testing.T) { {Key: toPtr(".appconfig.featureflag/Beta"), Value: &value1, ContentType: toPtr(featureFlagContentType)}, {Key: toPtr(".appconfig.featureflag/Alpha"), Value: &value2, ContentType: toPtr(featureFlagContentType)}, }, - pageETags: map[selectorKey][]*azcore.ETag{}, + pageETags: map[comparableSelector][]*azcore.ETag{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) @@ -1545,7 +1545,7 @@ func TestLoadFeatureFlags_TracingUpdated(t *testing.T) { ContentType: toPtr(featureFlagContentType), }, }, - pageETags: map[selectorKey][]*azcore.ETag{}, + pageETags: map[comparableSelector][]*azcore.ETag{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) @@ -1639,7 +1639,7 @@ func TestLoadKeyValues_WithTagFilter(t *testing.T) { }, }, }, - pageETags: map[selectorKey][]*azcore.ETag{}, + pageETags: map[comparableSelector][]*azcore.ETag{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) @@ -1695,7 +1695,7 @@ func TestLoadKeyValues_WithMultipleTagFilters(t *testing.T) { }, }, }, - pageETags: map[selectorKey][]*azcore.ETag{}, + pageETags: map[comparableSelector][]*azcore.ETag{}, } mockClient.On("getSettings", ctx).Return(mockResponse, nil) diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index f0331b8..4de9356 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -90,22 +90,22 @@ type Selector struct { // comparableKey returns a comparable representation of the Selector that can be used as a map key. // This method creates a deterministic string representation by sorting the TagFilter slice. -func (s Selector) comparableKey() selectorKey { +func (s Selector) comparableKey() comparableSelector { // Deduplicate TagFilter - unique := make(map[string]struct{}, len(s.TagFilter)) - var tagFilter []string - for _, tag := range s.TagFilter { - if _, exists := unique[tag]; !exists { - unique[tag] = struct{}{} - tagFilter = append(tagFilter, tag) - } - } + unique := make(map[string]struct{}, len(s.TagFilter)) + var tagFilter []string + for _, tag := range s.TagFilter { + if _, exists := unique[tag]; !exists { + unique[tag] = struct{}{} + tagFilter = append(tagFilter, tag) + } + } sort.Strings(tagFilter) // Use JSON encoding for robust serialization that handles all special characters tagFilterJSON, _ := json.Marshal(tagFilter) // Marshal of []string should never fail - return selectorKey{ + return comparableSelector{ KeyFilter: s.KeyFilter, LabelFilter: s.LabelFilter, SnapshotName: s.SnapshotName, @@ -113,9 +113,9 @@ func (s Selector) comparableKey() selectorKey { } } -// selectorKey is a comparable version of Selector that can be used as a map key. +// comparableSelector is a comparable version of Selector that can be used as a map key. // It represents the same selector information but with TagFilter as a sorted, JSON-encoded string. -type selectorKey struct { +type comparableSelector struct { KeyFilter string LabelFilter string SnapshotName string diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index 33c1eb1..b3534e4 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -20,7 +20,7 @@ import ( type settingsResponse struct { settings []azappconfig.Setting watchedETags map[WatchedSetting]*azcore.ETag - pageETags map[selectorKey][]*azcore.ETag + pageETags map[comparableSelector][]*azcore.ETag } type selectorSettingsClient struct { @@ -37,7 +37,7 @@ type watchedSettingClient struct { } type pageETagsClient struct { - pageETags map[selectorKey][]*azcore.ETag + pageETags map[comparableSelector][]*azcore.ETag client *azappconfig.Client tracingOptions tracing.Options } @@ -62,7 +62,7 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp } settings := make([]azappconfig.Setting, 0) - pageETags := make(map[selectorKey][]*azcore.ETag) + pageETags := make(map[comparableSelector][]*azcore.ETag) for _, filter := range s.selectors { if filter.SnapshotName == "" { selector := azappconfig.SettingSelector{ From cb7004f5d52fde2dfb089d5284e79b3b9bb38a80 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Wed, 29 Oct 2025 15:28:25 +0800 Subject: [PATCH 7/8] update --- .../azureappconfiguration_test.go | 32 +++++++++---------- azureappconfiguration/options.go | 12 +++---- azureappconfiguration/settings_client.go | 6 ++-- azureappconfiguration/utils.go | 4 +-- azureappconfiguration/utils_test.go | 8 ++--- 5 files changed, 31 insertions(+), 31 deletions(-) diff --git a/azureappconfiguration/azureappconfiguration_test.go b/azureappconfiguration/azureappconfiguration_test.go index 30832c2..e3a9294 100644 --- a/azureappconfiguration/azureappconfiguration_test.go +++ b/azureappconfiguration/azureappconfiguration_test.go @@ -1651,8 +1651,8 @@ func TestLoadKeyValues_WithTagFilter(t *testing.T) { }, kvSelectors: []Selector{ { - KeyFilter: "*", - TagFilter: []string{"env=production"}, + KeyFilter: "*", + TagFilters: []string{"env=production"}, }, }, keyValues: make(map[string]any), @@ -1707,8 +1707,8 @@ func TestLoadKeyValues_WithMultipleTagFilters(t *testing.T) { }, kvSelectors: []Selector{ { - KeyFilter: "*", - TagFilter: []string{"env=production", "team=backend"}, + KeyFilter: "*", + TagFilters: []string{"env=production", "team=backend"}, }, }, keyValues: make(map[string]any), @@ -1727,13 +1727,13 @@ func TestSelectorComparableKey_WithTagFilter(t *testing.T) { selector1 := Selector{ KeyFilter: "app*", LabelFilter: "prod", - TagFilter: []string{"env=production", "team=backend"}, + TagFilters: []string{"env=production", "team=backend"}, } selector2 := Selector{ KeyFilter: "app*", LabelFilter: "prod", - TagFilter: []string{"team=backend", "env=production"}, // Different order + TagFilters: []string{"team=backend", "env=production"}, // Different order } key1 := selector1.comparableKey() @@ -1741,8 +1741,8 @@ func TestSelectorComparableKey_WithTagFilter(t *testing.T) { // Should produce the same comparable key due to sorting assert.Equal(t, key1, key2) - assert.Equal(t, `["env=production","team=backend"]`, key1.TagFilter) - assert.Equal(t, `["env=production","team=backend"]`, key2.TagFilter) + assert.Equal(t, `["env=production","team=backend"]`, key1.TagFilters) + assert.Equal(t, `["env=production","team=backend"]`, key2.TagFilters) } func TestSelectorComparableKey_WithSpecialCharacters(t *testing.T) { @@ -1750,7 +1750,7 @@ func TestSelectorComparableKey_WithSpecialCharacters(t *testing.T) { selector := Selector{ KeyFilter: "app*", LabelFilter: "prod", - TagFilter: []string{ + TagFilters: []string{ `env=prod,staging`, // Comma in value `description="test,with,quotes"`, // Quotes and commas `path=c:\windows\system32`, // Backslashes @@ -1762,7 +1762,7 @@ func TestSelectorComparableKey_WithSpecialCharacters(t *testing.T) { // Verify JSON encoding handles all special characters properly expected := `["description=\"test,with,quotes\"","env=prod,staging","json={\"key\":\"value\"}","path=c:\\windows\\system32"]` - assert.Equal(t, expected, key.TagFilter) + assert.Equal(t, expected, key.TagFilters) } func TestSelectorComparableKey_WithEmptyAndNilTagFilter(t *testing.T) { @@ -1770,21 +1770,21 @@ func TestSelectorComparableKey_WithEmptyAndNilTagFilter(t *testing.T) { selector1 := Selector{ KeyFilter: "app*", LabelFilter: "prod", - TagFilter: []string{}, + TagFilters: []string{}, } key1 := selector1.comparableKey() - assert.Equal(t, "null", key1.TagFilter) + assert.Equal(t, "null", key1.TagFilters) // Test nil TagFilter (should be handled the same as empty) selector2 := Selector{ KeyFilter: "app*", LabelFilter: "prod", - TagFilter: nil, + TagFilters: nil, } key2 := selector2.comparableKey() - assert.Equal(t, "null", key2.TagFilter) + assert.Equal(t, "null", key2.TagFilters) } func TestSelectorComparableKey_Deterministic(t *testing.T) { @@ -1792,12 +1792,12 @@ func TestSelectorComparableKey_Deterministic(t *testing.T) { selector := Selector{ KeyFilter: "app*", LabelFilter: "prod", - TagFilter: []string{"z=last", "a=first", "m=middle"}, + TagFilters: []string{"z=last", "a=first", "m=middle"}, } key1 := selector.comparableKey() key2 := selector.comparableKey() assert.Equal(t, key1, key2) - assert.Equal(t, `["a=first","m=middle","z=last"]`, key1.TagFilter) // Should be sorted + assert.Equal(t, `["a=first","m=middle","z=last"]`, key1.TagFilters) // Should be sorted } diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index 4de9356..934558c 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -82,19 +82,19 @@ type Selector struct { // If SnapshotName is used in a selector, no key and label filter should be used for it. Otherwise, an error will be returned. SnapshotName string - // TagFilter specifies which tags to retrieve from Azure App Configuration. + // TagFilters specifies which tags to retrieve from Azure App Configuration. // Each tag filter must follow the format "tagName=tagValue". Only those key-values will be loaded whose tags match all the tags provided here. // Up to 5 tag filters can be provided. If no tag filters are provided, key-values will not be filtered based on tags. - TagFilter []string + TagFilters []string } // comparableKey returns a comparable representation of the Selector that can be used as a map key. // This method creates a deterministic string representation by sorting the TagFilter slice. func (s Selector) comparableKey() comparableSelector { // Deduplicate TagFilter - unique := make(map[string]struct{}, len(s.TagFilter)) + unique := make(map[string]struct{}, len(s.TagFilters)) var tagFilter []string - for _, tag := range s.TagFilter { + for _, tag := range s.TagFilters { if _, exists := unique[tag]; !exists { unique[tag] = struct{}{} tagFilter = append(tagFilter, tag) @@ -109,7 +109,7 @@ func (s Selector) comparableKey() comparableSelector { KeyFilter: s.KeyFilter, LabelFilter: s.LabelFilter, SnapshotName: s.SnapshotName, - TagFilter: string(tagFilterJSON), + TagFilters: string(tagFilterJSON), } } @@ -119,7 +119,7 @@ type comparableSelector struct { KeyFilter string LabelFilter string SnapshotName string - TagFilter string // Sorted, JSON-encoded representation of the original TagFilter slice + TagFilters string // Sorted, JSON-encoded representation of the original TagFilter slice } // KeyValueRefreshOptions contains optional parameters to configure the behavior of key-value settings refresh diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index b3534e4..da0c131 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -68,7 +68,7 @@ func (s *selectorSettingsClient) getSettings(ctx context.Context) (*settingsResp selector := azappconfig.SettingSelector{ KeyFilter: to.Ptr(filter.KeyFilter), LabelFilter: to.Ptr(filter.LabelFilter), - TagsFilter: filter.TagFilter, + TagsFilter: filter.TagFilters, Fields: azappconfig.AllSettingFields(), } @@ -177,8 +177,8 @@ func (c *pageETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) } tagFilters := make([]string, 0) - if selector.TagFilter != "" && selector.TagFilter != "null" { - json.Unmarshal([]byte(selector.TagFilter), &tagFilters) + if selector.TagFilters != "" && selector.TagFilters != "null" { + json.Unmarshal([]byte(selector.TagFilters), &tagFilters) s.TagsFilter = tagFilters } diff --git a/azureappconfiguration/utils.go b/azureappconfiguration/utils.go index 3af8180..aede1ba 100644 --- a/azureappconfiguration/utils.go +++ b/azureappconfiguration/utils.go @@ -77,7 +77,7 @@ func verifyOptions(options *Options) error { func verifySelectors(selectors []Selector) error { for _, selector := range selectors { if selector.SnapshotName != "" { - if selector.KeyFilter != "" || selector.LabelFilter != "" || len(selector.TagFilter) > 0 { + if selector.KeyFilter != "" || selector.LabelFilter != "" || len(selector.TagFilters) > 0 { return fmt.Errorf("key, label and tag filters should not be used if snapshot name is provided") } } else { @@ -89,7 +89,7 @@ func verifySelectors(selectors []Selector) error { return fmt.Errorf("label filter cannot contain '*' or ','") } - if err := validateTagFilters(selector.TagFilter); err != nil { + if err := validateTagFilters(selector.TagFilters); err != nil { return err } } diff --git a/azureappconfiguration/utils_test.go b/azureappconfiguration/utils_test.go index 9009b11..dc1c560 100644 --- a/azureappconfiguration/utils_test.go +++ b/azureappconfiguration/utils_test.go @@ -189,28 +189,28 @@ func TestVerifySelectors(t *testing.T) { { name: "valid tag filter", selectors: []Selector{ - {KeyFilter: "app*", LabelFilter: "prod", TagFilter: []string{"environment=production", "team=backend"}}, + {KeyFilter: "app*", LabelFilter: "prod", TagFilters: []string{"environment=production", "team=backend"}}, }, expectedError: false, }, { name: "invalid tag filter format", selectors: []Selector{ - {KeyFilter: "app*", LabelFilter: "prod", TagFilter: []string{"invalid_format"}}, + {KeyFilter: "app*", LabelFilter: "prod", TagFilters: []string{"invalid_format"}}, }, expectedError: true, }, { name: "too many tag filters", selectors: []Selector{ - {KeyFilter: "app*", LabelFilter: "prod", TagFilter: []string{"tag1=val1", "tag2=val2", "tag3=val3", "tag4=val4", "tag5=val5", "tag6=val6"}}, + {KeyFilter: "app*", LabelFilter: "prod", TagFilters: []string{"tag1=val1", "tag2=val2", "tag3=val3", "tag4=val4", "tag5=val5", "tag6=val6"}}, }, expectedError: true, }, { name: "tag filter with snapshot (should fail)", selectors: []Selector{ - {SnapshotName: "my-snapshot", TagFilter: []string{"environment=production"}}, + {SnapshotName: "my-snapshot", TagFilters: []string{"environment=production"}}, }, expectedError: true, }, From ddabc52f5036ff1bd801cf668c04c1cf4b73550b Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Wed, 29 Oct 2025 15:36:56 +0800 Subject: [PATCH 8/8] update --- .../azureappconfiguration_test.go | 4 +-- azureappconfiguration/options.go | 36 ++++++++++--------- azureappconfiguration/settings_client.go | 2 +- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/azureappconfiguration/azureappconfiguration_test.go b/azureappconfiguration/azureappconfiguration_test.go index e3a9294..681b622 100644 --- a/azureappconfiguration/azureappconfiguration_test.go +++ b/azureappconfiguration/azureappconfiguration_test.go @@ -1774,7 +1774,7 @@ func TestSelectorComparableKey_WithEmptyAndNilTagFilter(t *testing.T) { } key1 := selector1.comparableKey() - assert.Equal(t, "null", key1.TagFilters) + assert.Equal(t, "", key1.TagFilters) // Test nil TagFilter (should be handled the same as empty) selector2 := Selector{ @@ -1784,7 +1784,7 @@ func TestSelectorComparableKey_WithEmptyAndNilTagFilter(t *testing.T) { } key2 := selector2.comparableKey() - assert.Equal(t, "null", key2.TagFilters) + assert.Equal(t, "", key2.TagFilters) } func TestSelectorComparableKey_Deterministic(t *testing.T) { diff --git a/azureappconfiguration/options.go b/azureappconfiguration/options.go index 934558c..937a72c 100644 --- a/azureappconfiguration/options.go +++ b/azureappconfiguration/options.go @@ -91,26 +91,30 @@ type Selector struct { // comparableKey returns a comparable representation of the Selector that can be used as a map key. // This method creates a deterministic string representation by sorting the TagFilter slice. func (s Selector) comparableKey() comparableSelector { - // Deduplicate TagFilter - unique := make(map[string]struct{}, len(s.TagFilters)) - var tagFilter []string - for _, tag := range s.TagFilters { - if _, exists := unique[tag]; !exists { - unique[tag] = struct{}{} - tagFilter = append(tagFilter, tag) - } - } - sort.Strings(tagFilter) - - // Use JSON encoding for robust serialization that handles all special characters - tagFilterJSON, _ := json.Marshal(tagFilter) // Marshal of []string should never fail - - return comparableSelector{ + cs := comparableSelector{ KeyFilter: s.KeyFilter, LabelFilter: s.LabelFilter, SnapshotName: s.SnapshotName, - TagFilters: string(tagFilterJSON), } + + if len(s.TagFilters) > 0 { + // Deduplicate TagFilter + unique := make(map[string]struct{}, len(s.TagFilters)) + var tagFilter []string + for _, tag := range s.TagFilters { + if _, exists := unique[tag]; !exists { + unique[tag] = struct{}{} + tagFilter = append(tagFilter, tag) + } + } + sort.Strings(tagFilter) + + // Use JSON encoding for robust serialization that handles all special characters + tagFilterJSON, _ := json.Marshal(tagFilter) // Marshal of []string should never fail + cs.TagFilters = string(tagFilterJSON) + } + + return cs } // comparableSelector is a comparable version of Selector that can be used as a map key. diff --git a/azureappconfiguration/settings_client.go b/azureappconfiguration/settings_client.go index da0c131..d269322 100644 --- a/azureappconfiguration/settings_client.go +++ b/azureappconfiguration/settings_client.go @@ -177,7 +177,7 @@ func (c *pageETagsClient) checkIfETagChanged(ctx context.Context) (bool, error) } tagFilters := make([]string, 0) - if selector.TagFilters != "" && selector.TagFilters != "null" { + if selector.TagFilters != "" { json.Unmarshal([]byte(selector.TagFilters), &tagFilters) s.TagsFilter = tagFilters }