Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
80d9336
feat: request, response, client, factory
typotter Feb 9, 2026
67876be
return BanditParamsResponse from the parser and create a config build…
typotter Feb 11, 2026
1428354
drop serialize methods
typotter Feb 11, 2026
f3cd362
update javadocs
typotter Feb 11, 2026
d4161e9
lint
typotter Feb 11, 2026
9808cd7
Add nullability annotations to HTTP and parser interfaces
typotter Feb 11, 2026
29cbce5
lint
typotter Feb 11, 2026
74ff8fc
Add json value unwrapping to Parser interface
typotter Feb 12, 2026
cfe4eda
common module extraction with default parser and http client
typotter Feb 9, 2026
99d57e2
build deps
typotter Feb 10, 2026
2a86c3d
adust to parser interface
typotter Feb 11, 2026
3db6b18
temp: exlcude duplicates
typotter Feb 11, 2026
2637c4e
implement unwrapper
typotter Feb 12, 2026
8ce04fb
j8
typotter Feb 12, 2026
faed7dc
Optionally use ConfigurationParser and ConfigurationRequestClients in…
typotter Feb 10, 2026
e29f0cd
Cut over to real implementations in testing
typotter Feb 10, 2026
b700ec4
restore JSON methods for now
typotter Feb 10, 2026
260081c
Common client (okhttp and Jackson for parsing)
typotter Feb 10, 2026
79705cb
restore json methods for now
typotter Feb 10, 2026
50fbae3
tidy
typotter Feb 10, 2026
bd9ca53
use config.flagsSnapshotId
typotter Feb 11, 2026
350c636
merge artifact
typotter Feb 12, 2026
3b117fb
comments
typotter Feb 12, 2026
ebffdb7
Hard cut to ConfigurationClient
typotter Feb 11, 2026
969ecaa
remove deprecated http client
typotter Feb 11, 2026
1aff347
hard cut to ConfigurationParser
typotter Feb 11, 2026
ef91b6a
remove grafting format field
typotter Feb 11, 2026
e8c5521
drop serialize methods
typotter Feb 11, 2026
779ce0a
rip mapper from configuration
typotter Feb 12, 2026
434ad38
fix: j8 compatible failed future
typotter Feb 13, 2026
a0cf975
update jdocs
typotter Feb 13, 2026
ab43c3b
parameterize the Json Flag type on the client
typotter Feb 13, 2026
be2b1c3
remove the response bytes from the Configuration data object
typotter Feb 13, 2026
e32689b
removed unused annotations
typotter Feb 13, 2026
04e319a
cut over jackson
typotter Feb 13, 2026
83e6b81
update config docs
typotter Feb 13, 2026
6b0d94d
remove jackson from bandit action attributes
typotter Feb 13, 2026
a360488
re-delegate json parsing/unwrapping
typotter Feb 13, 2026
8c41131
move okhttp and jackson to test only
typotter Feb 13, 2026
cd8e79e
feat(utils): add Base64Codec interface for pluggable encoding
typotter Feb 19, 2026
7825bbf
refactor(deserializer): use Utils.base64Decode instead of duplicate
typotter Feb 19, 2026
9c0bc75
test(utils): add tests for Base64Codec pluggability
typotter Feb 19, 2026
0b8c5fe
fix(utils): add volatile, null check, reset method, and UTF-8 charset
typotter Feb 19, 2026
9e4d6e0
make resetCodec package private
typotter Feb 19, 2026
54cfb3c
merge feature base
typotter Feb 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package cloud.eppo.ufc.dto.adapters;

import static cloud.eppo.Utils.base64Decode;

import cloud.eppo.api.EppoValue;
import cloud.eppo.api.dto.Allocation;
import cloud.eppo.api.dto.BanditFlagVariation;
Expand Down Expand Up @@ -271,17 +273,4 @@ private static Date parseUtcISODateNode(JsonNode isoDateStringElement) {

return result;
}

