diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEngine.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEngine.java index faed857d..9bb53104 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEngine.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEngine.java @@ -1,6 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt; -import java.io.StringReader; +import java.util.Collection; import org.springframework.stereotype.Component; @@ -8,21 +8,29 @@ import com.decathlon.idp_core.infrastructure.adapters.entity_mapping.engine.ExpressionEngine; import com.fasterxml.jackson.databind.JsonNode; import com.schibsted.spt.data.jslt.Expression; -import com.schibsted.spt.data.jslt.JsltException; +import com.schibsted.spt.data.jslt.Function; import com.schibsted.spt.data.jslt.Parser; @Component public class JsltEngine implements ExpressionEngine { + private final Collection customFunctions; + + // Creates a JSLT engine with the custom functions discovered by Spring. + /// @param customFunctions custom JSLT functions to register + public JsltEngine(Collection customFunctions) { + this.customFunctions = customFunctions; + } + public Expression compile(String expression) { - return new Parser(new StringReader(expression)).compile(); + return Parser.compileString(expression, customFunctions); } @Override public void validateExpression(String expression) { try { compile(expression); - } catch (JsltException exception) { + } catch (Exception exception) { throw new EntityDynamicMappingJsltErrorException(exception.getMessage()); } } @@ -31,7 +39,7 @@ public void validateExpression(String expression) { public JsonNode evaluate(String expression, JsonNode payload) { try { return compile(expression).apply(payload); - } catch (JsltException exception) { + } catch (Exception exception) { throw new EntityDynamicMappingJsltErrorException(exception.getMessage()); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/functions/DecodeBase64.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/functions/DecodeBase64.java new file mode 100644 index 00000000..145ac94f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/functions/DecodeBase64.java @@ -0,0 +1,108 @@ +package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt.functions; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.schibsted.spt.data.jslt.Function; + +/// JSLT custom function that decodes a Base64-encoded string from an input +/// payload. +/// +/// **Usage in JSLT:** `base64-decode()` +/// +/// Returns `null` if the input is absent, null, or blank. +@Component +public final class DecodeBase64 implements Function { + + public static final String FUNCTION_NAME = "base64-decode"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + // Accepts exactly one argument: the Base64-encoded string node + private static final int MIN_ARGUMENTS = 1; + private static final int MAX_ARGUMENTS = 1; + + @Override + public String getName() { + return FUNCTION_NAME; + } + + @Override + public int getMinArguments() { + return MIN_ARGUMENTS; + } + + @Override + public int getMaxArguments() { + return MAX_ARGUMENTS; + } + + /// Decodes a Base64-encoded `JsonNode` string argument. + /// + /// **Behavior:** + /// - Returns `NullNode` if the input is absent, null, or blank + /// - Attempts to parse the decoded string as JSON; falls back to plain text if + /// parsing fails + /// - Throws `IllegalArgumentException` if the argument is non-textual (e.g., + /// number, object, array) + /// - Throws `IllegalArgumentException` if the Base64 string is malformed + /// + /// @param input the JSLT input context (unused) + /// @param args array of JsonNode arguments; expects exactly one text node + /// containing Base64-encoded data + /// @return a `JsonNode` (either parsed JSON, plain `TextNode`, or `NullNode`) + /// @throws IllegalArgumentException if arg is not textual or contains invalid + /// Base64 + @Override + public JsonNode call(JsonNode input, JsonNode[] args) { + if (args == null || args.length == 0) { + return NullNode.getInstance(); + } + + JsonNode arg = args[0]; + + // 1. Graceful: Null or absent inputs return NullNode + if (arg == null || arg.isNull()) { + return NullNode.getInstance(); + } + + // 2. Strict: Non-textual types (Numbers, Objects, Arrays) throw an exception + if (!arg.isTextual()) { + throw new IllegalArgumentException( + "DecodeBase64 expects a string argument, but received: " + arg.getNodeType()); + } + + // 3. Graceful: Blank strings return NullNode + String textValue = arg.asText(); + if (textValue.isBlank()) { + return NullNode.getInstance(); + } + + // 4. Strict: Malformed Base64 strings throw an exception + try { + byte[] decodedBytes = Base64.getDecoder().decode(textValue); + String decodedString = new String(decodedBytes, StandardCharsets.UTF_8); + return parseDecodedString(decodedString); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid Base64 string payload: '" + textValue + "'", e); + } + } + + private JsonNode parseDecodedString(String decodedString) { + // Try parsing as structured JSON first + try { + JsonNode parsed = OBJECT_MAPPER.readTree(decodedString); + // Jackson returns Java null if decodedString is empty ("") + return (parsed != null) ? parsed : TextNode.valueOf(decodedString); + } catch (JsonProcessingException _) { + // Fallback to plain TextNode if the decoded string is not JSON + return TextNode.valueOf(decodedString); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidatorTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidatorTest.java index 7114c0d2..63c89b7a 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidatorTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltEntityMappingValidatorTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.*; import java.lang.reflect.Method; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -24,7 +25,8 @@ class JsltEntityMappingValidatorTest { @BeforeEach void setUp() { - validator = new JsltEntityMappingValidator(new JsltEngine()); + validator = new JsltEntityMappingValidator(new JsltEngine(Collections.emptyList())); + } // --------------------------------------------------------------------------- diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltMappingEngineAdapterTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltMappingEngineAdapterTest.java index 123a9afe..fce3ee8d 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltMappingEngineAdapterTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/JsltMappingEngineAdapterTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -23,7 +24,7 @@ class JsltMappingEngineAdapterTest { @BeforeEach void setUp() { - var jsltEngine = new JsltEngine(); + var jsltEngine = new JsltEngine(Collections.emptyList()); adapter = new JsltMappingEngineAdapter(jsltEngine, new ObjectMapper(), new JsltExpressionEvaluator(jsltEngine)); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/functions/DecodeBase64Test.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/functions/DecodeBase64Test.java new file mode 100644 index 00000000..f53c8cab --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/entity_mapping/jslt/functions/DecodeBase64Test.java @@ -0,0 +1,122 @@ +package com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt.functions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingJsltErrorException; +import com.decathlon.idp_core.infrastructure.adapters.entity_mapping.jslt.JsltEngine; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.IntNode; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.TextNode; + +// Unit test for the DecodeBase64 JSLT custom function, +// covering direct invocation and integration with the JsltEngine. +@DisplayName("DecodeBase64") +class DecodeBase64Test { + + private DecodeBase64 decodeBase64; + private JsltEngine jsltEngine; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + decodeBase64 = new DecodeBase64(); + jsltEngine = new JsltEngine(List.of(decodeBase64)); + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("Should return correct metadata signature") + void testMetadata() { + assertEquals("base64-decode", decodeBase64.getName()); + assertEquals(1, decodeBase64.getMinArguments()); + assertEquals(1, decodeBase64.getMaxArguments()); + } + + @Test + @DisplayName("Should successfully decode a valid Base64 string directly") + void testDirectDecodeValidBase64() { + // "SGVsbG8gV29ybGQ=" is Base64 for "Hello World" + JsonNode context = NullNode.getInstance(); + JsonNode[] args = new JsonNode[]{TextNode.valueOf("SGVsbG8gV29ybGQ=")}; + + JsonNode result = decodeBase64.call(context, args); + + assertNotNull(result); + assertTrue(result.isTextual()); + assertEquals("Hello World", result.asText()); + } + + @Test + @DisplayName("Should gracefully return NullNode for null, absent, or blank inputs") + void testDirectDecodeGracefulNullAndBlank() { + JsonNode context = NullNode.getInstance(); + + // Null array element + JsonNode[] nullElementArgs = new JsonNode[]{null}; + assertEquals(NullNode.getInstance(), decodeBase64.call(context, nullElementArgs)); + + // NullNode instance + JsonNode[] nullNodeArgs = new JsonNode[]{NullNode.getInstance()}; + assertEquals(NullNode.getInstance(), decodeBase64.call(context, nullNodeArgs)); + + // Blank string + JsonNode[] blankStringArgs = new JsonNode[]{TextNode.valueOf(" ")}; + assertEquals(NullNode.getInstance(), decodeBase64.call(context, blankStringArgs)); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when input is not a textual type") + void testDirectDecodeWrongTypeThrowsException() { + JsonNode context = NullNode.getInstance(); + JsonNode[] nonTextualArgs = new JsonNode[]{new IntNode(123)}; + + assertThrows(IllegalArgumentException.class, () -> decodeBase64.call(context, nonTextualArgs)); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when string payload is malformed Base64") + void testDirectDecodeMalformedBase64ThrowsException() { + JsonNode context = NullNode.getInstance(); + JsonNode[] malformedBase64Args = new JsonNode[]{TextNode.valueOf("NotValidBase64!!!")}; + + assertThrows(IllegalArgumentException.class, + () -> decodeBase64.call(context, malformedBase64Args)); + } + + @Test + @DisplayName("Should execute DecodeBase64 function inside JSLT engine evaluation successfully") + void testJsltEngineIntegrationSuccess() throws Exception { + String jsltExpression = "{ \"decoded\": base64-decode(.encoded_data) }"; + JsonNode payload = objectMapper.readTree("{\"encoded_data\": \"SGVsbG8=\"}"); // "SGVsbG8=" -> + // "Hello" + + JsonNode result = jsltEngine.evaluate(jsltExpression, payload); + + assertNotNull(result); + assertNotNull(result.get("decoded")); + assertTrue(result.get("decoded").isTextual()); + assertEquals("Hello", result.get("decoded").asText()); + } + + @Test + @DisplayName("Should wrap Base64 decoding exception in EntityDynamicMappingJsltErrorException during engine evaluation") + void testJsltEngineIntegrationFailure() throws Exception { + String jsltExpression = "{ \"decoded\": base64-decode(.encoded_data) }"; + JsonNode payloadWithInvalidBase64 = objectMapper + .readTree("{\"encoded_data\": \"NotValidBase64!!!\"}"); + + assertThrows(EntityDynamicMappingJsltErrorException.class, + () -> jsltEngine.evaluate(jsltExpression, payloadWithInvalidBase64)); + } +}