Strumbles
Strumbles

Reputation: 436

@ConditionalOnProperty for lists or arrays?

I'm using Spring Boot 1.4.3 @AutoConfiguration where I create beans automatically based on properties user specifies. User can specify an array of services, where name and version are required fields:

service[0].name=myServiceA
service[0].version=1.0

service[1].name=myServiceB
service[1].version=1.2

...

If the user forgets to specify a required field on even just one service, I want to back-off and not create any beans. Can I accomplish this with @ConditionalOnProperty? I want something like:

@Configuration
@ConditionalOnProperty({"service[i].name", "service[i].version"})
class AutoConfigureServices {
....
} 

Upvotes: 8

Views: 8702

Answers (5)

nekperu15739
nekperu15739

Reputation: 3698

As i understand, your problem its how to validate mandatory fields, for it my suggestion will be use a @ConfigurationProperties("root") annotation, and then add all fields as @NotNull like this:

@Getter
@Validated
@RequiredArgsConstructor
@ConfigurationProperties("root")
public class YourProperties {

  private final Set<Item> service;

  @Getter
  @Validated
  @RequiredArgsConstructor
  public static class Item {

    @NotNull
    private final String name;

    @NotNull
    private final String version;
  }
}

In case you prefer continue with conditional approach you could use ConditionalOnExpression, however, should be aware that number of items its unlimited:

@ConditionalOnExpression("#{T(org.springframework.util.StringUtils).hasText('${service[0].name}')}")

Upvotes: 0

Adrian Baker
Adrian Baker

Reputation: 10009

You can leverage the org.springframework.boot.autoconfigure.condition.OnPropertyListCondition class. For example, given you want to check for the service property having at least one value:

class MyListCondition extends OnPropertyListCondition {
    MyListCondition() {
        super("service", () -> ConditionMessage.forCondition("service"));
    }
}

@Configuration
@Condition(MyListCondition.class)
class AutoConfigureServices {

}

See the org.springframework.boot.autoconfigure.webservices.OnWsdlLocationsCondition used on org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration#wsdlDefinitionBeanFactoryPostProcessor for an example within Spring itself.

Upvotes: 3

Tomasz Krug
Tomasz Krug

Reputation: 71

Here's my take on this issue with the use of custom conditions in Spring autoconfiguration. Somewhat similar to what @Strumbels proposed but more reusable.

@Conditional annotations are executed very early in during the application startup. Properties sources are already loaded but ConfgurationProperties beans are not yet created. However we can work around that issue by binding properties to Java POJO ourselves.

First I introduce a functional interface which will enable us to define any custom logic checking if properties are in fact present or not. In your case this method will take care of checking if the property List is empty/null and if all items within are valid.

public interface OptionalProperties {
  boolean isPresent();
}

Now let's create an annotation which will be metannotated with Spring @Conditional and allow us to define custom parameters. prefix represents the property namespace and targetClass represents the configuration properties model class to which properties should be mapped.

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(OnConfigurationPropertiesCondition.class)
public @interface ConditionalOnConfigurationProperties {

  String prefix();

  Class<? extends OptionalProperties> targetClass();

}

And now the main part. The custom condition implementation.

public class OnConfigurationPropertiesCondition extends SpringBootCondition {

  @Override
  public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
    MergedAnnotation<ConditionalOnConfigurationProperties> mergedAnnotation = metadata.getAnnotations().get(ConditionalOnConfigurationProperties.class);
    String prefix = mergedAnnotation.getString("prefix");
    Class<?> targetClass = mergedAnnotation.getClass("targetClass");
    // type precondition
    if (!OptionalProperties.class.isAssignableFrom(targetClass)) {
      return ConditionOutcome.noMatch("Target type does not implement the OptionalProperties interface.");
    }
    // the crux of this solution, binding properties to Java POJO
    Object bean = Binder.get(context.getEnvironment()).bind(prefix, targetClass).orElse(null);
    // if properties are not present at all return no match
    if (bean == null) {
      return ConditionOutcome.noMatch("Binding properties to target type resulted in null value.");
    }
    OptionalProperties props = (OptionalProperties) bean;

