Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Support validation in ConfigMappings.
Browse files Browse the repository at this point in the history
radcortez committed May 6, 2021

Verified

This commit was signed with the committer’s verified signature.
radcortez Roberto Cortez
1 parent 1f622b9 commit 72f9098
Showing 11 changed files with 604 additions and 10 deletions.
Original file line number Diff line number Diff line change
@@ -24,7 +24,7 @@
/**
* Information about a configuration interface.
*/
final class ConfigMappingInterface implements ConfigMappingMetadata {
public final class ConfigMappingInterface implements ConfigMappingMetadata {
static final ConfigMappingInterface[] NO_TYPES = new ConfigMappingInterface[0];
static final Property[] NO_PROPERTIES = new Property[0];
static final ClassValue<ConfigMappingInterface> cv = new ClassValue<ConfigMappingInterface>() {
@@ -56,7 +56,7 @@ protected ConfigMappingInterface computeValue(final Class<?> type) {
* @param interfaceType the interface type (must not be {@code null})
* @return the configuration interface, or {@code null} if the type does not appear to be a configuration interface
*/
static ConfigMappingInterface getConfigurationInterface(Class<?> interfaceType) {
public static ConfigMappingInterface getConfigurationInterface(Class<?> interfaceType) {
Assert.checkNotNullParam("interfaceType", interfaceType);
return cv.get(interfaceType);
}
@@ -99,7 +99,7 @@ ConfigMappingInterface getSuperType(int index) throws IndexOutOfBoundsException
return superTypes[index];
}

Property[] getProperties() {
public Property[] getProperties() {
return properties;
}

@@ -130,7 +130,7 @@ Property getProperty(final String name) {
return propertiesByName.get(name);
}

NamingStrategy getNamingStrategy() {
public NamingStrategy getNamingStrategy() {
return namingStrategy;
}

@@ -792,7 +792,7 @@ private static NamingStrategy getNamingStrategy(final Class<?> interfaceType) {
private static final NamingStrategy KEBAB_CASE_NAMING_STRATEGY = new KebabNamingStrategy();
private static final NamingStrategy SNAKE_CASE_NAMING_STRATEGY = new SnakeNamingStrategy();

interface NamingStrategy extends Function<String, String> {
public interface NamingStrategy extends Function<String, String> {

}

Original file line number Diff line number Diff line change
@@ -16,9 +16,11 @@
public final class ConfigMappings implements Serializable {
private static final long serialVersionUID = -7790784345796818526L;

private final ConfigValidator configValidator;
private final ConcurrentMap<Class<?>, Map<String, ConfigMappingObject>> mappings;

ConfigMappings() {
ConfigMappings(final ConfigValidator configValidator) {
this.configValidator = configValidator;
this.mappings = new ConcurrentHashMap<>();
}

@@ -76,11 +78,14 @@ <T> T getConfigMapping(Class<T> type, String prefix) {
throw ConfigMessages.msg.mappingPrefixNotFound(type.getName(), prefix);
}

Object value = configMappingObject;
if (configMappingObject instanceof ConfigMappingClassMapper) {
return type.cast(((ConfigMappingClassMapper) configMappingObject).map());
value = ((ConfigMappingClassMapper) configMappingObject).map();
}

return type.cast(configMappingObject);
configValidator.validateMapping(type, prefix, value);

return type.cast(value);
}

static String getPrefix(Class<?> type) {
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@
/**
* An exception which is thrown when a configuration validation problem occurs.
*/
public class ConfigValidationException extends Exception {
public class ConfigValidationException extends RuntimeException {
private static final long serialVersionUID = -2637730579475070264L;

private final Problem[] problems;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.smallrye.config;

public interface ConfigValidator {
void validateMapping(Class<?> mappingClass, String prefix, Object mappingObject) throws ConfigValidationException;

ConfigValidator EMPTY = (mappingClass, prefix, mappingObject) -> {
};
}
Original file line number Diff line number Diff line change
@@ -24,6 +24,7 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.OptionalInt;
@@ -54,12 +55,14 @@ public class SmallRyeConfigBuilder implements ConfigBuilder {
private final List<InterceptorWithPriority> interceptors = new ArrayList<>();
private final KeyMap<String> defaultValues = new KeyMap<>();
private final ConfigMappingProvider.Builder mappingsBuilder = ConfigMappingProvider.builder();
private ConfigValidator validator = ConfigValidator.EMPTY;
private ClassLoader classLoader = SecuritySupport.getContextClassLoader();
private boolean addDefaultSources = false;
private boolean addDefaultInterceptors = false;
private boolean addDiscoveredSources = false;
private boolean addDiscoveredConverters = false;
private boolean addDiscoveredInterceptors = false;
private boolean addDiscoveredValidator = false;

public SmallRyeConfigBuilder() {
}
@@ -81,6 +84,11 @@ public SmallRyeConfigBuilder addDiscoveredInterceptors() {
return this;
}

public SmallRyeConfigBuilder addDiscoveredValidator() {
addDiscoveredValidator = true;
return this;
}

List<ConfigSource> discoverSources() {
List<ConfigSource> discoveredSources = new ArrayList<>();
ServiceLoader<ConfigSource> configSourceLoader = ServiceLoader.load(ConfigSource.class, classLoader);
@@ -131,6 +139,15 @@ List<InterceptorWithPriority> discoverInterceptors() {
return interceptors;
}

ConfigValidator discoverValidator() {
ServiceLoader<ConfigValidator> validatorLoader = ServiceLoader.load(ConfigValidator.class, classLoader);
Iterator<ConfigValidator> iterator = validatorLoader.iterator();
if (iterator.hasNext()) {
return iterator.next();
}
return ConfigValidator.EMPTY;
}

@Override
public SmallRyeConfigBuilder addDefaultSources() {
addDefaultSources = true;
@@ -283,6 +300,11 @@ public SmallRyeConfigBuilder withValidateUnknown(boolean validateUnknown) {
return this;
}

public SmallRyeConfigBuilder withValidator(ConfigValidator validator) {
this.validator = validator;
return this;
}

@Override
public SmallRyeConfigBuilder withConverters(Converter<?>[] converters) {
for (Converter<?> converter : converters) {
@@ -335,6 +357,13 @@ List<InterceptorWithPriority> getInterceptors() {
return interceptors;
}

private ConfigValidator getValidator() {
if (isAddDiscoveredValidator()) {
this.validator = discoverValidator();
}
return validator;
}

KeyMap<String> getDefaultValues() {
return defaultValues;
}
@@ -359,13 +388,17 @@ boolean isAddDiscoveredInterceptors() {
return addDiscoveredInterceptors;
}

boolean isAddDiscoveredValidator() {
return addDiscoveredValidator;
}

@Override
public SmallRyeConfig build() {
ConfigMappingProvider mappingProvider = mappingsBuilder.build();
defaultValues.putAll(mappingProvider.getDefaultValues());

try {
ConfigMappings configMappings = new ConfigMappings();
ConfigMappings configMappings = new ConfigMappings(getValidator());
SmallRyeConfig config = new SmallRyeConfig(this, configMappings);
mappingProvider.mapConfiguration(config);
return config;
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -67,6 +67,7 @@
<module>common</module>
<module>implementation</module>
<module>cdi</module>
<module>validator</module>
<module>sources/hocon</module>
<module>sources/file-system</module>
<module>sources/yaml</module>
68 changes: 68 additions & 0 deletions validator/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2021 Red Hat, Inc.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>io.smallrye.config</groupId>
<artifactId>smallrye-config-parent</artifactId>
<version>2.2.1-SNAPSHOT</version>
</parent>

<artifactId>smallrye-config-validator</artifactId>

<name>SmallRye: MicroProfile Config Validator</name>

<properties>
<version.jakarta.validation>2.0.2</version.jakarta.validation>

<!-- Test -->
<version.hibernate.validator>6.2.0.Final</version.hibernate.validator>
<version.jakarta.el>3.0.3</version.jakarta.el>
</properties>

<dependencies>
<dependency>
<groupId>io.smallrye.config</groupId>
<artifactId>smallrye-config</artifactId>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>${version.jakarta.validation}</version>
<scope>provided</scope>
</dependency>

<!-- Test -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>${version.hibernate.validator}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.el</artifactId>
<version>${version.jakarta.el}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package io.smallrye.config.validator;

import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import javax.validation.ConstraintViolation;
import javax.validation.Path;
import javax.validation.Validator;

import io.smallrye.config.ConfigMappingInterface;
import io.smallrye.config.ConfigMappingInterface.CollectionProperty;
import io.smallrye.config.ConfigMappingInterface.MapProperty;
import io.smallrye.config.ConfigMappingInterface.NamingStrategy;
import io.smallrye.config.ConfigMappingInterface.Property;
import io.smallrye.config.ConfigValidationException;
import io.smallrye.config.ConfigValidationException.Problem;
import io.smallrye.config.ConfigValidator;

public interface BeanValidationConfigValidator extends ConfigValidator {

Validator getValidator();

@Override
default void validateMapping(
final Class<?> mappingClass,
final String prefix,
final Object mappingObject)
throws ConfigValidationException {

final List<Problem> problems = new ArrayList<>();
final ConfigMappingInterface mappingInterface = ConfigMappingInterface.getConfigurationInterface(mappingClass);
if (mappingInterface != null) {
validateMappingInterface(mappingInterface, prefix, mappingInterface.getNamingStrategy(), mappingObject, problems);
} else {
validateMappingClass(mappingObject, problems);
}

if (!problems.isEmpty()) {
throw new ConfigValidationException(problems.toArray(ConfigValidationException.Problem.NO_PROBLEMS));
}
}

default void validateMappingInterface(
final ConfigMappingInterface mappingInterface,
final String currentPath,
final NamingStrategy namingStrategy,
final Object mappingObject,
final List<Problem> problems) {

for (Property property : mappingInterface.getProperties()) {
validateProperty(property, currentPath, namingStrategy, mappingObject, false, problems);
}
}

default void validateProperty(
final Property property,
final String currentPath,
final NamingStrategy namingStrategy,
final Object mappingObject,
final boolean optional,
final List<Problem> problems) {

if (property.isOptional()) {
validateProperty(property.asOptional().getNestedProperty(), currentPath, namingStrategy, mappingObject, true,
problems);
}

if ((property.isLeaf() || property.isPrimitive()) && !property.isOptional()) {
validatePropertyValue(property, currentPath, namingStrategy, mappingObject, problems);
}

if (property.isGroup()) {
try {
Object group = property.getMethod().invoke(mappingObject);
// unwrap
if (optional) {
Optional<?> optionalGroup = (Optional<?>) group;
if (!optionalGroup.isPresent()) {
return;
}
group = optionalGroup.get();
}

validateMappingInterface(property.asGroup().getGroupType(), appendPropertyName(currentPath, property),
namingStrategy, group, problems);
} catch (IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
}

if (property.isCollection()) {
CollectionProperty collectionProperty = property.asCollection();
if (collectionProperty.getElement().isGroup()) {
try {
Collection<?> collection = (Collection<?>) property.getMethod().invoke(mappingObject);
int i = 0;
for (Object element : collection) {
validateMappingInterface(collectionProperty.getElement().asGroup().getGroupType(),
appendPropertyName(currentPath, property) + "[" + i + "]",
namingStrategy, element, problems);
i++;
}
} catch (IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
} else if (collectionProperty.getElement().isLeaf()) {
validateProperty(collectionProperty.getElement(), currentPath, namingStrategy, mappingObject, optional,
problems);
}
}

if (property.isMap()) {
MapProperty mapProperty = property.asMap();
if (mapProperty.getValueProperty().isGroup()) {
try {
Map<?, ?> map = (Map<?, ?>) property.getMethod().invoke(mappingObject);
for (Map.Entry<?, ?> entry : map.entrySet()) {
validateMappingInterface(mapProperty.getValueProperty().asGroup().getGroupType(),
appendPropertyName(currentPath, property) + "." + entry.getKey(),
namingStrategy, entry.getValue(), problems);
}
} catch (IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
} else if (mapProperty.getValueProperty().isLeaf()) {
validatePropertyValue(property, currentPath, namingStrategy, mappingObject, problems);
}
}
}

default void validatePropertyValue(
final Property property,
final String currentPath,
final NamingStrategy namingStrategy,
final Object mappingObject,
final List<Problem> problems) {

try {
Set<ConstraintViolation<Object>> violations = getValidator().forExecutables().validateReturnValue(mappingObject,
property.getMethod(),
property.getMethod().invoke(mappingObject));
for (ConstraintViolation<Object> violation : violations) {
problems.add(new Problem(interpolateMessage(currentPath, namingStrategy, property, violation)));
}
} catch (IllegalArgumentException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
}

default void validateMappingClass(final Object mappingObject, final List<Problem> problems) {
final Set<ConstraintViolation<Object>> violations = getValidator().validate(mappingObject);
for (ConstraintViolation<Object> violation : violations) {
problems.add(new Problem(violation.getPropertyPath() + " " + violation.getMessage()));
}
}

default String appendPropertyName(final String currentPath, final Property property) {
if (currentPath.isEmpty()) {
return property.getPropertyName();
}

if (property.getPropertyName().isEmpty()) {
return currentPath;
}

return currentPath + "." + property.getPropertyName();
}

default String interpolateMessage(
final String currentPath,
final NamingStrategy namingStrategy,
final Property property,
final ConstraintViolation<?> violation) {
StringBuilder propertyName = new StringBuilder(currentPath);
String name = namingStrategy.apply(property.getPropertyName());
if (!name.isEmpty()) {
propertyName.append(".").append(name);
}
Path propertyPath = violation.getPropertyPath();
for (Path.Node node : propertyPath) {
if (node.isInIterable()) {
if (node.getIndex() != null) {
propertyName.append("[").append(node.getIndex()).append("]");
} else if (node.getKey() != null) {
propertyName.append(".").append(node.getKey());
}
}
}
return propertyName.toString() + " " + violation.getMessage();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.smallrye.config.validator;

import javax.validation.Validation;
import javax.validation.Validator;

public class BeanValidationConfigValidatorImpl implements BeanValidationConfigValidator {
private Validator validator;

public BeanValidationConfigValidatorImpl() {
this.validator = Validation.buildDefaultValidatorFactory().getValidator();
}

@Override
public Validator getValidator() {
return validator;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package io.smallrye.config.validator;

import java.io.Serializable;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

import org.eclipse.microprofile.config.spi.ConfigSource;

public class KeyValuesConfigSource implements ConfigSource, Serializable {

private final Map<String, String> properties = new HashMap<>();

private KeyValuesConfigSource(Map<String, String> properties) {
this.properties.putAll(properties);
}

@Override
public Map<String, String> getProperties() {
return Collections.unmodifiableMap(properties);
}

@Override
public Set<String> getPropertyNames() {
return Collections.unmodifiableSet(properties.keySet());
}

@Override
public String getValue(String propertyName) {
return properties.get(propertyName);
}

@Override
public String getName() {
return "KeyValuesConfigSource";
}

public static ConfigSource config(Map<String, String> properties) {
return new KeyValuesConfigSource(properties);
}

public static ConfigSource config(String... keyValues) {
if (keyValues.length % 2 != 0) {
throw new IllegalArgumentException("keyValues array must be a multiple of 2");
}

Map<String, String> props = new HashMap<>();
for (int i = 0; i < keyValues.length; i += 2) {
props.put(keyValues[i], keyValues[i + 1]);
}
return new KeyValuesConfigSource(props);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package io.smallrye.config.validator;

import static io.smallrye.config.validator.KeyValuesConfigSource.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;

import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.Size;

import org.eclipse.microprofile.config.inject.ConfigProperties;
import org.junit.jupiter.api.Test;

import io.smallrye.config.ConfigMapping;
import io.smallrye.config.ConfigValidationException;
import io.smallrye.config.SmallRyeConfig;
import io.smallrye.config.SmallRyeConfigBuilder;
import io.smallrye.config.WithParentName;

public class ValidateConfigTest {
@Test
void validateConfigMapping() {
SmallRyeConfig config = new SmallRyeConfigBuilder()
.withValidator(new BeanValidationConfigValidatorImpl())
.withSources(config(
"server.host", "localhost",
"server.port", "8080",
"server.log.days", "20",
"server.proxy.enable", "true",
"server.proxy.timeout", "20",
"server.form.login-page", "login.html",
"server.form.error-page", "error.html",
"server.form.landing-page", "index.html",
"server.cors.origins[0].host", "some-server",
"server.cors.origins[0].port", "9000",
"server.cors.origins[1].host", "localhost",
"server.cors.origins[1].port", "1",
"server.cors.methods[0]", "GET",
"server.cors.methods[1]", "POST",
"server.info.name", "Bond",
"server.info.code", "007",
"server.info.alias[0]", "James",
"server.info.admins.root.username", "root"))
.withMapping(Server.class, "server")
.build();

ConfigValidationException validationException = assertThrows(ConfigValidationException.class,
() -> config.getConfigMapping(Server.class, "server"));
List<String> validations = new ArrayList<>();
for (int i = 0; i < validationException.getProblemCount(); i++) {
validations.add(validationException.getProblem(i).getMessage());
}
assertTrue(validations.contains("server.port must be less than or equal to 10"));
assertTrue(validations.contains("server.log.days must be less than or equal to 15"));
assertTrue(validations.contains("server.proxy.timeout must be less than or equal to 10"));
assertTrue(validations.contains("server.cors.origins[0].host size must be between 0 and 10"));
assertTrue(validations.contains("server.cors.origins[0].port must be less than or equal to 10"));
assertTrue(validations.contains("server.cors.methods[1] size must be between 0 and 3"));
assertTrue(validations.contains("server.form.login-page size must be between 0 and 3"));
assertTrue(validations.contains("server.form.error-page size must be between 0 and 3"));
assertTrue(validations.contains("server.form.landing-page size must be between 0 and 3"));
assertTrue(validations.contains("server.info.name size must be between 0 and 3"));
assertTrue(validations.contains("server.info.code must be less than or equal to 3"));
assertTrue(validations.contains("server.info.alias[0] size must be between 0 and 3"));
assertTrue(validations.contains("server.info.admins.root.username size must be between 0 and 3"));
}

@Test
void validateNamingStrategy() {
SmallRyeConfig config = new SmallRyeConfigBuilder()
.withValidator(new BeanValidationConfigValidatorImpl())
.withSources(config(
"server.the_host", "localhost",
"server.the_port", "8080"))
.withMapping(ServerNamingStrategy.class, "server")
.build();

ConfigValidationException validationException = assertThrows(ConfigValidationException.class,
() -> config.getConfigMapping(ServerNamingStrategy.class, "server"));
List<String> validations = new ArrayList<>();
for (int i = 0; i < validationException.getProblemCount(); i++) {
validations.add(validationException.getProblem(i).getMessage());
}

assertTrue(validations.contains("server.the_port must be less than or equal to 10"));
}

@Test
void validateConfigProperties() {
SmallRyeConfig config = new SmallRyeConfigBuilder()
.withValidator(new BeanValidationConfigValidatorImpl())
.withMapping(Client.class, "client")
.build();

ConfigValidationException validationException = assertThrows(ConfigValidationException.class,
() -> config.getConfigMapping(Client.class, "client"));
assertEquals(1, validationException.getProblemCount());
List<String> validations = new ArrayList<>();
validations.add(validationException.getProblem(0).getMessage());
assertTrue(validations.contains("port must be less than or equal to 10"));
}

@Test
void validateParent() {
SmallRyeConfig config = new SmallRyeConfigBuilder()
.withValidator(new BeanValidationConfigValidatorImpl())
.withSources(config(
"server.host", "localhost",
"server.port", "80"))
.withMapping(ServerParent.class, "server")
.build();

ConfigValidationException validationException = assertThrows(ConfigValidationException.class,
() -> config.getConfigMapping(ServerParent.class, "server"));
assertEquals("server.port must be greater than or equal to 8000", validationException.getProblem(0).getMessage());
}

@ConfigMapping(prefix = "server")
public interface Server {
String host();

@Max(10)
int port();

Map<String, @Size(max = 3) String> form();

Optional<Proxy> proxy();

Log log();

Cors cors();

Info info();

interface Proxy {
boolean enable();

@Max(10)
int timeout();
}

interface Log {
@Max(15)
int days();
}

interface Cors {
List<Origin> origins();

List<@Size(max = 3) String> methods();

interface Origin {
@Size(max = 10)
String host();

@Max(10)
int port();
}
}

interface Info {
Optional<@Size(max = 3) String> name();

@Max(3)
OptionalInt code();

Optional<List<@Size(max = 3) String>> alias();

Map<String, Admin> admins();

interface Admin {
@Size(max = 3)
String username();
}
}
}

@ConfigMapping(prefix = "server", namingStrategy = ConfigMapping.NamingStrategy.SNAKE_CASE)
public interface ServerNamingStrategy {
String theHost();

@Max(10)
int thePort();
}

@ConfigProperties(prefix = "client")
public static class Client {
public String host = "localhost";
@Max(10)
public int port = 8080;
}

@ConfigMapping(prefix = "server")
public interface ServerParent {
@WithParentName
Parent parent();

interface Parent {
String host();

@Min(8000)
int port();
}
}
}

0 comments on commit 72f9098

Please sign in to comment.