private static String base64Decode(String input) {
if (input == null) {
return null;
}
byte[] decodedBytes = Base64.getDecoder().decode(input);
if (decodedBytes.length == 0 && !input.isEmpty()) {
throw new RuntimeException(
"zero byte output from Base64; if not running on Android hardware be sure to use"
+ " RobolectricTestRunner");
}
return new String(decodedBytes);
}
}
65 changes: 54 additions & 11 deletions src/main/java/cloud/eppo/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,36 @@ public final class Utils {
private static final ThreadLocal<SimpleDateFormat> UTC_ISO_DATE_FORMAT = buildUtcIsoDateFormat();
private static final Logger log = LoggerFactory.getLogger(Utils.class);
private static final ThreadLocal<MessageDigest> md = buildMd5MessageDigest();
private static volatile Base64Codec base64Codec = new DefaultBase64Codec();

/** Interface for Base64 encoding/decoding operations. */
public interface Base64Codec {
String base64Encode(String input);

String base64Decode(String input);
}

/**
* Sets the Base64 codec implementation to use for encoding and decoding operations. This allows
* platform-specific implementations (e.g., Android SDK using android.util.Base64).
*
* @param codec the Base64 codec implementation to use
* @throws IllegalArgumentException if codec is null
*/
public static void setBase64Codec(Base64Codec codec) {
if (codec == null) {
throw new IllegalArgumentException("Base64Codec cannot be null");
}
base64Codec = codec;
}

/**
* Resets the Base64 codec to the default implementation. Package-private: intended for testing
* purposes only.
*/
static void resetBase64Codec() {
base64Codec = new DefaultBase64Codec();
}

@SuppressWarnings("AnonymousHasLambdaAlternative")
private static ThreadLocal<MessageDigest> buildMd5MessageDigest() {
Expand Down Expand Up @@ -95,21 +125,34 @@ public static String getISODate(Date date) {
}

public static String base64Encode(String input) {
if (input == null) {
return null;
}
return new String(Base64.getEncoder().encode(input.getBytes(StandardCharsets.UTF_8)));
return base64Codec.base64Encode(input);
}

public static String base64Decode(String input) {
if (input == null) {
return null;
return base64Codec.base64Decode(input);
}

private static class DefaultBase64Codec implements Base64Codec {
@Override
public String base64Encode(String input) {
if (input == null) {
return null;
}
return new String(Base64.getEncoder().encode(input.getBytes(StandardCharsets.UTF_8)));
}
byte[] decodedBytes = Base64.getDecoder().decode(input);
if (decodedBytes.length == 0 && !input.isEmpty()) {
throw new RuntimeException(
"zero byte output from Base64; if not running on Android hardware be sure to use RobolectricTestRunner");

@Override
public String base64Decode(String input) {
if (input == null) {
return null;
}
byte[] decodedBytes = Base64.getDecoder().decode(input);
if (decodedBytes.length == 0 && !input.isEmpty()) {
throw new RuntimeException(
"zero byte output from Base64; if not running on Android hardware be sure to use"
+ " RobolectricTestRunner");
Comment on lines +152 to +153

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we need something similar for base64Encode

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Looking a little wider at it, we're only actually using the encode method in tests. I'd prefer to keep the Codec here just in case prod code needs to encode in the future we don't have to make a breaking change to add it.

}
return new String(decodedBytes, StandardCharsets.UTF_8);
}
return new String(decodedBytes);
}
}
60 changes: 60 additions & 0 deletions src/test/java/cloud/eppo/UtilsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

public class UtilsTest {

@AfterEach
void resetCodec() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason these are package level instead of class? (e.g., why no public keyword here?)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

package-private is enough for JUnit to find them (uses reflection/annotation to find the methods)

Utils.resetBase64Codec();
}

@Test
public void testGetMd5Hash() {
// empty string
Expand Down Expand Up @@ -149,4 +156,57 @@ public void testDateFormattingThreadSafety() throws InterruptedException {
assertFalse(collisionDetected.get(), failureMessage);
assertEquals(0, unexpectedExceptions.get(), failureMessage);
}

@Test
void testCustomBase64Codec() {
AtomicBoolean encodeCalled = new AtomicBoolean(false);
AtomicBoolean decodeCalled = new AtomicBoolean(false);

Utils.Base64Codec customCodec =
new Utils.Base64Codec() {
@Override
public String base64Encode(String input) {
encodeCalled.set(true);
return "encoded:" + input;
}

@Override
public String base64Decode(String input) {
decodeCalled.set(true);
return "decoded:" + input;
}
};

Utils.setBase64Codec(customCodec);

assertEquals("encoded:test", Utils.base64Encode("test"));
assertTrue(encodeCalled.get());

assertEquals("decoded:test", Utils.base64Decode("test"));
assertTrue(decodeCalled.get());
Comment on lines +182 to +186

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

}

@Test
void testBase64EncodeDecodeDefault() {
// Test null handling
assertNull(Utils.base64Encode(null));
assertNull(Utils.base64Decode(null));

// Test encoding
String original = "Hello, World!";
String encoded = Utils.base64Encode(original);
assertEquals("SGVsbG8sIFdvcmxkIQ==", encoded);

// Test decoding
String decoded = Utils.base64Decode(encoded);
assertEquals(original, decoded);

// Test round-trip
assertEquals(original, Utils.base64Decode(Utils.base64Encode(original)));
}

@Test
void testSetBase64CodecWithNullThrowsException() {
assertThrows(IllegalArgumentException.class, () -> Utils.setBase64Codec(null));
}
}