Created
February 19, 2025 09:45
-
-
Save ccjmne/5920d65bf6d715a3a41ca300e58587dd to your computer and use it in GitHub Desktop.
Configuration reader
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.mercateo.creditcardx.service; | |
import java.io.BufferedReader; | |
import java.io.IOException; | |
import java.io.InputStreamReader; | |
import java.net.MalformedURLException; | |
import java.net.URL; | |
import java.nio.file.Paths; | |
import java.util.*; | |
import java.util.regex.Matcher; | |
import java.util.regex.Pattern; | |
import java.util.stream.Collectors; | |
import java.util.stream.Stream; | |
public class Props { | |
private static final Comparator<String> KEY_MATCHER = Comparator.comparing(a -> a.toLowerCase().replaceAll("[_-]|!$", "")); | |
// Both of these will perform fuzzy matching on keys, so that e.g. "foo-bar", "foo_bar" and "fooBar" are considered equal | |
private final TreeMap<String, String> source; | |
private final TreeSet<String> guaranteed; | |
private Props(final TreeMap<String, String> source, final Map<String, String> defaults, final Set<String> required) { | |
this.source = source; | |
this.guaranteed = Stream.concat(defaults.keySet().stream(), required.stream()) | |
.collect(Collectors.toCollection(() -> new TreeSet<>(KEY_MATCHER))); | |
} | |
public static PropsLoader load(final String... keys) { | |
return new PropsLoader("", Collections.emptyMap(), Arrays.stream(keys) | |
.filter(k -> k.endsWith("!")).collect(Collectors.toSet())); | |
} | |
public static Props from(final String filename) { | |
return load().from(filename); | |
} | |
public static String getFrom(final String filename, final String key) { | |
return load(key.replaceAll("!?$", "!")).from(filename).get(key); | |
} | |
public static String getFrom(final String filename, final String key, final String defaultValue) { | |
return load(key).from(filename).get(key, defaultValue); | |
} | |
public static Integer getIntFrom(final String filename, final String key) { | |
return Integer.parseInt(getFrom(filename, key)); | |
} | |
public static Integer getIntFrom(final String filename, final String key, final int defaultValue) { | |
return Integer.parseInt(getFrom(filename, key, String.valueOf(defaultValue))); | |
} | |
public String get(final String key) { | |
if (!guaranteed.contains(key)) { | |
throw new IllegalArgumentException("The presence of key '" + key + "' is not guaranteed; you must either:\n" | |
+ "- mark it required at load-time by suffixing it with '!',\n" | |
+ "- provide a default value with withDefaults(), or\n" | |
+ "- use get(String, String) instead of get(String)"); | |
} | |
return source.get(key); | |
} | |
public String get(final String key, final String defaultValue) { | |
if (guaranteed.contains(key)) { | |
throw new IllegalArgumentException("The presence of key '" + key + "' is guaranteed; you should use get(String) instead of get(String, String)"); | |
} | |
return source.getOrDefault(key, defaultValue); | |
} | |
public Integer getInt(final String key) { | |
return Integer.parseInt(get(key)); | |
} | |
public Integer getInt(final String key, final int defaultValue) { | |
return Integer.parseInt(get(key, String.valueOf(defaultValue))); | |
} | |
public static class PropsLoader { | |
private static final String SCONF = "/opt/mercateo/sconf"; | |
private static final String CONF = "/opt/mercateo/conf"; | |
private static final Pattern LINE_PATTERN = Pattern.compile( | |
"^(?<key>[a-z0-9_-]+(\\.[a-z0-9_-]+)*)=(?<value>.*)$", Pattern.CASE_INSENSITIVE); | |
private final String scope; | |
private final Map<String, String> defaults; | |
private final Set<String> required; | |
private PropsLoader(final String scope, final Map<String, String> defaults, final Set<String> required) { | |
this.scope = scope.replaceAll("\\.?$", "."); // Ensure the scope matches a full sub-path | |
this.defaults = defaults; | |
this.required = required; | |
} | |
public Props from(final String filename) { | |
try { | |
final String sanitised = filename.replaceFirst("^/?", "/"); | |
return from(Stream.of( | |
getClass().getResource(sanitised), | |
Paths.get(SCONF, sanitised).toUri().toURL(), | |
Paths.get(CONF, sanitised).toUri().toURL() | |
).filter(Objects::nonNull).toArray(URL[]::new)); | |
} catch (final MalformedURLException e) { | |
throw new RuntimeException(e); // The most obvious triggering case was handled through sanitization | |
} | |
} | |
public Props from(final URL... sources) { | |
final Map.Entry<URL, TreeMap<String, String>> parsed = parse(sources); | |
final TreeMap<String, String> map = parsed.getValue(); | |
if (required.stream().allMatch(map::containsKey)) { | |
return new Props(map, defaults, required); | |
} | |
throw new IllegalStateException("Missing required properties: " | |
+ Arrays.toString(required.stream().filter(k -> !map.containsKey(k)).toArray()) | |
+ " (from " + parsed.getKey() + ")"); | |
} | |
public PropsLoader withScope(final String scope) { | |
return new PropsLoader(scope, defaults, required); | |
} | |
public PropsLoader withDefaults(final Map<String, String> defaults) { | |
return new PropsLoader(scope, defaults, required); | |
} | |
private Map.Entry<URL, TreeMap<String, String>> parse(final URL... sources) { | |
return Arrays.stream(sources).map(this::parse).filter(Optional::isPresent).map(Optional::get).findFirst() | |
.orElseThrow(() -> new IllegalStateException("Couldn't locate any of " + Arrays.toString(sources))); | |
} | |
private Optional<Map.Entry<URL, TreeMap<String, String>>> parse(final URL source) { | |
try (final BufferedReader reader = new BufferedReader(new InputStreamReader(source.openStream()))) { | |
return Optional.of(new AbstractMap.SimpleEntry<>(source, reader.lines() | |
.map(LINE_PATTERN::matcher) | |
.filter(Matcher::matches) | |
.sorted(Comparator.comparing(m -> m.group("key").startsWith(scope))) // Lower priority to global properties | |
.map(match -> new AbstractMap.SimpleImmutableEntry<>( | |
match.group("key").replaceFirst("^" + Pattern.quote(scope), ""), // TODO: Should the scope be able to be fuzzily matched? | |
match.group("value").trim())) | |
.filter(entry -> !entry.getValue().isEmpty()) | |
.collect(Collectors.toMap( | |
Map.Entry::getKey, | |
Map.Entry::getValue, | |
(a, b) -> b, // Latest one remains | |
() -> new TreeMap<>(KEY_MATCHER))))); | |
} catch (final IOException e) { | |
return Optional.empty(); | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment