Ether Framework
Unified API docs for Ether modules
Loading...
Searching...
No Matches
ConfigBinder.java
Go to the documentation of this file.
1package dev.rafex.ether.config.binding;
2
3/*-
4 * #%L
5 * ether-config
6 * %%
7 * Copyright (C) 2025 - 2026 Raúl Eduardo González Argote
8 * %%
9 * Permission is hereby granted, free of charge, to any person obtaining a copy
10 * of this software and associated documentation files (the "Software"), to deal
11 * in the Software without restriction, including without limitation the rights
12 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 * copies of the Software, and to permit persons to whom the Software is
14 * furnished to do so, subject to the following conditions:
15 *
16 * The above copyright notice and this permission notice shall be included in
17 * all copies or substantial portions of the Software.
18 *
19 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25 * THE SOFTWARE.
26 * #L%
27 */
28
29import java.lang.reflect.ParameterizedType;
30import java.lang.reflect.RecordComponent;
31import java.lang.reflect.Type;
32import java.net.URI;
33import java.time.Duration;
34import java.util.ArrayList;
35import java.util.Arrays;
36import java.util.LinkedHashMap;
37import java.util.LinkedHashSet;
38import java.util.List;
39import java.util.Locale;
40import java.util.Map;
41import java.util.Set;
42import java.util.TreeSet;
43import java.util.regex.Pattern;
44
45import dev.rafex.ether.config.EtherConfig;
46import dev.rafex.ether.config.annotations.ConfigAlias;
47import dev.rafex.ether.config.annotations.ConfigPrefix;
48import dev.rafex.ether.config.sources.MapConfigSource;
49import dev.rafex.ether.config.validation.ConfigValidator;
50
51public final class ConfigBinder {
52
53 private static final Pattern INDEXED_KEY = Pattern.compile("^(.+)\\[(\\d+)](?:\\.|$)");
54
55 private ConfigBinder() {
56 }
57
58 public static <T extends Record> T bind(final EtherConfig config, final Class<T> recordType) {
59 return bindInternal(config, resolvePrefix("", recordType), recordType);
60 }
61
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);
64 }
65
66 public static <T extends Record> T bindValidated(final EtherConfig config, final Class<T> recordType) {
67 final var bound = bind(config, recordType);
69 return bound;
70 }
71
72 public static <T extends Record> T bindValidated(final EtherConfig config, final String prefix,
73 final Class<T> recordType) {
74 final var bound = bind(config, prefix, recordType);
76 return bound;
77 }
78
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());
83 }
84
85 try {
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();
92
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);
97 }
98 return constructor.newInstance(args);
99 } catch (final ReflectiveOperationException e) {
100 throw new IllegalStateException("Unable to bind config to record " + recordType.getName(), e);
101 }
102 }
103
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)) {
108 if (required) {
109 throw new IllegalArgumentException("Missing config key: " + key);
110 }
111 return null;
112 }
113 return bindInternal(EtherConfig.of(new MapConfigSource("nested", snapshot)), key,
114 rawType.asSubclass(Record.class));
115 }
116
117 if (List.class.isAssignableFrom(rawType)) {
118 return bindList(snapshot, key, genericType, required);
119 }
120
121 if (Map.class.isAssignableFrom(rawType)) {
122 return bindMap(snapshot, key, genericType, required);
123 }
124
125 final var raw = snapshot.get(key);
126 if (raw == null) {
127 if (required) {
128 throw new IllegalArgumentException("Missing config key: " + key);
129 }
130 return null;
131 }
132 return convert(raw, rawType);
133 }
134
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));
143 }
144 return List.copyOf(result);
145 }
146
147 if (snapshot.containsKey(key)) {
148 final var raw = snapshot.get(key);
149 if (raw == null || raw.isBlank()) {
150 return List.of();
151 }
152 final List<Object> result = new ArrayList<>();
153 for (final String part : raw.split(",")) {
154 result.add(convert(part.trim(), itemType));
155 }
156 return List.copyOf(result);
157 }
158
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)));
166 }
167 return List.copyOf(result);
168 }
169 }
170
171 return List.of();
172 }
173
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);
180 }
181
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)));
189 } else {
190 final var raw = snapshot.get(entryPrefix);
191 if (raw != null) {
192 result.put(entry, convert(raw, valueType));
193 }
194 }
195 }
196
197 return Map.copyOf(result);
198 }
199
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());
206 }
207 }
208
209 if (values.isEmpty()) {
210 return List.of();
211 }
212
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);
217 }
218 ordered.add(values.get(i));
219 }
220 return ordered;
221 }
222
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());
229 if (close > 0) {
230 indices.add(Integer.parseInt(candidate.substring(prefix.length(), close)));
231 }
232 }
233 }
234 return indices;
235 }
236
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);
245 }
246 }
247 return entries;
248 }
249
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)) {
255 return true;
256 }
257 }
258 return false;
259 }
260
261 private static String join(final String prefix, final String name) {
262 return prefix == null || prefix.isBlank() ? name : prefix + "." + name;
263 }
264
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;
269 }
270 if (explicitPrefix == null || explicitPrefix.isBlank()) {
271 return annotation.value();
272 }
273 return explicitPrefix + "." + annotation.value();
274 }
275
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();
280 }
281 return component.getName();
282 }
283
284 private static Class<?> firstTypeArgument(final Type genericType) {
285 if (genericType instanceof final ParameterizedType parameterizedType) {
286 return rawClass(parameterizedType.getActualTypeArguments()[0]);
287 }
288 throw new IllegalArgumentException("Generic type information is required");
289 }
290
291 private static Class<?> secondTypeArgument(final Type genericType) {
292 if (genericType instanceof final ParameterizedType parameterizedType) {
293 return rawClass(parameterizedType.getActualTypeArguments()[1]);
294 }
295 throw new IllegalArgumentException("Generic type information is required");
296 }
297
298 private static Class<?> rawClass(final Type type) {
299 if (type instanceof final Class<?> clazz) {
300 return clazz;
301 }
302 if (type instanceof final ParameterizedType parameterizedType) {
303 return (Class<?>) parameterizedType.getRawType();
304 }
305 throw new IllegalArgumentException("Unsupported generic type: " + type.getTypeName());
306 }
307
308 @SuppressWarnings({ "rawtypes", "unchecked" })
309 private static Object convert(final String raw, final Class<?> targetType) {
310 if (targetType == String.class) {
311 return raw;
312 }
313 if (targetType == int.class || targetType == Integer.class) {
314 return Integer.parseInt(raw);
315 }
316 if (targetType == long.class || targetType == Long.class) {
317 return Long.parseLong(raw);
318 }
319 if (targetType == boolean.class || targetType == Boolean.class) {
320 return Boolean.parseBoolean(raw);
321 }
322 if (targetType == double.class || targetType == Double.class) {
323 return Double.parseDouble(raw);
324 }
325 if (targetType == Duration.class) {
326 return Duration.parse(raw);
327 }
328 if (targetType == URI.class) {
329 return URI.create(raw);
330 }
331 if (targetType.isEnum()) {
332 return Enum.valueOf((Class<? extends Enum>) targetType.asSubclass(Enum.class),
333 raw.toUpperCase(Locale.ROOT));
334 }
335 throw new IllegalArgumentException("Unsupported config target type: " + targetType.getName());
336 }
337}
static< T extends Record > T bindValidated(final EtherConfig config, final Class< T > recordType)
static< T extends Record > T bindValidated(final EtherConfig config, final String prefix, final Class< T > recordType)
static< T extends Record > T bind(final EtherConfig config, final Class< T > recordType)
static< T extends Record > T bind(final EtherConfig config, final String prefix, final Class< T > recordType)
static void validate(final Object instance)
Typed configuration entry points and composition APIs for Ether applications.