Given then following YAML configuration
a:
b:
instances:
- name: T1
endpoint: "http://t1"
version: "1.5.3.24305,2021-08-09 18:01"
read-timeout: 20
- name: T2
endpoint: "http://t1"
version: "2.0.0.16555,2022-01-03 16:48"
read-timeout: 20
I would like to bind it to my immutable configuration interface using Micronaut 2.5.13.
@ConfigurationProperties("a.b")
public interface MyConfig {
List<Instance> getInstances();
interface Instance {
String getName();
URL getEndpoint();
String getVersion();
int getReadTimeout();
}
}
when I then run my Spock integration test
@MicronautTest
class MyConfigSpec extends Specification {
@Inject
MyConfig config
void "Make sure the config has instances"() {
expect:
config.instances.isEmpty() == false
}
}
the assertions fail since the list is empty. How can I tell Micronaut to map the instances
list into my immutable Instance
object?
The problem is caused by the missing TypeConverter<S,T>
that knows how to create an instance of MyConfig.Instance
from another type. The property source resolver sees a.b.instances
configuration as a List<Map>
, so for every entry it tries to find a converter that satisfies Map
-> MyConfig.Instance
. Because there is no one that explicitly satisfies such conversion, it falls back to Map
-> Object
type converter. This converter uses Jackson's serialization and deserialization, and it also does not work because Jackson does not know how to deserialize a JSON like this:
{
"name": "T1",
"endpoint": "http://t1",
"version": "1.5.3.24305,2021-08-09 18:01",
"read-timeout": 20
}
to an instance of MyConfig.Instance
, because there is no implementation of that interface available for the Jackson deserializer.
Implementing TypeConverter<Map, MyConfig.Instance>
bean can solve this problem. It can return an anonymous class that implements the interface that returns all values from the input map.
import io.micronaut.context.annotation.ConfigurationProperties;
import io.micronaut.core.convert.ConversionContext;
import io.micronaut.core.convert.TypeConverter;
import javax.inject.Singleton;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ConfigurationProperties("a.b")
public interface MyConfig {
List<Instance> getInstances();
interface Instance {
String getName();
URL getEndpoint();
String getVersion();
int getReadTimeout();
}
@Singleton
class MapToInstanceConverter implements TypeConverter<Map, Instance> {
@Override
public Optional<Instance> convert(Map object, Class<Instance> targetType, ConversionContext context) {
return Optional.of(new Instance() {
@Override
public String getName() {
return object.getOrDefault("name", "").toString();
}
@Override
public URL getEndpoint() {
try {
return new URI(object.getOrDefault("endpoint", "").toString()).toURL();
} catch (MalformedURLException | URISyntaxException e) {
throw new RuntimeException(e);
}
}
@Override
public String getVersion() {
return object.getOrDefault("version", "").toString();
}
@Override
public int getReadTimeout() {
return Integer.parseInt(object.getOrDefault("read-timeout", 0).toString());
}
});
}
}
}
You will see two entries returned by the MyConfig.getInstances()
method when you run the test now.
"Hold on", you may ask, "why does it work when I define the Instance getInstance()
method without the need of specifying any type converter?". That's the right question to ask. As you may already tried, the following example works just fine, without providing any additional type converter:
@ConfigurationProperties("a.b")
public interface MyConfig {
Instance getInstance();
@ConfigurationProperties("instance")
interface Instance {
String getName();
URL getEndpoint();
String getVersion();
int getReadTimeout();
}
}
The main difference between those two examples is that the previous one uses a list with the generic type - List<Instance>
. The immutable configuration mechanism uses the ConfigurationIntroductionAdvice.intercept(ctx)
method to invoke every getter method you define in the interface. The problem here is that the generic type gets erased in the runtime, and thus the return type of the getInstances()
method is recognized as a List
, not List<Instance>
. You can add a breakpoint on line 66 in the ConfigurationIntroductionAdvice
class and debug the test to see what happens. When the return type is Instance
from the second example, the intercept(ctx)
method recognizes that the Instance
type is a member of ConfigurationAdvice
through the @ConfigurationProperties
annotation. In this case, it uses the interceptors to invoke each method from the Instance
interface - the same mechanism it applies to the MyConfig
interface. When the same check is done for the List getInstances()
method, it does not recognize the List
type as a part of the ConfigurationAdvice
, and thus it treats it as a regular type, that is not wrapped in any interceptors but just used directly as any other class.