Polymorphic configuration properties in Spring Boot
Asked Answered
P

1

13

I would like to use polymorphic configuration properties on Spring, using Spring's @ConfigurationProperties annotation.

Suppose we have the following POJO classes.

public class Base {
  private String sharedProperty;

  public String getSharedProperty() {
    return sharedProperty;
  }

  public String setSharedProperty(String sharedProperty) {
    this.sharedProperty = sharedProperty;
  }
}

public class Foo extends Base {
  private String fooProperty;

  public String getFooProperty() {
    return fooProperty;
  }

  public String setFooProperty(String sharedProperty) {
    this. fooProperty = fooProperty;
  }
}

public class Bar extends Base {
  private String barProperty;

  public String getSharedProperty() {
    return sharedProperty;
  }

  public String setBarProperty(String barProperty) {
    this.barProperty = barProperty;
  }
}

And the configuration properties class,

@Component
@ConfigurationProperties(prefix = "playground")
public class SomeConfigurationProperties {
  private List<Base> mixed;

  public List<Base> getMixed() {
    return mixed;
  }

  public void setMixed(List<Base> mixed) {
    this.mixed = mixed;
  }
}

And the application.yml file,

playground:
  mixed:
    - shared-property: "shared prop"
      foo-property: "foo prop"
    - shared-property: "shared prop"
      bar-property: "bar prop"

However, with this configuration, Spring initializes the @ConfigurationProperties-annotated class with the list of Base objects, instead of their subclasses. That is, actually, an expected behavior (due to security concerns).

Is there a way to enforce the behavior of SnakeYAML to use subclasses, or implement any kind of custom deserialization provider?

Phila answered 28/12, 2018 at 18:2 Comment(2)
Have you tried using an approach using @Profile?Whacking
Have you found any solution to your problem? I am trying to do the same thing.Connett
L
7

Although it is possible to implement custom PropertySources and/or ConversionService, a custom deserialization provider is not necessary.

Spring has no issues binding the same properties to multiple beans. The reason your implementation is not working is because you are only registering one bean with the ApplicationContext with the @Component annotation on the base class. This is telling the component scanner that there is only one singleton of type Base. Because Foo and Bar are not registered as beans, they won't be bound to.

If the only reason you are looking at making these polymorphic is to share property name prefixes in SnakeYAML based config, then you actually do not need to introduce the polymorphic relationship, and can bind to shared properties by a common field name in different classes.

There are many ways to implement what you are asking for however in a polymorphic way, here are a few of the most straight forward simple ones:

Self declaring Polymorphic ConfigurationProperties singleton beans

Instead of applying the @ConfigurationProperties and @Component annotations on the base class, apply them on the concrete classes, with the same property name prefix. This wouldn't be my preferred approach, as each bean would not be conditional on their properties being set, however it may suit your needs. Depending on if your Spring Configuration allows properties to be reloaded, Spring will maintain the bindings on all of the beans.

Note: As of IntelliJ Idea 2018.3, an inspection profile was added to identify duplicate prefix keys as an error. You may want to ignore this, or suppress the warnings.

I tested the following successfully:

Base.java

package sample;

public class Base {
    private String sharedProperty;

    public String getSharedProperty() {
        return sharedProperty;
    }

    public void setSharedProperty(String sharedProperty) {
        this.sharedProperty = sharedProperty;
    }
}

Foo.java

package sample;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties("playground")
public class Foo extends Base {
    private String fooProperty;

    public String getFooProperty() {
        return fooProperty;
    }

    public void setFooProperty(String fooProperty) {
        this.fooProperty = fooProperty;
    }
}

Bar.java

package sample;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties("playground")
public class Bar extends Base {
    private String barProperty;

    public String getBarProperty() {
        return barProperty;
    }

    public void setBarProperty(String barProperty) {
        this.barProperty = barProperty;
    }
}

application.yml

playground:
  shared-property: "shared prop"
  foo-property: "foo prop"
  bar-property: "bar prop"

SampleAppTest.java

package sample;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.env.Environment;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
public class SampleAppTest {

    @Autowired
    public Environment environment;

    @Test
    public void test(@Autowired Bar bar, @Autowired Foo foo) {
        assertEquals("shared prop", bar.getSharedProperty());
        assertEquals("shared prop", foo.getSharedProperty());
        assertEquals("bar prop", bar.getBarProperty());
        assertEquals("foo prop", foo.getFooProperty());
    }

    @Test
    public void testSuper(@Autowired List<Base> props) {
        assertEquals(2, props.size());
    }
}

Polymorphic ConfigurationProperties beans conditional on properties

You may not want certain concrete implementations to be instantiated if their specific properties are missing. Furthermore, you may not want to couple the @ConfigurationProperties and @Component annotations to each concrete class. This implementation constructs the ConfigurationProperties beans via a Spring @Configuration bean. The configuration bean ensures they are only constructed conditionally via a property existence check. This implementation also creates a bean of concrete type Base if none of the other Base beans meet conditions and the shared properties exist. The same unit test from the previous example is used here and passes:

Base.java

package sample;

public class Base {
    private String sharedProperty;

    public String getSharedProperty() {
        return sharedProperty;
    }

    public void setSharedProperty(String sharedProperty) {
        this.sharedProperty = sharedProperty;
    }
}

Foo.java

package sample;

public class Foo extends Base {
    private String fooProperty;

    public String getFooProperty() {
        return fooProperty;
    }

    public void setFooProperty(String fooProperty) {
        this.fooProperty = fooProperty;
    }
}

Bar.java

package sample;

public class Bar extends Base {
    private String barProperty;

    public String getBarProperty() {
        return barProperty;
    }

    public void setBarProperty(String barProperty) {
        this.barProperty = barProperty;
    }
}

SampleConfiguration.java

package sample;

import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SampleConfiguration {

    @Bean
    @ConfigurationProperties("playground")
    @ConditionalOnProperty("playground.foo-property")
    public Foo foo() {
        return new Foo();
    }

    @Bean
    @ConfigurationProperties("playground")
    @ConditionalOnProperty("playground.bar-property")
    public Bar bar() {
        return new Bar();
    }

    @Bean
    @ConfigurationProperties("playground")
    @ConditionalOnProperty("playground.shared-property")
    @ConditionalOnMissingBean(Base.class)
    public Base base() {
        return new Base();
    }
}

application.yml

playground:
  shared-property: "shared prop"
  foo-property: "foo prop"
  bar-property: "bar prop"

SampleAppTest.java

package sample;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.env.Environment;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
public class SampleAppTest {

    @Autowired
    public Environment environment;

    @Test
    public void test(@Autowired Bar bar, @Autowired Foo foo) {
        assertEquals("shared prop", bar.getSharedProperty());
        assertEquals("shared prop", foo.getSharedProperty());
        assertEquals("bar prop", bar.getBarProperty());
        assertEquals("foo prop", foo.getFooProperty());
    }

    @Test
    public void testSuper(@Autowired List<Base> props) {
        assertEquals(2, props.size());
    }
}
Lenorelenox answered 29/12, 2018 at 3:47 Comment(3)
In our case, since we needed a custom deserialization algorithm that includes JSR 303-compliant validation, ConversionService was the right choice for us. Thank you.Steersman
I don't get your point about Base being a bean and Foo and Bar not. None of these are beans in the OP's example. Only SomeConfigurationProperties is a Component. And your answer gives no clue on how to configure the described list with mixed instances of Foo and Bar.Congeal
What if I want to deserialize a DTO that has inside it a field of type Base?Plantagenet

© 2022 - 2024 — McMap. All rights reserved.