Search code examples
spring-bootconfigurationproperty-binding

Spring Boot 3 - property binding into Map "duplicates" keys


I am using Spring Boot 3.1.4 (tested on 3.0.5 as well) and noticed something that caused problems downstream. I am not sure if this is a bug in SpringBoot or misuse from my side (if so, please advise what should be the appropriate way)

I have a multitenant application where I need to configure DataSource for each tenant. For that, I used configuration properties under spring.datasource by autowiring bean DataSourceProperties. When I try to get all XA datasource properties which are defined under spring.datasource.xa.properties they are mapped to Map<String, String> (so for instance if I define property spring.datasource.xa.properties.my-first-property=firstValue in application.properties that will be mapped to map as my-first-property:firstValue).

And when all properties are read only from application.properties there are no problems.

The interesting part comes when I override some of configuration properties via environment variable (that is the case when I deploy the application to the Kubernetes cluster for instance). Property defined via environment variable is defined exactly as SpringBoot recommends in relaxed binding section here.

When this occurs, I get "duplicate" keys in map - one key is coming from application.properties and is mapped as-is (camelCase or kebab-case, whatever is defined in configuration file) and other is all lowercase without dashes. But both keys have value which is defined in environment variable (as it should be).

So for instance, if I defined in application.properties parameter spring.datasource.xa.properties.my-first-property=firstValue and I also define environment variable as SPRING_DATASOURCE_XA_PROPERTIES_MYFIRSTPROPERTY=newValue I would have two entries in map - my-first-value and myfirstvalue, both having the value newValue.

I'll share relevant classes that demonstrate such behavior:

@Configuration
public class MySimpleConfiguration {


    @Autowired
    DataSourceProperties dataSourceProperties;

    @Bean
    public String myDummyBean() {
        Map<String, String> xaProperties = dataSourceProperties.getXa().getProperties();

        System.out.println("XA PROPERTY:\n" + xaProperties);
        
        return "myDummyBean";
    }
}
spring.datasource.xa.properties.my-first-property=firstValue
spring.datasource.xa.properties.my-second-property=secondValue

I run it via IntelliJ IDEA with such configuration: https://i.sstatic.net/yrzAL.png

And the logs I get when this is run are:

2023-11-09 12:02:47,320 [,] DEBUG o.s.b.f.s.DefaultListableBeanFactory Creating shared instance of singleton bean 'myDummyBean'
XA PROPERTY:
{myfirstproperty=newFirstValue, my-first-property=newFirstValue, my-second-property=secondValue}

I would expect that value from environment variable is overridden in Map but that only one key exist - one which is contained in configuration file application.properties.

Why is this causing trouble for me? When I extract all XA properties and pass them through to actual DB Driver (in my case IBM DB2 driver), autobinding of parameters fail with exception that there is no matching key with all lowercase without dashes. I overcame this by explicitly removing such properties from map, but again, why are they "duplicated" in the first place?


Solution

  • I've researched the question and seems like there is no such a way.

    TL;DR

    (In fact, some Shells could support hyphen in the environment variable names (see this examples), but this is against the POSIX standard and not portable, more on that - could be changed without notification, like it was in dash)

    Potential workaround (i applied in my case)

    Inside the map value structure (POJO, record) we "duplicate" the config map key and then in Spring Boot properties initialization validate the config skipping duplicates (if they have the same key and value)

    @Configuration
    @ConfigurationProperties("spring.datasource.xa.properties")
    @Validated
    public class XaConfigurationProperties {
    
    private Map<String, XaProperties>  = emptyMap();
    
    public record XaProperties(@NotBlank String xaKey, @NotBlank String url,  String username, String password) {}
    
    public void setXaProperties(Map<String, XaProperties> xaProperties) {
    
      if (isEmpty(properties)) {
        this.properties = emptyMap();
      }
    
      Map<String, XaProperties> result = new HashMap<>();
    
      for (var entry : properties.entrySet()) {
        String configMapKey = entry.getKey();
        XaProperties configMapValue = entry.getValue();
        String currentXaKey = configMapValue.xaKey();
    
        // validate whether we have already cached a config with the same xaKey and if so -
        // verify the configs aren't diverged
        XaProperties existingConfig = result.get(currentXaKey);
        if (existingConfig != null) {
          if (!existingConfig.equals(configMapValue)) {
            throw new IllegalArgumentException(
                "xaKey is duplicated, but the configuration values are different: configMapKey=%s, configMapXaProperties=[%s], existingXaProperties=[%s]"
                    .formatted(configMapKey, configMapValue, existingConfig));
          }
          continue; // if existingConfig equals to currentConfig - leave it as is
        }
    
        result.put(currentXaKey, configMapValue);
      }
    
      this.properties = unmodifiableMap(result);
    }
    

    and application.yaml will look like:

    spring.datasource.xa.properties:
     my-first-property:
       xa-key: my-first-property
       username: batman
       password: Gotham
     my-second-property:
       xa-key: my-second-property
       username: freddy-crueger
       xa-value-2: Springwood
    

    And this could be overridden by environment variable SPRING_DATASOURCE_XA_PROPERTIES_MYFIRSTPROPERTY_PASSWORD=Arkham