51public final class ConfigBinder {
53 private static final Pattern INDEXED_KEY = Pattern.compile(
"^(.+)\\[(\\d+)](?:\\.|$)");
55 private ConfigBinder() {
59 return bindInternal(
config, resolvePrefix(
"", recordType), recordType);
62 public static <T extends Record> T
bind(
final EtherConfig config,
final String prefix,
final Class<T> recordType) {
63 return bindInternal(
config, resolvePrefix(prefix, recordType), recordType);
73 final Class<T> recordType) {
74 final var bound =
bind(
config, prefix, recordType);
79 private static <T extends Record> T bindInternal(
final EtherConfig config,
final String prefix,
80 final Class<T> recordType) {
81 if (!recordType.isRecord()) {
82 throw new IllegalArgumentException(
"Only record types are supported: " + recordType.getName());
86 final var components = recordType.getRecordComponents();
87 final var argTypes = Arrays.stream(components).map(RecordComponent::getType).toArray(Class<?>[]::new);
88 final var constructor = recordType.getDeclaredConstructor(argTypes);
89 constructor.setAccessible(
true);
90 final var args =
new Object[components.length];
91 final var snapshot =
config.snapshot();
93 for (var i = 0; i < components.length; i++) {
94 final var component = components[i];
95 final var key = join(prefix, componentName(component));
96 args[i] = bindValue(snapshot, key, component.getType(), component.getGenericType(),
true);
98 return constructor.newInstance(args);
99 }
catch (
final ReflectiveOperationException e) {
100 throw new IllegalStateException(
"Unable to bind config to record " + recordType.getName(), e);
104 private static Object bindValue(
final Map<String, String> snapshot,
final String key,
final Class<?> rawType,
105 final Type genericType,
final boolean required) {
106 if (rawType.isRecord()) {
107 if (!hasNestedKeys(snapshot, key)) {
109 throw new IllegalArgumentException(
"Missing config key: " + key);
113 return bindInternal(EtherConfig.of(
new MapConfigSource(
"nested", snapshot)), key,
114 rawType.asSubclass(Record.class));
117 if (List.class.isAssignableFrom(rawType)) {
118 return bindList(snapshot, key, genericType, required);
121 if (Map.class.isAssignableFrom(rawType)) {
122 return bindMap(snapshot, key, genericType, required);
125 final var raw = snapshot.get(key);
128 throw new IllegalArgumentException(
"Missing config key: " + key);
132 return convert(raw, rawType);
135 private static Object bindList(
final Map<String, String> snapshot,
final String key,
final Type genericType,
136 final boolean required) {
137 final Class<?> itemType = firstTypeArgument(genericType);
138 final var directValues = directIndexedValues(snapshot, key);
139 if (!directValues.isEmpty()) {
140 final List<Object> result =
new ArrayList<>(directValues.size());
141 for (
final String value : directValues) {
142 result.add(convert(value, itemType));
144 return List.copyOf(result);
147 if (snapshot.containsKey(key)) {
148 final var raw = snapshot.get(key);
149 if (raw ==
null || raw.isBlank()) {
152 final List<Object> result =
new ArrayList<>();
153 for (
final String part : raw.split(
",")) {
154 result.add(convert(part.trim(), itemType));
156 return List.copyOf(result);
159 if (itemType.isRecord()) {
160 final var indices = nestedListIndices(snapshot, key);
161 if (!indices.isEmpty()) {
162 final List<Object> result =
new ArrayList<>(indices.size());
163 for (
final Integer index : indices) {
164 result.add(bindInternal(EtherConfig.of(
new MapConfigSource(
"nested", snapshot)),
165 key +
"[" + index +
"]", itemType.asSubclass(Record.class)));
167 return List.copyOf(result);
174 private static Object bindMap(
final Map<String, String> snapshot,
final String key,
final Type genericType,
175 final boolean required) {
176 final Class<?> keyType = firstTypeArgument(genericType);
177 final Class<?> valueType = secondTypeArgument(genericType);
178 if (keyType != String.class) {
179 throw new IllegalArgumentException(
"Only Map<String, ?> is supported: " + key);
182 final Map<String, Object> result =
new LinkedHashMap<>();
183 final var entries = mapEntries(snapshot, key);
184 for (
final String entry : entries) {
185 final var entryPrefix = key +
"." + entry;
186 if (valueType.isRecord()) {
187 result.put(entry, bindInternal(EtherConfig.of(
new MapConfigSource(
"nested", snapshot)), entryPrefix,
188 valueType.asSubclass(Record.class)));
190 final var raw = snapshot.get(entryPrefix);
192 result.put(entry, convert(raw, valueType));
197 return Map.copyOf(result);
200 private static List<String> directIndexedValues(
final Map<String, String> snapshot,
final String key) {
201 final Map<Integer, String> values =
new LinkedHashMap<>();
202 for (
final var entry : snapshot.entrySet()) {
203 final var matcher = INDEXED_KEY.matcher(entry.getKey());
204 if (matcher.matches() && matcher.group(1).equals(key)) {
205 values.put(Integer.parseInt(matcher.group(2)), entry.getValue());
209 if (values.isEmpty()) {
213 final List<String> ordered =
new ArrayList<>(values.size());
214 for (var i = 0; i < values.size(); i++) {
215 if (!values.containsKey(i)) {
216 throw new IllegalArgumentException(
"List indices must be contiguous for key: " + key);
218 ordered.add(values.get(i));
223 private static Set<Integer> nestedListIndices(
final Map<String, String> snapshot,
final String key) {
224 final Set<Integer> indices =
new TreeSet<>();
225 final var prefix = key +
"[";
226 for (
final String candidate : snapshot.keySet()) {
227 if (candidate.startsWith(prefix)) {
228 final var close = candidate.indexOf(
']', prefix.length());
230 indices.add(Integer.parseInt(candidate.substring(prefix.length(), close)));
237 private static Set<String> mapEntries(
final Map<String, String> snapshot,
final String key) {
238 final Set<String> entries =
new LinkedHashSet<>();
239 final var prefix = key +
".";
240 for (
final String candidate : snapshot.keySet()) {
241 if (candidate.startsWith(prefix)) {
242 final var remainder = candidate.substring(prefix.length());
243 final var dotIndex = remainder.indexOf(
'.');
244 entries.add(dotIndex >= 0 ? remainder.substring(0, dotIndex) : remainder);
250 private static boolean hasNestedKeys(
final Map<String, String> snapshot,
final String key) {
251 final var prefix = key +
".";
252 final var listPrefix = key +
"[";
253 for (
final String candidate : snapshot.keySet()) {
254 if (candidate.equals(key) || candidate.startsWith(prefix) || candidate.startsWith(listPrefix)) {
261 private static String join(
final String prefix,
final String name) {
262 return prefix ==
null || prefix.isBlank() ? name : prefix +
"." + name;
265 private static String resolvePrefix(
final String explicitPrefix,
final Class<?> recordType) {
266 final var annotation = recordType.getAnnotation(ConfigPrefix.class);
267 if (annotation ==
null || annotation.value().isBlank()) {
268 return explicitPrefix ==
null ?
"" : explicitPrefix;
270 if (explicitPrefix ==
null || explicitPrefix.isBlank()) {
271 return annotation.value();
273 return explicitPrefix +
"." + annotation.value();
276 private static String componentName(
final RecordComponent component) {
277 final var alias = component.getAnnotation(ConfigAlias.class);
278 if (alias !=
null && !alias.value().isBlank()) {
279 return alias.value();
281 return component.getName();
284 private static Class<?> firstTypeArgument(
final Type genericType) {
285 if (genericType instanceof
final ParameterizedType parameterizedType) {
286 return rawClass(parameterizedType.getActualTypeArguments()[0]);
288 throw new IllegalArgumentException(
"Generic type information is required");
291 private static Class<?> secondTypeArgument(
final Type genericType) {
292 if (genericType instanceof
final ParameterizedType parameterizedType) {
293 return rawClass(parameterizedType.getActualTypeArguments()[1]);
295 throw new IllegalArgumentException(
"Generic type information is required");
298 private static Class<?> rawClass(
final Type type) {
299 if (type instanceof
final Class<?> clazz) {
302 if (type instanceof
final ParameterizedType parameterizedType) {
303 return (Class<?>) parameterizedType.getRawType();
305 throw new IllegalArgumentException(
"Unsupported generic type: " + type.getTypeName());
308 @SuppressWarnings({
"rawtypes",
"unchecked" })
309 private static Object convert(
final String raw,
final Class<?> targetType) {
310 if (targetType == String.class) {
313 if (targetType ==
int.
class || targetType == Integer.class) {
314 return Integer.parseInt(raw);
316 if (targetType ==
long.
class || targetType == Long.class) {
317 return Long.parseLong(raw);
319 if (targetType ==
boolean.
class || targetType == Boolean.class) {
320 return Boolean.parseBoolean(raw);
322 if (targetType ==
double.
class || targetType == Double.class) {
323 return Double.parseDouble(raw);
325 if (targetType == Duration.class) {
326 return Duration.parse(raw);
328 if (targetType == URI.class) {
329 return URI.create(raw);
331 if (targetType.isEnum()) {
332 return Enum.valueOf((Class<? extends Enum>) targetType.asSubclass(Enum.class),
333 raw.toUpperCase(Locale.ROOT));
335 throw new IllegalArgumentException(
"Unsupported config target type: " + targetType.getName());