Skip to content

Commit 199d49c

Browse files
Allow overwriting fields and methods via annotations
1 parent 7296d2b commit 199d49c

20 files changed

Lines changed: 1245 additions & 4 deletions

.github/workflows/publish.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ jobs:
1414

1515
steps:
1616
- uses: actions/checkout@v4
17-
- name: Set up JDK 17
17+
- name: Set up JDK 21
1818
uses: actions/setup-java@v4
1919
with:
2020
distribution: adopt
21-
java-version: 17
21+
java-version: 21
2222
- name: Test with Gradle
2323
run: ./gradlew --build-cache test
2424
- name: Publish to Maven Central

.github/workflows/verify.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ jobs:
99

1010
steps:
1111
- uses: actions/checkout@v4
12-
- name: Set up JDK 17
12+
- name: Set up JDK 21
1313
uses: actions/setup-java@v4
1414
with:
1515
distribution: adopt
16-
java-version: 17
16+
java-version: 21
1717
- name: Test with Gradle
1818
run: ./gradlew --build-cache test

build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ repositories {
2929
dependencies {
3030
compileOnly("org.slf4j", "slf4j-api", "2.0.17")
3131
compileOnlyApi("org.jetbrains", "annotations", "26.1.0")
32+
annotationProcessor("org.jetbrains:annotations:26.1.0") // to avoid warnings if any
33+
// ... rest of dependencies
3234
api("tools.jackson.core", "jackson-databind") {
3335
version {
3436
require("3.0.0")
@@ -42,6 +44,7 @@ dependencies {
4244
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.1")
4345
testRuntimeOnly("org.junit.jupiter:junit-jupiter-params:6.0.3")
4446
testRuntimeOnly("org.junit.platform:junit-platform-launcher:6.0.3")
47+
testAnnotationProcessor(sourceSets.main.get().output)
4548
testImplementation("org.jetbrains", "annotations", "26.1.0")
4649
testImplementation("org.slf4j", "slf4j-api", "2.0.17")
4750
testImplementation("org.slf4j", "slf4j-simple", "2.0.17")

src/main/java/dev/chojo/ocular/Configurations.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import dev.chojo.ocular.key.Key;
2424
import dev.chojo.ocular.locks.KeyLock;
2525
import dev.chojo.ocular.locks.KeyLocks;
26+
import dev.chojo.ocular.override.OverrideApplier;
27+
import dev.chojo.ocular.override.ValueSupplier;
2628
import org.jetbrains.annotations.NotNull;
2729
import org.slf4j.Logger;
2830
import tools.jackson.core.JacksonException;
@@ -362,13 +364,47 @@ private <V> FileWrapper<V> read(Format<?, ?> format, Path path, Class<V> clazz)
362364
if (v instanceof ConfigSubscriber sub) {
363365
sub.postRead(this);
364366
}
367+
applyOverrides(v, clazz);
365368
return new FileWrapper<>(format, v);
366369
} catch (JacksonException e) {
367370
log.error("Could not read configuration file from {}", path, e);
368371
throw new ConfigurationException("Could not read configuration file from " + path, e);
369372
}
370373
}
371374

375+
/**
376+
* Attempts to apply environment variable and system property overrides to a deserialized config object.
377+
* <p>
378+
* This is the runtime bridge between the config file and the compile-time generated override classes.
379+
* It tries to find a generated class named {@code <ConfigClass>_OcularOverride} (created by
380+
* {@link dev.chojo.ocular.processor.OcularProcessor} during compilation). If found, it instantiates
381+
* the generated {@link ValueSupplier} and delegates to {@link OverrideApplier} to replace field values.
382+
* <p>
383+
* If no generated class exists (i.e. the config class has no {@code @Overwrite} annotations),
384+
* this method silently does nothing.
385+
*/
386+
private <V> void applyOverrides(V object, Class<V> clazz) {
387+
String baseName = clazz.getName();
388+
// Inner classes use '$' in Class.getName() (e.g. "Outer$Inner") but the generated class
389+
// uses '_' instead (e.g. "Outer_Inner_OcularOverride"), so we try both naming conventions.
390+
for (String candidate : List.of(baseName.replace('$', '_'), baseName)) {
391+
try {
392+
// Look up the generated override class by its conventional name
393+
Class<?> overrideClass = Class.forName(candidate + "_OcularOverride", true, classLoader);
394+
// Create an instance — the constructor reads env vars and sys props into its internal map
395+
ValueSupplier supplier = (ValueSupplier) overrideClass.getDeclaredConstructor().newInstance();
396+
// Apply the collected override values to the config object's fields/methods
397+
OverrideApplier.applyOverrides(object, supplier);
398+
return;
399+
} catch (ClassNotFoundException ignored) {
400+
// No generated class for this naming variant — try the next one
401+
} catch (ReflectiveOperationException e) {
402+
log.warn("Could not apply overrides for class {}: {}", clazz.getName(), e.getMessage());
403+
return;
404+
}
405+
}
406+
}
407+
372408
private Path resolvePath(Key<?> key) {
373409
return key.path().isAbsolute() ? key.path() : base.resolve(key.path());
374410
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* SPDX-License-Identifier: LGPL-3.0-or-later
3+
*
4+
* Copyright (C) RainbowDashLabs and Contributor
5+
*/
6+
package dev.chojo.ocular.override;
7+
import java.lang.annotation.ElementType;
8+
import java.lang.annotation.Retention;
9+
import java.lang.annotation.RetentionPolicy;
10+
import java.lang.annotation.Target;
11+
12+
/**
13+
* Specifies that a configuration field or method can be overridden by an environment variable.
14+
* <p>
15+
* Used inside {@link Overwrite @Overwrite} to declare an environment variable source, e.g.:
16+
* <pre>{@code
17+
* @Overwrite(env = @EnvVar("MY_APP_HOST"))
18+
* private String host;
19+
* }</pre>
20+
* <p>
21+
* If no explicit name is provided (i.e. {@code @EnvVar()}), the variable name is derived
22+
* automatically from the class name and field name in UPPER_CASE format:
23+
* {@code CLASSNAME_FIELDNAME}. For example, a field {@code myCoolVariable} in class
24+
* {@code AppConfig} would look for the environment variable {@code APPCONFIG_MYCOOLVARIABLE}.
25+
*
26+
* @see Overwrite
27+
*/
28+
@Retention(RetentionPolicy.RUNTIME)
29+
@Target({ElementType.FIELD, ElementType.METHOD})
30+
public @interface EnvVar {
31+
/**
32+
* The environment variable name to read. Leave empty to use the auto-derived name.
33+
*/
34+
String value() default "";
35+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* SPDX-License-Identifier: LGPL-3.0-or-later
3+
*
4+
* Copyright (C) RainbowDashLabs and Contributor
5+
*/
6+
package dev.chojo.ocular.override;
7+
8+
import org.slf4j.Logger;
9+
10+
import java.lang.reflect.Field;
11+
import java.lang.reflect.Method;
12+
import java.util.Optional;
13+
14+
import static org.slf4j.LoggerFactory.getLogger;
15+
16+
/**
17+
* Applies override values from a {@link ValueSupplier} to a configuration object at runtime.
18+
* <p>
19+
* This is the runtime counterpart to the compile-time code generation done by
20+
* {@link dev.chojo.ocular.processor.OcularProcessor}. The overall flow is:
21+
* <ol>
22+
* <li>At compile time, the annotation processor scans {@link Overwrite @Overwrite} annotations
23+
* and generates a {@link ValueSupplier} class that reads env vars / system properties.</li>
24+
* <li>At runtime, after a config file is deserialized, {@link dev.chojo.ocular.Configurations}
25+
* loads the generated {@link ValueSupplier} via reflection.</li>
26+
* <li>This class then iterates over every field and single-parameter method of the config object,
27+
* asks the supplier if an override exists for that name, converts the string value to the
28+
* correct type, and sets it on the object — effectively replacing the file-based value.</li>
29+
* </ol>
30+
*/
31+
public final class OverrideApplier {
32+
private static final Logger log = getLogger(OverrideApplier.class);
33+
34+
private OverrideApplier() {}
35+
36+
/**
37+
* Applies all available overrides from the given supplier to the configuration object.
38+
* <p>
39+
* For each declared field, it checks if the supplier has an override value for that field name.
40+
* If so, the value is converted from a string to the field's type and written directly into the field.
41+
* The same is done for single-parameter methods (e.g. setters), where the override value is
42+
* converted to the method's parameter type and the method is invoked.
43+
*
44+
* @param object the configuration object whose fields/methods may be overridden
45+
* @param supplier the source of override values (typically a generated class)
46+
* @param <V> the configuration type
47+
*/
48+
public static <V> void applyOverrides(V object, ValueSupplier supplier) {
49+
if (object == null || supplier == null) return;
50+
51+
// Get the class definition so we can inspect its fields and methods via reflection.
52+
// Reflection lets us read and write private fields at runtime, which is how we inject
53+
// override values without the config class needing any special code.
54+
Class<?> clazz = object.getClass();
55+
56+
// Walk through every field in the config class (e.g. "private String host")
57+
// and check if the generated supplier has an override value for that field name.
58+
for (Field field : clazz.getDeclaredFields()) {
59+
Optional<Object> override = supplier.getValue(field.getName());
60+
if (override.isPresent()) {
61+
try {
62+
// By default, Java prevents access to private fields from outside the class.
63+
// setAccessible(true) bypasses that restriction so we can write to it.
64+
field.setAccessible(true);
65+
// The override value comes as a String (from env var / sys prop), but the field
66+
// might be an int, boolean, etc. convertValue handles that conversion.
67+
Object value = convertValue(field.getType(), override.get());
68+
if (value != null) {
69+
// Actually write the override value into the field on the config object
70+
field.set(object, value);
71+
}
72+
} catch (IllegalAccessException e) {
73+
log.warn("Could not set override for field {}: {}", field.getName(), e.getMessage());
74+
} catch (NumberFormatException e) {
75+
log.warn("Could not convert override value for field {}: {}", field.getName(), e.getMessage());
76+
}
77+
}
78+
}
79+
80+
// Also check single-parameter methods (typically setters like "setHost(String host)").
81+
// We only consider methods with exactly one parameter, since those are the ones that
82+
// make sense as "set this value" operations.
83+
for (Method method : clazz.getDeclaredMethods()) {
84+
if (method.getParameterCount() != 1) continue;
85+
Optional<Object> override = supplier.getValue(method.getName());
86+
if (override.isPresent()) {
87+
try {
88+
method.setAccessible(true);
89+
// Convert the string value to match the method's parameter type
90+
Object value = convertValue(method.getParameterTypes()[0], override.get());
91+
if (value != null) {
92+
// Call the method on the config object, passing the override value
93+
method.invoke(object, value);
94+
}
95+
} catch (IllegalAccessException e) {
96+
log.warn("Could not invoke override for method {}: {}", method.getName(), e.getMessage());
97+
} catch (NumberFormatException e) {
98+
log.warn("Could not convert override value for method {}: {}", method.getName(), e.getMessage());
99+
} catch (java.lang.reflect.InvocationTargetException e) {
100+
log.warn("Override method {} threw an exception: {}", method.getName(), e.getCause().getMessage());
101+
}
102+
}
103+
}
104+
}
105+
106+
/**
107+
* Converts a string override value to the target field/parameter type.
108+
* Supports all Java primitive types and their boxed equivalents, plus String.
109+
* Returns null for unsupported types (with a warning logged).
110+
*/
111+
private static Object convertValue(Class<?> type, Object value) {
112+
if (value == null) return null;
113+
// Environment variables and system properties are always strings, so we need to parse
114+
// them into the correct Java type. For example, the string "8080" becomes the int 8080.
115+
String stringValue = value.toString();
116+
117+
if (type == String.class) return stringValue;
118+
if (type == int.class || type == Integer.class) return Integer.parseInt(stringValue);
119+
if (type == long.class || type == Long.class) return Long.parseLong(stringValue);
120+
if (type == boolean.class || type == Boolean.class) return Boolean.parseBoolean(stringValue);
121+
if (type == double.class || type == Double.class) return Double.parseDouble(stringValue);
122+
if (type == float.class || type == Float.class) return Float.parseFloat(stringValue);
123+
if (type == short.class || type == Short.class) return Short.parseShort(stringValue);
124+
if (type == byte.class || type == Byte.class) return Byte.parseByte(stringValue);
125+
126+
log.warn("Unsupported override type: {}", type.getName());
127+
return null;
128+
}
129+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* SPDX-License-Identifier: LGPL-3.0-or-later
3+
*
4+
* Copyright (C) RainbowDashLabs and Contributor
5+
*/
6+
package dev.chojo.ocular.override;
7+
8+
import java.lang.annotation.ElementType;
9+
import java.lang.annotation.Retention;
10+
import java.lang.annotation.RetentionPolicy;
11+
import java.lang.annotation.Target;
12+
13+
/**
14+
* Marks a field or method in a configuration class as overridable at runtime.
15+
* <p>
16+
* When a configuration class contains fields or methods annotated with {@code @Overwrite},
17+
* the annotation processor ({@link dev.chojo.ocular.processor.OcularProcessor}) will generate
18+
* a helper class at compile time that knows how to look up override values from environment
19+
* variables and/or system properties.
20+
* <p>
21+
* You can specify one or more {@link EnvVar} and/or {@link SysProp} sources. The order you
22+
* declare them matters: sources listed later take priority over earlier ones. For example:
23+
* <pre>{@code
24+
* @Overwrite(sys = @SysProp(), env = @EnvVar())
25+
* private String host;
26+
* }</pre>
27+
* This will first check the system property, then the environment variable. If both are set,
28+
* the environment variable wins because it is declared last.
29+
*/
30+
@Retention(RetentionPolicy.RUNTIME)
31+
@Target({ElementType.FIELD, ElementType.METHOD})
32+
public @interface Overwrite {
33+
/** Environment variable sources to check for an override value. */
34+
EnvVar[] env() default {};
35+
36+
/** System property sources to check for an override value. */
37+
SysProp[] sys() default {};
38+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* SPDX-License-Identifier: LGPL-3.0-or-later
3+
*
4+
* Copyright (C) RainbowDashLabs and Contributor
5+
*/
6+
package dev.chojo.ocular.override;
7+
8+
import java.lang.annotation.ElementType;
9+
import java.lang.annotation.Retention;
10+
import java.lang.annotation.RetentionPolicy;
11+
import java.lang.annotation.Target;
12+
13+
/**
14+
* Specifies that a configuration field or method can be overridden by a JVM system property.
15+
* <p>
16+
* Used inside {@link Overwrite @Overwrite} to declare a system property source, e.g.:
17+
* <pre>{@code
18+
* @Overwrite(sys = @SysProp("app.host"))
19+
* private String host;
20+
* }</pre>
21+
* <p>
22+
* If no explicit name is provided (i.e. {@code @SysProp()}), the property name is derived
23+
* automatically from the class name (lowercased) and field name in dot notation:
24+
* {@code classname.fieldName}. For example, a field {@code myCoolVariable} in class
25+
* {@code AppConfig} would look for the system property {@code appconfig.myCoolVariable}
26+
* (set via {@code -Dappconfig.myCoolVariable=value} on the JVM command line).
27+
*
28+
* @see Overwrite
29+
*/
30+
@Retention(RetentionPolicy.RUNTIME)
31+
@Target({ElementType.FIELD, ElementType.METHOD})
32+
public @interface SysProp {
33+
/**
34+
* The system property name to read. Leave empty to use the auto-derived name.
35+
*/
36+
String value() default "";
37+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* SPDX-License-Identifier: LGPL-3.0-or-later
3+
*
4+
* Copyright (C) RainbowDashLabs and Contributor
5+
*/
6+
package dev.chojo.ocular.override;
7+
8+
import java.util.Optional;
9+
10+
/**
11+
* Provides override values for configuration fields and methods.
12+
* <p>
13+
* Implementations of this interface are generated automatically at compile time by
14+
* {@link dev.chojo.ocular.processor.OcularProcessor} for each configuration class that uses
15+
* {@link Overwrite @Overwrite} annotations. The generated class reads environment variables
16+
* and system properties during construction and stores them in an internal map.
17+
* <p>
18+
* At runtime, when a configuration is loaded, {@link OverrideApplier} calls
19+
* {@link #getValue(String)} for each field/method name to check if an override exists.
20+
*
21+
* @see OverrideApplier
22+
* @see dev.chojo.ocular.processor.OcularProcessor
23+
*/
24+
public interface ValueSupplier {
25+
/**
26+
* Returns the override value for the given field or method name, if one was found
27+
* in the environment variables or system properties at construction time.
28+
*
29+
* @param fieldOrMethodName the name of the field or setter method to look up
30+
* @return an {@link Optional} containing the override value as a string, or empty if no override exists
31+
*/
32+
Optional<Object> getValue(String fieldOrMethodName);
33+
}

0 commit comments

Comments
 (0)