diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptor.java index c3ac494480..b74ad55a91 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptor.java @@ -16,19 +16,26 @@ import com.predic8.membrane.annot.*; import com.predic8.membrane.core.exchange.*; +import com.predic8.membrane.core.http.*; import com.predic8.membrane.core.interceptor.*; +import com.predic8.membrane.core.lang.*; import com.predic8.membrane.core.util.*; +import com.predic8.membrane.core.util.uri.*; +import org.jetbrains.annotations.*; import org.slf4j.*; +import java.util.function.*; + import static com.predic8.membrane.core.exceptions.ProblemDetails.*; import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; import static com.predic8.membrane.core.interceptor.Outcome.*; import static com.predic8.membrane.core.interceptor.Outcome.ABORT; +import static com.predic8.membrane.core.util.uri.EscapingUtil.Escaping.NONE; +import static com.predic8.membrane.core.util.uri.EscapingUtil.getEscapingFunction; import static java.nio.charset.StandardCharsets.*; /** - * @description - * sets the content of the HTTP message body to the specified value. The value + * @description sets the content of the HTTP message body to the specified value. The value * can be a static string, or it can be dynamically generated by an expression. * Different languages such as SpEL, Groovy, XMLPath or JsonPath are supported. * setBody does not support conditional processing or loops. When you need these features, @@ -58,16 +65,19 @@ private Outcome handleInternal(Exchange exchange, Flow flow) { var msg = exchange.getMessage(flow); try { // The value is typically set from YAML there we can assume UTF-8 - var result = exchangeExpression.evaluate(exchange, flow, String.class); + var result = exchangeExpression.evaluate(exchange, flow, Object.class); if (result == null) { result = "null"; } - msg.setBodyContent(result.getBytes(UTF_8)); - - if (contentType!=null) { + if (result instanceof String s) { + msg.setBodyContent(s.getBytes(UTF_8)); + } else { + // Hope that all possibles return types are covered + msg.setBodyContent(result.toString().getBytes(UTF_8)); + } + if (contentType != null) { msg.getHeader().setContentType(contentType); } - return CONTINUE; } catch (Exception e) { var root = ExceptionUtil.getRootCause(e); @@ -84,6 +94,14 @@ private Outcome handleInternal(Exchange exchange, Flow flow) { } } + protected ExchangeExpression getExchangeExpression() { + return TemplateExchangeExpression.newInstance(this, language, expression, router, + getEscapingFunction(contentType).orElseGet(() -> { + log.warn("Turning off escaping for 'setBody'. No escaping found for content type {}. To enable escaping set 'contentType' on setBody.", contentType); + return getEscapingFunction(NONE); + })); + } + /** * Sets the expression to be evaluated for modifying the HTTP message body. * The provided string value can represent a static text or a dynamic expression diff --git a/core/src/main/java/com/predic8/membrane/core/lang/ExchangeExpression.java b/core/src/main/java/com/predic8/membrane/core/lang/ExchangeExpression.java index b366f37bbb..ac28708997 100644 --- a/core/src/main/java/com/predic8/membrane/core/lang/ExchangeExpression.java +++ b/core/src/main/java/com/predic8/membrane/core/lang/ExchangeExpression.java @@ -23,6 +23,7 @@ import com.predic8.membrane.core.lang.spel.*; import com.predic8.membrane.core.lang.xpath.*; import com.predic8.membrane.core.router.*; +import org.jetbrains.annotations.*; /** * Language expression that takes an exchange as input @@ -59,15 +60,18 @@ enum Language {GROOVY, SPEL, XPATH, JSONPATH} * @return */ static ExchangeExpression expression(Interceptor interceptor, Language language, String expression) { - var router = interceptor != null ? interceptor.getRouter() : null; return switch (language) { - case GROOVY -> new GroovyExchangeExpression(expression, router); - case SPEL -> new SpELExchangeExpression(expression,null, router); // parserContext is null on purpose ${} or #{} are not needed here - case XPATH -> new XPathExchangeExpression(interceptor,expression, router); - case JSONPATH -> new JsonpathExchangeExpression(expression, router); + case GROOVY -> new GroovyExchangeExpression(expression, getRouter(interceptor)); + case SPEL -> new SpELExchangeExpression(expression,null, getRouter(interceptor)); // parserContext is null on purpose ${} or #{} are not needed here + case XPATH -> new XPathExchangeExpression(interceptor,expression, getRouter(interceptor)); + case JSONPATH -> new JsonpathExchangeExpression(expression, getRouter(interceptor)); }; } + private static @Nullable Router getRouter(Interceptor interceptor) { + return interceptor != null ? interceptor.getRouter() : null; + } + /** * Allows to pass an Interceptor as an argument where there is no interceptor e.g. Target */ diff --git a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java index dfa9db3dd2..60e829282d 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/uri/EscapingUtil.java @@ -14,9 +14,19 @@ package com.predic8.membrane.core.util.uri; +import com.predic8.membrane.core.http.*; +import com.predic8.membrane.core.lang.*; +import groovy.json.*; +import org.apache.commons.text.StringEscapeUtils; +import org.jetbrains.annotations.*; + import java.net.*; +import java.util.*; import java.util.function.*; +import static com.predic8.membrane.core.http.MimeType.isJson; +import static com.predic8.membrane.core.http.MimeType.isXML; +import static com.predic8.membrane.core.util.uri.EscapingUtil.Escaping.*; import static java.lang.Character.*; import static java.nio.charset.StandardCharsets.*; @@ -30,13 +40,27 @@ public class EscapingUtil { * - {@code NONE}: No escaping is applied. Strings are returned as-is. * - {@code URL}: Encodes strings for safe inclusion in a URL, replacing spaces and * other special characters with their percent-encoded counterparts (e.g., SPACE -> +). + * - {@code JSON}: Escapes strings for safe inclusion in a JSON context. + * - {@code XML}: Escapes strings for safe inclusion in an XML context using XML 1.1 rules. * - {@code SEGMENT}: Encodes strings as safe URI path segments, ensuring they do not introduce * path separators, query delimiters, or other unsafe characters, as per RFC 3986. */ public enum Escaping { NONE, URL, - SEGMENT + SEGMENT, + JSON, + XML + } + + public static Optional> getEscapingFunction(String mimeType) { + if (isJson(mimeType)) { + return Optional.of(getEscapingFunction(JSON)); + } + if (isXML(mimeType)) { + return Optional.of(getEscapingFunction(XML)); + } + return Optional.empty(); } public static Function getEscapingFunction(Escaping escaping) { @@ -44,6 +68,8 @@ public static Function getEscapingFunction(Escaping escaping) { case NONE -> Function.identity(); case URL -> s -> URLEncoder.encode(s, UTF_8); case SEGMENT -> EscapingUtil::pathEncode; + case JSON -> CommonBuiltInFunctions::toJSON; // Alternative: StringEscapeUtils::escapeJson ? + case XML -> StringEscapeUtils::escapeXml11; }; } diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptorTest.java index 04b043b2cc..bce64efc4c 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/lang/SetBodyInterceptorTest.java @@ -15,13 +15,16 @@ package com.predic8.membrane.core.interceptor.lang; import com.predic8.membrane.core.exchange.*; -import com.predic8.membrane.core.http.*; +import com.predic8.membrane.core.lang.*; import com.predic8.membrane.core.router.*; import org.junit.jupiter.api.*; import java.net.*; -import static com.predic8.membrane.core.http.Response.notImplemented; +import static com.predic8.membrane.core.http.MimeType.*; +import static com.predic8.membrane.core.http.Request.*; +import static com.predic8.membrane.core.http.Response.*; +import static com.predic8.membrane.core.lang.ExchangeExpression.Language.*; import static org.junit.jupiter.api.Assertions.*; /** @@ -37,7 +40,7 @@ class SetBodyInterceptorTest { void setup() throws URISyntaxException { sbi = new SetBodyInterceptor(); - exc = Request.get("/foo").buildExchange(); + exc = get("/foo").buildExchange(); exc.setResponse(notImplemented().body("bar").build()); } @@ -57,7 +60,43 @@ void evalOfSimpleExpression() { assertEquals("/foo", exc.getRequest().getBodyAsStringDecoded()); } + /** + * When inserting a value from JSONPath into a JSON document like: + * { "a": ${.a} } + * and the value is null, the document should be: + * { "a": null } + */ + @Nested + class Null { + + @Test + void escapeNullJsonPath() throws URISyntaxException { + callSetBody(JSONPATH, "${$.a}"); + } + @Test + void escapeNullGroovy() throws URISyntaxException { + callSetBody(GROOVY, "${fn.jsonPath('$.a')}"); + } + + private void callSetBody(ExchangeExpression.Language language, String expression) throws URISyntaxException { + exc = setJsonSample(); + sbi.setLanguage(language); + sbi.setContentType(APPLICATION_JSON_UTF8); + sbi.setValue(expression); + sbi.init(new DefaultRouter()); + sbi.handleRequest(exc); + assertEquals("null", exc.getRequest().getBodyAsStringDecoded()); + } + + private Exchange setJsonSample() throws URISyntaxException { + return post("/foo").json(""" + {"a":null} + """).buildExchange(); + } + } + + @Test void response() { sbi.setValue("SC: ${statusCode}"); sbi.init(new DefaultRouter()); diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/templating/TemplateInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/templating/TemplateInterceptorTest.java index 24ff43408c..0fcb25dd41 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/templating/TemplateInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/templating/TemplateInterceptorTest.java @@ -17,7 +17,6 @@ import com.fasterxml.jackson.databind.*; import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.http.*; -import com.predic8.membrane.core.resolver.*; import com.predic8.membrane.core.router.*; import com.predic8.membrane.core.security.*; import com.predic8.membrane.core.util.*; @@ -29,6 +28,7 @@ import javax.xml.parsers.*; import javax.xml.xpath.*; import java.io.*; +import java.net.*; import java.nio.file.*; import java.util.*; @@ -51,7 +51,7 @@ public class TemplateInterceptorTest { Router router; @BeforeEach - void setUp(){ + void setUp() { router = new DummyTestRouter(); ti = new TemplateInterceptor(); exc = new Exchange(null); @@ -84,7 +84,7 @@ void accessJson() throws Exception { @SuppressWarnings("unchecked") @Test void createJson() throws Exception { - Exchange exchange = Request.put("/foo").contentType(APPLICATION_JSON).buildExchange(); + var exchange = Request.put("/foo").contentType(APPLICATION_JSON).buildExchange(); invokeInterceptor(exchange, """ {"foo":7,"bar":"baz"} @@ -92,15 +92,15 @@ void createJson() throws Exception { assertEquals(APPLICATION_JSON, exchange.getRequest().getHeader().getContentType()); - Map m = om.readValue(exchange.getRequest().getBodyAsStringDecoded(),Map.class); - assertEquals(7,m.get("foo")); - assertEquals("baz",m.get("bar")); + Map m = om.readValue(exchange.getRequest().getBodyAsStringDecoded(), Map.class); + assertEquals(7, m.get("foo")); + assertEquals("baz", m.get("bar")); } @Test void accessBindings() throws Exception { Exchange exchange = post("/foo?a=1&b=2").contentType(TEXT_PLAIN).body("vlinder").buildExchange(); - exchange.setProperty("baz",7); + exchange.setProperty("baz", 7); invokeInterceptor(exchange, """ <% for(h in header.allHeaderFields) { %> @@ -123,7 +123,7 @@ void accessBindings() throws Exception { <%= p.key %> : <%= p.value %> <% } %> """, APPLICATION_JSON); - + String body = exchange.getRequest().getBodyAsStringDecoded(); assertTrue(body.contains("/foo")); assertTrue(body.contains("Flow: \"REQUEST\"")); @@ -201,7 +201,7 @@ void contentTypeTestOther() { @Test void contentTypeTestJson() { setAndHandleRequest("json/template_test.json"); - assertEquals(APPLICATION_JSON,exc.getRequest().getHeader().getContentType()); + assertEquals(APPLICATION_JSON, exc.getRequest().getHeader().getContentType()); } @Test @@ -209,7 +209,7 @@ void contentTypeTestNoXml() { ti.setSrc("normal text"); ti.init(router); ti.handleRequest(exc); - assertEquals(TEXT_PLAIN,exc.getRequest().getHeader().getContentType()); + assertEquals(TEXT_PLAIN, exc.getRequest().getHeader().getContentType()); } @Test @@ -223,7 +223,7 @@ void testPrettify() { ti.setContentType(APPLICATION_JSON); ti.setSrc(inputJson); - ti.setPretty( TRUE); + ti.setPretty(TRUE); ti.init(); assertArrayEquals(expectedPrettyJson.getBytes(UTF_8), ti.prettify(inputJson.getBytes(UTF_8))); } @@ -246,14 +246,40 @@ void builtInFunctions() { exc.setProperty(SECURITY_SCHEMES, List.of(new BasicHttpSecurityScheme().username("alice"))); ti.setContentType(APPLICATION_JSON); ti.setSrc(""" - { "foo": ${user()} } - """); + { "foo": ${user()} } + """); ti.init(router); ti.handleRequest(exc); assertTrue(exc.getRequest().getBodyAsStringDecoded().contains("alice")); } + /** + * When inserting a value from JSONPath into a JSON document like: + * { "a": ${.a} } + * and the value is null, the document should be: + * { "a": null } + */ + @Nested + class Null { + + @Test + void escapeNull() throws URISyntaxException { + exc = setJsonSample(); + ti.setContentType(APPLICATION_JSON_UTF8); + ti.setSrc("${fn.jsonPath('$.a')}"); + ti.init(new DefaultRouter()); + ti.handleRequest(exc); + assertEquals("null", exc.getRequest().getBodyAsStringDecoded()); + } + + private Exchange setJsonSample() throws URISyntaxException { + return post("/foo").json(""" + {"a":null} + """).buildExchange(); + } + } + private void setAndHandleRequest(String location) { ti.setLocation(Paths.get("src/test/resources/" + location).toString()); ti.init(router); diff --git a/core/src/test/java/com/predic8/membrane/core/util/xml/NormalizeXMLForJsonUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/xml/NormalizeXMLForJsonUtilTest.java index 2f5b79dac7..94a503a0ce 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/xml/NormalizeXMLForJsonUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/xml/NormalizeXMLForJsonUtilTest.java @@ -1,3 +1,17 @@ +/* Copyright 2026 predic8 GmbH, www.predic8.com + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + package com.predic8.membrane.core.util.xml; import com.fasterxml.jackson.databind.*; diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/soap/WSDLRewriterTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/soap/WSDLRewriterTutorialTest.java index b0e0ac6fb7..6399882a4f 100644 --- a/distribution/src/test/java/com/predic8/membrane/tutorials/soap/WSDLRewriterTutorialTest.java +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/soap/WSDLRewriterTutorialTest.java @@ -59,6 +59,7 @@ void soapCall() throws IOException { .when() .post("http://localhost:%d/my-service".formatted(getPort())) .then() + .log().ifValidationFails() .body("Envelope.Body.getCityResponse.population", equalTo("34665600")); // @formatter:on } diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/RestGetToSoapTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/RestGetToSoapTutorialTest.java index c425a55fa2..61ca31c546 100644 --- a/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/RestGetToSoapTutorialTest.java +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/RestGetToSoapTutorialTest.java @@ -33,6 +33,7 @@ void restGetIsConvertedToSoapAndBackToJson() { .when() .get("http://localhost:2000/cities/Bielefeld") .then() + .log().ifValidationFails() .statusCode(200) .body("country", equalTo("Germany")) .body("population", greaterThan(0)); diff --git a/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/SoapArrayToJsonTutorialTest.java b/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/SoapArrayToJsonTutorialTest.java index 159177e79e..ab373b7d62 100644 --- a/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/SoapArrayToJsonTutorialTest.java +++ b/distribution/src/test/java/com/predic8/membrane/tutorials/transformation/SoapArrayToJsonTutorialTest.java @@ -37,6 +37,7 @@ void soapArrayIsConvertedToJsonArray() throws IOException { .when() .post("http://localhost:2000") .then() + .log().ifValidationFails() .statusCode(200) .contentType("application/json") .body("fruits", hasSize(3))