    // execute method from OptionalProperties interface 
    // to check if condition should be matched or not
    // can include any custom logic using property values in a type safe manner
    if (props.isPresent()) {
      return ConditionOutcome.match();
    } else {
      return ConditionOutcome.noMatch("Properties are not present.");
    }
  }

}

Now you should create your own configuration properties class implementing OptionalProperties interface.

@ConfigurationProperties("your.property.prefix")
@ConstructorBinding
public class YourConfigurationProperties implements OptionalProperties {

  // Service is your POJO representing the name and version subproperties
  private final List<Service> services;

  @Override
  public boolean isPresent() {
    return services != null && services.stream().all(Service::isValid);
  }

}

And then in Spring @Configuration class.

@Configuration
@ConditionalOnConfigurationProperties(prefix = "", targetClass = YourConfigurationProperties.class)
class AutoConfigureServices {
....
} 

There are two downsides to this solution:

  • Property prefix must be specified in two locations: on @ConfigurationProperties annotation and on @ConditionalOnConfigurationProperties annotation. This can partially be alleviated by defining a public static final String PREFIX = "namespace" in your configuration properties POJO.
  • Property binding process is executed separately for each use of our custom conditional annotation and then once again to create the configuration properties bean itself. It happens only during app startup so it shouldn't be an issue but it still is an inefficiency.

Upvotes: 0

zegee29
zegee29

Reputation: 994

Old question, but I hope my answer will help for Spring2.x: Thanks to @Brian, I checked migration guide, where I was inspired by example code. This code works for me:

final List<String> services = Binder.get(context.getEnvironment()).bind("my.services", List.class).orElse(null);

I did try to get List of POJO (as AutoConfigureService) but my class differs from AutoConfigureServices. For that purpose, I used:

final Services services = Binder.get(context.getEnvironment()).bind("my.services", Services.class).orElse(null);

Well, keep playing :-D

Upvotes: 0

Strumbles
Strumbles

Reputation: 436

This is the custom Condition I created. It needs some polishing to be more generic (ie not hardcoding strings), but worked great for me.

To use, I annotated my Configuration class with @Conditional(RequiredRepeatablePropertiesCondition.class)

public class RequiredRepeatablePropertiesCondition extends SpringBootCondition {

    private static final Logger LOGGER = LoggerFactory.getLogger(RequiredRepeatablePropertiesCondition.class.getName());

    public static final String[] REQUIRED_KEYS = {
            "my.services[i].version",
            "my.services[i].name"
    };

    @Override
    public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
        List<String> missingProperties = new ArrayList<>();
        RelaxedPropertyResolver resolver = new RelaxedPropertyResolver(context.getEnvironment());
        Map<String, Object> services = resolver.getSubProperties("my.services");
        if (services.size() == 0) {
            missingProperties.addAll(Arrays.asList(REQUIRED_KEYS));
            return getConditionOutcome(missingProperties);
        }
        //gather indexes to check: [0], [1], [3], etc
        Pattern p = Pattern.compile("\\[(\\d+)\\]");
        Set<String> uniqueIndexes = new HashSet<String>();
        for (String key : services.keySet()) {
            Matcher m = p.matcher(key);
            if (m.find()) {
                uniqueIndexes.add(m.group(1));
            }
        }
        //loop each index and check required props
        uniqueIndexes.forEach(index -> {
            for (String genericKey : REQUIRED_KEYS) {
                String multiServiceKey = genericKey.replace("[i]", "[" + index + "]");
                if (!resolver.containsProperty(multiServiceKey)) {
                    missingProperties.add(multiServiceKey);
                }
            }
        });
        return getConditionOutcome(missingProperties);
    }

    private ConditionOutcome getConditionOutcome(List<String> missingProperties) {
        if (missingProperties.isEmpty()) {
            return ConditionOutcome.match(ConditionMessage.forCondition(RequiredRepeatablePropertiesCondition.class.getCanonicalName())
                    .found("property", "properties")
                    .items(Arrays.asList(REQUIRED_KEYS)));
        }
        return ConditionOutcome.noMatch(
                ConditionMessage.forCondition(RequiredRepeatablePropertiesCondition.class.getCanonicalName())
            .didNotFind("property", "properties")
            .items(missingProperties)
        );
    }
}

Upvotes: 2

Related Questions