diff --git a/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java index b49d3b5d..49046dc3 100644 --- a/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java +++ b/eppo-sdk-common/src/main/java/cloud/eppo/ufc/dto/adapters/FlagConfigResponseDeserializer.java @@ -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; @@ -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); - } } diff --git a/src/main/java/cloud/eppo/Utils.java b/src/main/java/cloud/eppo/Utils.java index 2c8882de..d3aff926 100644 --- a/src/main/java/cloud/eppo/Utils.java +++ b/src/main/java/cloud/eppo/Utils.java @@ -14,6 +14,36 @@ public final class Utils { private static final ThreadLocal UTC_ISO_DATE_FORMAT = buildUtcIsoDateFormat(); private static final Logger log = LoggerFactory.getLogger(Utils.class); private static final ThreadLocal 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 buildMd5MessageDigest() { @@ -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"); + } + return new String(decodedBytes, StandardCharsets.UTF_8); } - return new String(decodedBytes); } } diff --git a/src/test/java/cloud/eppo/UtilsTest.java b/src/test/java/cloud/eppo/UtilsTest.java index 3de55e29..82e79f3a 100644 --- a/src/test/java/cloud/eppo/UtilsTest.java +++ b/src/test/java/cloud/eppo/UtilsTest.java @@ -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() { + Utils.resetBase64Codec(); + } + @Test public void testGetMd5Hash() { // empty string @@ -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()); + } + + @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)); + } }