Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@

### Features Added

- Added support for filtering configuration settings and feature flags by tags. Tags can be configured via `spring.cloud.azure.appconfiguration.stores[0].selects[0].tags-filter` for key-value settings and `spring.cloud.azure.appconfiguration.stores[0].feature-flags.selects[0].tags-filter` for feature flags. The value is a list of `tag=value` pairs (e.g., `["env=prod", "team=backend"]`) combined with AND logic.

### Breaking Changes

### Bugs Fixed

- Fixed YAML configuration binding for `label-filter` by adding standard no-arg getter methods to `AppConfigurationKeyValueSelector` and `FeatureFlagKeyValueSelector`, enabling proper type resolution by Spring Boot's `@ConfigurationProperties` binder.

### Other Changes

## 7.0.0 (2026-02-03)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,18 @@ class AppConfigurationApplicationSettingPropertySource extends AppConfigurationP

private final String[] labelFilters;

private final List<String> tagsFilter;

AppConfigurationApplicationSettingPropertySource(String name, AppConfigurationReplicaClient replicaClient,
AppConfigurationKeyVaultClientFactory keyVaultClientFactory, String keyFilter, String[] labelFilters) {
AppConfigurationKeyVaultClientFactory keyVaultClientFactory, String keyFilter, String[] labelFilters,
List<String> tagsFilter) {
// The context alone does not uniquely define a PropertySource, append storeName
// and label to uniquely define a PropertySource
super(name + getLabelName(labelFilters), replicaClient);
this.keyVaultClientFactory = keyVaultClientFactory;
this.keyFilter = keyFilter;
this.labelFilters = labelFilters;
this.tagsFilter = tagsFilter;
}

