diff --git a/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverter.java b/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverter.java index d1817e9c3..60f0c9a0e 100644 --- a/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverter.java +++ b/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverter.java @@ -2,19 +2,247 @@ import com.mageddo.dnsproxyserver.config.provider.dataformatv3.ConfigV3; +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; + public class EnvConverter implements Converter { + + static final String PREFIX = "DPS_"; + + private final Map environment; + + public EnvConverter() { + this(System.getenv()); + } + + EnvConverter(Map environment) { + this.environment = new HashMap<>(environment); + } + @Override public ConfigV3 parse() { - return null; + return new Parser(this.environment).parse(); } @Override public String serialize(ConfigV3 config) { - return ""; + throw new UnsupportedOperationException("Not implemented yet"); } @Override public int priority() { return 0; } + + private static final class Parser { + + private static final Map, Map> CACHE = new ConcurrentHashMap<>(); + public static final String FIELD_AND_CASE_SEPARATOR = "_"; + + private final Map environment; + + private Parser(Map environment) { + this.environment = environment; + } + + private ConfigV3 parse() { + final var config = new ConfigV3(); + this.filterMatchingVariables() + .forEach(entry -> this.apply(config, entry.getKey(), entry.getValue())); + return config; + } + + private Stream> filterMatchingVariables() { + return this.environment + .entrySet() + .stream() + .filter(entry -> isTargetVariable(entry.getKey(), entry.getValue())); + } + + private static boolean isTargetVariable(String key, String value) { + return key != null && key.startsWith(PREFIX) && value != null; + } + + private void apply(ConfigV3 config, String key, String value) { + final var tokens = this.buildTokens(key); + this.setValue(config, ConfigV3.class, tokens, value); + } + + private Deque buildTokens(String key) { + final var rawPath = key.substring(PREFIX.length()); + return new ArrayDeque<>(List.of(rawPath.split(FIELD_AND_CASE_SEPARATOR))); + } + + private void setValue(Object current, Class type, Deque tokens, String value) { + final var match = this.matchField(type, tokens); + consume(tokens, match.segmentsConsumed()); + + try { + final var field = match.field(); + final var fieldType = field.getType(); + if (List.class.isAssignableFrom(fieldType)) { + setListValue(current, field, tokens, value); + return; + } + + if (tokens.isEmpty()) { + final var convertedValue = convert(value, fieldType); + field.set(current, convertedValue); + return; + } + + var nestedValue = field.get(current); + if (nestedValue == null) { + nestedValue = instantiate(fieldType); + field.set(current, nestedValue); + } + setValue(nestedValue, fieldType, tokens, value); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to set value for key: " + match.field() + .getName(), e); + } + } + + private void setListValue(Object current, Field field, Deque tokens, String value) + throws ReflectiveOperationException { + + if (tokens.isEmpty()) { + throw new IllegalArgumentException("Missing list index for field " + field.getName()); + } + + final var index = Integer.parseInt(tokens.removeFirst()); + final var list = ensureList(current, field); + ensureCapacity(list, index); + + final var elementType = resolveListElementType(field); + if (tokens.isEmpty()) { + list.set(index, convert(value, elementType)); + return; + } + + var item = list.get(index); + if (item == null) { + item = instantiate(elementType); + list.set(index, item); + } + setValue(item, elementType, tokens, value); + } + + private static List ensureList(Object current, Field field) throws IllegalAccessException { + @SuppressWarnings("unchecked") var list = (List) field.get(current); + if (list == null) { + list = new ArrayList<>(); + field.set(current, list); + } + return list; + } + + private static void ensureCapacity(List list, int index) { + while (list.size() <= index) { + list.add(null); + } + } + + private static Object convert(String value, Class targetType) { + if (Objects.equals(targetType, String.class)) { + return value; + } + if (Objects.equals(targetType, Integer.class) || targetType == int.class) { + return Integer.valueOf(value); + } + if (Objects.equals(targetType, Boolean.class) || targetType == boolean.class) { + return Boolean.valueOf(value); + } + if (Objects.equals(targetType, Long.class) || targetType == long.class) { + return Long.valueOf(value); + } + throw new IllegalArgumentException("Unsupported conversion to " + targetType); + } + + private static Object instantiate(Class type) { + try { + return type.getDeclaredConstructor() + .newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Could not instantiate " + type.getName(), e); + } + } + + private FieldMatch matchField(Class type, Deque tokens) { + final var fields = CACHE.computeIfAbsent(type, Parser::loadFields); + final var iterator = tokens.iterator(); + final var consumed = new ArrayList(); + final var builder = new StringBuilder(); + + while (iterator.hasNext()) { + final var segment = iterator.next(); + if (!consumed.isEmpty()) { + builder.append(FIELD_AND_CASE_SEPARATOR); + } + builder.append(segment); + consumed.add(segment); + final var field = fields.get(builder.toString()); + if (field != null) { + return new FieldMatch(field, consumed.size()); + } + } + throw new IllegalArgumentException("Unknown configuration path: " + String.join(FIELD_AND_CASE_SEPARATOR, tokens)); + } + + private static Map loadFields(Class type) { + final var fields = new HashMap(); + for (Field field : type.getFields()) { + if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) { + continue; + } + field.setAccessible(true); + fields.put(toEnvKey(field.getName()), field); + } + return fields; + } + + private static String toEnvKey(String name) { + final var builder = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + final var ch = name.charAt(i); + if (Character.isUpperCase(ch)) { + builder.append('_'); + } + builder.append(Character.toUpperCase(ch)); + } + return builder.toString(); + } + + private static void consume(Deque tokens, int items) { + for (int i = 0; i < items; i++) { + tokens.removeFirst(); + } + } + + private static Class resolveListElementType(Field field) { + final var type = field.getGenericType(); + if (type instanceof ParameterizedType parameterizedType) { + final var actualType = parameterizedType.getActualTypeArguments()[0]; + return extractClass(actualType); + } + throw new IllegalArgumentException("Unable to resolve list element type for field " + field.getName()); + } + + private static Class extractClass(Type type) { + if (type instanceof Class clazz) { + return clazz; + } + if (type instanceof ParameterizedType parameterizedType) { + return extractClass(parameterizedType.getRawType()); + } + throw new IllegalArgumentException("Unsupported type: " + type); + } + + private record FieldMatch(Field field, int segmentsConsumed) { + } + } } diff --git a/src/test/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverterTest.java b/src/test/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverterTest.java new file mode 100644 index 000000000..13f39331e --- /dev/null +++ b/src/test/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverterTest.java @@ -0,0 +1,61 @@ +package com.mageddo.dnsproxyserver.config.provider.dataformatv3.converter; + +import com.mageddo.dnsproxyserver.config.provider.dataformatv3.ConfigV3; +import com.mageddo.dnsproxyserver.config.provider.dataformatv3.templates.ConfigV3Templates; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static java.util.Map.entry; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EnvConverterTest { + + @Test + void shouldParseEnvironmentVariablesIntoConfigV3() { + var expected = new ConfigV3Templates().build(); + + var converter = new EnvConverter(Map.ofEntries( + entry("DPS_VERSION", "3"), + entry("DPS_SERVER_DNS_PORT", "53"), + entry("DPS_SERVER_DNS_NO_ENTRIES_RESPONSE_CODE", "3"), + entry("DPS_SERVER_WEB_PORT", "5380"), + entry("DPS_SERVER_PROTOCOL", "UDP_TCP"), + entry("DPS_SOLVER_REMOTE_ACTIVE", "true"), + entry("DPS_SOLVER_REMOTE_DNS_SERVERS_0", "8.8.8.8"), + entry("DPS_SOLVER_REMOTE_DNS_SERVERS_1", "4.4.4.4:53"), + entry("DPS_SOLVER_REMOTE_CIRCUIT_BREAKER_NAME", "STATIC_THRESHOLD"), + entry("DPS_SOLVER_DOCKER_REGISTER_CONTAINER_NAMES", "false"), + entry("DPS_SOLVER_DOCKER_DOMAIN", "docker"), + entry("DPS_SOLVER_DOCKER_HOST_MACHINE_FALLBACK", "true"), + entry("DPS_SOLVER_DOCKER_DPS_NETWORK_NAME", "dps"), + entry("DPS_SOLVER_DOCKER_DPS_NETWORK_AUTO_CREATE", "false"), + entry("DPS_SOLVER_DOCKER_DPS_NETWORK_AUTO_CONNECT", "false"), + entry("DPS_SOLVER_SYSTEM_HOST_MACHINE_HOSTNAME", "host.docker"), + entry("DPS_SOLVER_LOCAL_ACTIVE_ENV", ""), + entry("DPS_SOLVER_LOCAL_ENVS_0_NAME", ""), + entry("DPS_SOLVER_LOCAL_ENVS_0_HOSTNAMES_0_TYPE", "A"), + entry("DPS_SOLVER_LOCAL_ENVS_0_HOSTNAMES_0_HOSTNAME", "github.com"), + entry("DPS_SOLVER_LOCAL_ENVS_0_HOSTNAMES_0_IP", "192.168.0.1"), + entry("DPS_SOLVER_LOCAL_ENVS_0_HOSTNAMES_0_TTL", "255"), + entry("DPS_SOLVER_STUB_DOMAIN_NAME", "stub"), + entry("DPS_DEFAULT_DNS_ACTIVE", "true"), + entry("DPS_DEFAULT_DNS_RESOLV_CONF_PATHS", "/host/etc/systemd/resolved.conf,/host/etc/resolv.conf,/etc/systemd/resolved.conf,/etc/resolv.conf"), + entry("DPS_DEFAULT_DNS_RESOLV_CONF_OVERRIDE_NAME_SERVERS", "true"), + entry("DPS_LOG_LEVEL", "DEBUG"), + entry("DPS_LOG_FILE", "console") + )); + + ConfigV3 actual = converter.parse(); + + assertEquals(expected, actual); + } + + @Test + void shouldFailWhenUnknownVariableIsProvided() { + var converter = new EnvConverter(Map.of("DPS_UNKNOWN", "value")); + + assertThrows(IllegalArgumentException.class, converter::parse); + } +}