/**
Expand All @@ -70,6 +74,10 @@ public void initProperties(List<String> keyPrefixTrimValues, Context context) th
for (String label : labels) {
SettingSelector settingSelector = new SettingSelector().setKeyFilter(keyFilter + "*").setLabelFilter(label);

if (tagsFilter != null && !tagsFilter.isEmpty()) {
settingSelector.setTagsFilter(tagsFilter);
}

// * for wildcard match
processConfigurationSettings(replicaClient.listSettings(settingSelector, context), settingSelector.getKeyFilter(),
keyPrefixTrimValues);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ final class AppConfigurationSnapshotPropertySource extends AppConfigurationAppli
FeatureFlagClient featureFlagClient) {
// The context alone does not uniquely define a PropertySource, append storeName
// and label to uniquely define a PropertySource
super(name, replicaClient, keyVaultClientFactory, null, null);
super(name, replicaClient, keyVaultClientFactory, null, null, null);
this.snapshotName = snapshotName;
this.featureFlagClient = featureFlagClient;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,8 @@ private List<AppConfigurationPropertySource> createSettings(AppConfigurationRepl
} else {
propertySource = new AppConfigurationApplicationSettingPropertySource(
selectedKeys.getKeyFilter() + resource.getEndpoint() + "/", client, keyVaultClientFactory,
selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles));
selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles),
selectedKeys.getTagsFilter());
}
propertySource.initProperties(resource.getTrimKeyPrefix(), requestContext);
sourceList.add(propertySource);
Expand All @@ -282,7 +283,8 @@ private List<WatchedConfigurationSettings> createFeatureFlags(AppConfigurationRe

for (FeatureFlagKeyValueSelector selectedKeys : resource.getFeatureFlagSelects()) {
List<WatchedConfigurationSettings> storesFeatureFlags = featureFlagClient.loadFeatureFlags(client,
selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles), requestContext);
selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles),
selectedKeys.getTagsFilter(), requestContext);
featureFlagWatchKeys.addAll(storesFeatureFlags);
}

Expand Down Expand Up @@ -315,6 +317,10 @@ private List<WatchedConfigurationSettings> getWatchedConfigurationSettings(AppCo
.setKeyFilter(selectedKeys.getKeyFilter() + "*")
.setLabelFilter(label);

if (selectedKeys.getTagsFilter() != null && !selectedKeys.getTagsFilter().isEmpty()) {
settingSelector.setTagsFilter(selectedKeys.getTagsFilter());
}

WatchedConfigurationSettings watchedConfigurationSettings = client.loadWatchedSettings(settingSelector,
requestContext);
watchedConfigurationSettingsList.add(watchedConfigurationSettings);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class FeatureFlagClient {
*
*/
List<WatchedConfigurationSettings> loadFeatureFlags(AppConfigurationReplicaClient replicaClient, String customKeyFilter,
String[] labelFilter, Context context) {
String[] labelFilter, List<String> tagsFilter, Context context) {
List<WatchedConfigurationSettings> loadedFeatureFlags = new ArrayList<>();

String keyFilter = SELECT_ALL_FEATURE_FLAGS;
Expand All @@ -93,6 +93,11 @@ List<WatchedConfigurationSettings> loadFeatureFlags(AppConfigurationReplicaClien

for (String label : labels) {
SettingSelector settingSelector = new SettingSelector().setKeyFilter(keyFilter).setLabelFilter(label);

if (tagsFilter != null && !tagsFilter.isEmpty()) {
settingSelector.setTagsFilter(tagsFilter);
}

context.addData("FeatureFlagTracing", tracing);

WatchedConfigurationSettings features = replicaClient.listFeatureFlags(settingSelector, context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,50 +35,68 @@ public final class AppConfigurationKeyValueSelector {

@NotNull
/**
* Key filter to use when loading configurations. The default value is
* "/application/". The key filter is used to filter configurations by key.
* The key filter must be a non-null string that does not contain an asterisk.
* Filters configurations by key prefix. Defaults to {@code /application/} when
* not explicitly set. Must not be {@code null} or contain asterisks ({@code *}).
*/
private String keyFilter = "";

/**
* Label filter to use when loading configurations. The label filter is used to
* filter configurations by label. If the label filter is not set, the default
* value is the current active Spring profiles. If no active profiles are set,
* then all configurations with no label are loaded. The label filter must be a
* non-null string that does not contain an asterisk.
* Filters configurations by label. When unset, defaults to the active Spring
* profiles; if no profiles are active, only configurations with no label are
* loaded. Multiple labels can be specified as a comma-separated string. Must
* not contain asterisks ({@code *}).
*/
private String labelFilter;

/**
* Snapshot name to use when loading configurations. The snapshot name is used
* to load configurations from a snapshot. If the snapshot name is set, the key
* and label filters must not be set. The snapshot name must be a non-null
* string that does not contain an asterisk.
* Filters configurations by tags. Each entry must follow the {@code tagName=tagValue}
* format. When multiple entries are provided, they are combined using AND logic.
Comment on lines +52 to +53
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JavaDoc states that each entry in tagsFilter must follow tagName=tagValue, but there’s currently no validation ensuring that (or preventing null/blank entries). Please either validate the list in validateAndInit/setter (with a helpful exception message) or adjust the documentation so it doesn’t promise constraints the code doesn’t enforce.

Suggested change
* Filters configurations by tags. Each entry must follow the {@code tagName=tagValue}
* format. When multiple entries are provided, they are combined using AND logic.
* Filters configurations by tags. Each entry is interpreted as a tag-based filter,
* typically in the {@code tagName=tagValue} format. When multiple entries are
* provided, they are combined using AND logic.

Copilot uses AI. Check for mistakes.
*/
private List<String> tagsFilter;

/**
* Loads configurations from a named snapshot. Cannot be used together with
* key, label, or tag filters. Must not contain asterisks ({@code *}).
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JavaDoc says snapshotName “Must not contain asterisks (*)”, but validateAndInit() doesn’t currently check snapshotName for *. Either add the corresponding validation (fail fast) or remove that constraint from the JavaDoc so it matches actual behavior.

Suggested change
* key, label, or tag filters. Must not contain asterisks ({@code *}).
* key, label, or tag filters.

Copilot uses AI. Check for mistakes.
*/
private String snapshotName = "";

/**
* @return the keyFilter
* Returns the key filter, defaulting to {@code /application/} when not explicitly set.
*
* @return the key filter string
*/
public String getKeyFilter() {
return StringUtils.hasText(keyFilter) ? keyFilter : APPLICATION_SETTING_DEFAULT_KEY_FILTER;
}

/**
* @param keyFilter the keyFilter to set
* @return AppConfigurationStoreSelects
* Sets the key filter used to select configurations by key prefix.
*
* @param keyFilter the key prefix to filter by
* @return this {@link AppConfigurationKeyValueSelector} for chaining
*/
public AppConfigurationKeyValueSelector setKeyFilter(String keyFilter) {
this.keyFilter = keyFilter;
return this;
}

/**
* @param profiles List of current Spring profiles to default to using is null
* label is set.
* @return List of reversed label values, which are split by the separator, the
* latter label has higher priority
* Returns the raw label filter string, or {@code null} if not set.
*
* @return the label filter
*/
public String getLabelFilter() {
return labelFilter;
}

/**
* Resolves the label filter into an array of labels. When no label filter is set,
* falls back to the active Spring profiles (in reverse priority order). If neither
* is available, returns the empty-label sentinel. Returns an empty array when a
* snapshot is configured.
*
* @param profiles the active Spring profiles to use as a fallback
* @return an array of resolved labels, ordered from lowest to highest priority
*/
public String[] getLabelFilter(List<String> profiles) {
if (StringUtils.hasText(snapshotName)) {
Expand Down Expand Up @@ -110,30 +128,58 @@ public String[] getLabelFilter(List<String> profiles) {
}

/**
* @param labelFilter the labelFilter to set
* @return AppConfigurationStoreSelects
* Sets the label filter used to select configurations by label.
*
* @param labelFilter a comma-separated string of labels to filter by
* @return this {@link AppConfigurationKeyValueSelector} for chaining
*/
public AppConfigurationKeyValueSelector setLabelFilter(String labelFilter) {
this.labelFilter = labelFilter;
return this;
}

/**
* @return the snapshot
* Returns the list of tag filters, or {@code null} if not set.
*
* @return the tag filter list
*/
public List<String> getTagsFilter() {
return tagsFilter;
}

/**
* Sets the tag filters used to select configurations by tags.
*
* @param tagsFilter list of tag expressions in {@code tagName=tagValue} format
* @return this {@link AppConfigurationKeyValueSelector} for chaining
*/
public AppConfigurationKeyValueSelector setTagsFilter(List<String> tagsFilter) {
this.tagsFilter = tagsFilter;
return this;
}

/**
* Returns the snapshot name, or an empty string if not set.
*
* @return the snapshot name
*/
public String getSnapshotName() {
return snapshotName;
}

/**
* @param snapshot the snapshot to set
* Sets the snapshot name to load configurations from.
*
* @param snapshotName the snapshot name
*/
public void setSnapshotName(String snapshotName) {
this.snapshotName = snapshotName;
}

/**
* Validates key-filter and label-filter are valid.
* Validates that key, label, tag, and snapshot filters are well-formed and
* mutually compatible. Asterisks are not allowed in key or label filters,
* and snapshots cannot be combined with any other filter type.
*/
@PostConstruct
void validateAndInit() {
Expand All @@ -145,6 +191,8 @@ void validateAndInit() {
"Snapshots can't use key filters");
Assert.isTrue(!(StringUtils.hasText(labelFilter) && StringUtils.hasText(snapshotName)),
"Snapshots can't use label filters");
Assert.isTrue(!(tagsFilter != null && !tagsFilter.isEmpty() && StringUtils.hasText(snapshotName)),
"Snapshots can't use tag filters");
}

private String mapLabel(String label) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,64 +23,78 @@
public class AppConfigurationProperties {

/**
* Prefix for client configurations for connecting to configuration stores.
* Configuration property prefix for Azure App Configuration client settings.
*/
public static final String CONFIG_PREFIX = "spring.cloud.azure.appconfiguration";

private boolean enabled = true;

/**
* List of Azure App Configuration stores to connect to.
* Azure App Configuration store connections. At least one store must be configured.
*/
private List<ConfigStore> stores = new ArrayList<>();

private Duration refreshInterval;

/**
* @return the enabled
* Returns whether Azure App Configuration is enabled.
*
* @return {@code true} if enabled, {@code false} otherwise
*/
public boolean isEnabled() {
return enabled;
}

/**
* @param enabled the enabled to set
* Sets whether Azure App Configuration is enabled.
*
* @param enabled {@code true} to enable, {@code false} to disable
*/
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

/**
* @return the stores
* Returns the list of configured App Configuration stores.
*
* @return the list of {@link ConfigStore} instances
*/
public List<ConfigStore> getStores() {
return stores;
}

/**
* @param stores the stores to set
* Sets the list of App Configuration stores to connect to.
*
* @param stores the list of {@link ConfigStore} instances
*/
public void setStores(List<ConfigStore> stores) {
this.stores = stores;
}

/**
* @return the refreshInterval
* Returns the interval between configuration refreshes.
*
* @return the refresh interval, or {@code null} if not set
*/
public Duration getRefreshInterval() {
return refreshInterval;
}

/**
* @param refreshInterval the refreshInterval to set
* Sets the interval between configuration refreshes. Must be at least 1 second.
*
* @param refreshInterval the refresh interval duration
*/
public void setRefreshInterval(Duration refreshInterval) {
this.refreshInterval = refreshInterval;
}

/**
* Validates at least one store is configured for use, and that they are valid.
* @throws IllegalArgumentException when duplicate endpoints are configured
* Validates that at least one store is configured with a valid endpoint or
* connection string, and that no duplicate endpoints exist.
*
* @throws IllegalArgumentException if validation fails or duplicate endpoints are found
*/
@PostConstruct
public void validateAndInit() {
Expand Down
Loading