From 75abf92ab6b94e282edc74c298aa2b45cf3c344c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= <118011644+christiangoerdes@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:16:54 +0100 Subject: [PATCH 01/16] 6.x json2xml array support (#2364) * feat: json2xml support for arrays * refactor: minor * refactor: minor * refactor: minor * refactor: minor * refactor: minor * refactor: minor * refactor: minor * refactor: minor * refactor: minor --------- Co-authored-by: Thomas Bayer --- .../interceptor/xml/Json2XmlInterceptor.java | 124 +++++----- .../AbstractArrayParameterParser.java | 3 +- .../ExplodedObjectParameterParser.java | 2 +- .../parameters/ObjectParameterParser.java | 2 +- .../parameters/ScalarParameterParser.java | 2 +- .../membrane/core/util/json/JsonToXml.java | 193 +++++++++++++++ .../core/util/{ => json}/JsonUtil.java | 2 +- .../xml/Json2XmlInterceptorTest.java | 98 +++++++- .../membrane/core/util/JsonUtilTest.java | 2 +- .../core/util/json/JsonToXmlTest.java | 228 ++++++++++++++++++ 10 files changed, 574 insertions(+), 82 deletions(-) create mode 100644 core/src/main/java/com/predic8/membrane/core/util/json/JsonToXml.java rename core/src/main/java/com/predic8/membrane/core/util/{ => json}/JsonUtil.java (98%) create mode 100644 core/src/test/java/com/predic8/membrane/core/util/json/JsonToXmlTest.java diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptor.java index 6172b5851d..58b169a412 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptor.java @@ -18,37 +18,46 @@ import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.http.*; import com.predic8.membrane.core.interceptor.*; -import com.predic8.membrane.core.util.*; -import org.jetbrains.annotations.*; -import org.json.*; -import org.slf4j.*; - -import java.io.*; +import com.predic8.membrane.core.util.json.*; import static com.predic8.membrane.core.exceptions.ProblemDetails.*; import static com.predic8.membrane.core.http.MimeType.*; import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; -import static com.predic8.membrane.core.interceptor.Outcome.ABORT; import static com.predic8.membrane.core.interceptor.Outcome.*; +import static com.predic8.membrane.core.interceptor.Outcome.ABORT; +import static com.predic8.membrane.core.util.StringUtil.*; import static java.nio.charset.StandardCharsets.*; - /** - * @description Converts body payload from JSON to XML. The JSON must be an object other JSON documents e.g. arrays are not supported. - * @explanation Resulting XML will be in UTF-8 encoding. + * @description Converts JSON message bodies into XML. + * The converter wraps the JSON document into a root element. The name of the + * root element is configurable. If unset, JSON objects default to "root" and JSON arrays default to "array". + * + * @explanation + * This interceptor reads the JSON body, converts it into XML and updates the message + * body and Content-Type header. The resulting XML is always UTF-8 encoded and starts with an XML prolog. + * * @topic 2. Enterprise Integration Patterns */ @MCElement(name = "json2Xml") public class Json2XmlInterceptor extends AbstractInterceptor { - private static final Logger log = LoggerFactory.getLogger(Json2XmlInterceptor.class); + // --- Configuration properties --- + private String root; + private String array = "array"; + private String item = "item"; + // --- Converter instance (created once) --- + private JsonToXml converter; - // Prolog is needed to provide the UTF-8 encoding - private static final String PROLOG = """ - """; + @Override + public void init() { + super.init(); + converter = new JsonToXml().arrayName(array).itemName(item).rootName(root); - private String root; + // root gets handled dynamically at runtime because it depends on json document type + // unless explicitly set via @MCAttribute + } @Override public Outcome handleRequest(Exchange exc) { @@ -66,27 +75,15 @@ private Outcome handleInternal(Exchange exchange, Flow flow) { return CONTINUE; try { - msg.setBodyContent(json2Xml(msg.getBodyAsStream())); - } catch (JSONException e) { - log.info("Error parsing JSON: {}",e.getMessage()); - user(router.isProduction(), getDisplayName()) - .title("Error parsing JSON") - .addSubType("validation/json") - .exception(e) - .stacktrace(false) - .internal("flow", flow) - .internal("body", StringUtil.truncateAfter(msg.getBodyAsStringDecoded(), 200)) - .buildAndSetResponse(exchange); - return ABORT; - } - catch (Exception e) { + msg.setBodyContent(json2Xml(msg)); + } catch (Exception e) { internal(router.isProduction(), getDisplayName()) .title("Error parsing JSON") .addSubType("validation/json") - .exception(e) - .stacktrace(true) + .exception(e) // Message contains a meaningful error message + .stacktrace(false) .internal("flow", flow) - .internal("body", StringUtil.truncateAfter(msg.getBodyAsStringDecoded(), 200)) + .internal("body", truncateAfter(msg.getBodyAsStringDecoded(), 200)) .buildAndSetResponse(exchange); return ABORT; } @@ -95,33 +92,8 @@ private Outcome handleInternal(Exchange exchange, Flow flow) { return CONTINUE; } - private byte[] json2Xml(InputStream body) { - return (PROLOG + XML.toString(getJSONRoot(body))).getBytes(UTF_8); - } - - private @NotNull JSONObject getJSONRoot(InputStream body) { - if (root != null) { - return createRoot(root, convertToJsonObject(body)); - } - JSONObject json = convertToJsonObject(body); - - // If there is exactly one element, then we can use that as root - if (json.length() == 1) { - return json; - } else { - // Otherwise we must wrap the fields into a single root element - return createRoot("root", json); - } - } - - private JSONObject createRoot(String name, JSONObject jsonObject) { - JSONObject root = new JSONObject(); - root.put(name, jsonObject); - return root; - } - - private JSONObject convertToJsonObject(InputStream body) { - return new JSONObject(new JSONTokener(new InputStreamReader(body, UTF_8))); + private byte[] json2Xml(Message msg) { + return converter.toXml(msg.getBodyAsStringDecoded()).getBytes(UTF_8); } @Override @@ -139,13 +111,41 @@ public String getRoot() { } /** - * A JSON object can have multiple keys. When transforming that to XML a single root element is needed. - * If set a root element with this name will wrap the content. + * XML always needs a single root element. A JSON object can have multiple properties or an array can have multiple items. + * The converter therefore wraps the document into a root element if necessary. With this property you can set the name of the root element. + * If the property is set, the XML is always wrapped into an element with the given name. * * @param root Name of the element to wrap the content in + * @default "root" for objects and "array" for arrays */ @MCAttribute public void setRoot(String root) { this.root = root; } -} + + public String getArray() { + return array; + } + + /** + * Sets the XML tag name used to represent JSON arrays. + * @default "array" + */ + @MCAttribute + public void setArray(String array) { + this.array = array; + } + + public String getItem() { + return item; + } + + /** + * Sets the XML tag name used for array items. + * Default is "item". + */ + @MCAttribute + public void setItem(String item) { + this.item = item; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/AbstractArrayParameterParser.java b/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/AbstractArrayParameterParser.java index 84292a72b7..d47e6b54aa 100644 --- a/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/AbstractArrayParameterParser.java +++ b/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/AbstractArrayParameterParser.java @@ -17,10 +17,9 @@ import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.node.*; -import java.net.*; import java.util.stream.*; -import static com.predic8.membrane.core.util.JsonUtil.*; +import static com.predic8.membrane.core.util.json.JsonUtil.*; import static java.net.URLDecoder.decode; import static java.nio.charset.StandardCharsets.*; diff --git a/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/ExplodedObjectParameterParser.java b/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/ExplodedObjectParameterParser.java index 6396d2e04d..8ae593ae4c 100644 --- a/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/ExplodedObjectParameterParser.java +++ b/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/ExplodedObjectParameterParser.java @@ -24,7 +24,7 @@ import java.util.regex.*; import static com.predic8.membrane.core.openapi.util.OpenAPIUtil.*; -import static com.predic8.membrane.core.util.JsonUtil.*; +import static com.predic8.membrane.core.util.json.JsonUtil.*; import static java.net.URLDecoder.*; import static java.nio.charset.StandardCharsets.*; diff --git a/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/ObjectParameterParser.java b/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/ObjectParameterParser.java index 6d794086b9..97d42dcc97 100644 --- a/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/ObjectParameterParser.java +++ b/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/ObjectParameterParser.java @@ -23,7 +23,7 @@ import java.util.*; import static com.predic8.membrane.core.openapi.validators.JsonSchemaValidator.*; -import static com.predic8.membrane.core.util.JsonUtil.*; +import static com.predic8.membrane.core.util.json.JsonUtil.*; import static java.net.URLDecoder.*; import static java.nio.charset.StandardCharsets.*; import static java.util.Objects.*; diff --git a/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/ScalarParameterParser.java b/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/ScalarParameterParser.java index 1c456c9382..327bffcf54 100644 --- a/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/ScalarParameterParser.java +++ b/core/src/main/java/com/predic8/membrane/core/openapi/validators/parameters/ScalarParameterParser.java @@ -16,7 +16,7 @@ import com.fasterxml.jackson.databind.*; -import static com.predic8.membrane.core.util.JsonUtil.*; +import static com.predic8.membrane.core.util.json.JsonUtil.*; import static java.net.URLDecoder.*; import static java.nio.charset.StandardCharsets.*; diff --git a/core/src/main/java/com/predic8/membrane/core/util/json/JsonToXml.java b/core/src/main/java/com/predic8/membrane/core/util/json/JsonToXml.java new file mode 100644 index 0000000000..d11deefe12 --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/util/json/JsonToXml.java @@ -0,0 +1,193 @@ +package com.predic8.membrane.core.util.json; + +import org.jetbrains.annotations.*; +import org.json.*; + +import static java.lang.Boolean.*; +import static org.json.JSONObject.*; + +public class JsonToXml { + + // Prolog is needed to provide the UTF-8 encoding + static final String XML_PROLOG = ""; + + private String rootName = null; + private String arrayName = "array"; + private String itemName = "item"; + + public JsonToXml rootName(String rootName) { + this.rootName = rootName; + return this; + } + + public JsonToXml arrayName(String arrayName) { + this.arrayName = arrayName; + return this; + } + + public JsonToXml itemName(String itemName) { + this.itemName = itemName; + return this; + } + + public String toXml(String json) { + return XML_PROLOG + toXmlInternal(json); + } + + String toXmlInternal(String json) { + Object input = parse(json); + StringBuilder sb = new StringBuilder(); + + // --- Case 1: Single-property object --- + if (rootName == null && + input instanceof JSONObject jsonObj && + jsonObj.keySet().size() == 1) { + + String singleKey = jsonObj.keySet().iterator().next(); + startTag(sb, singleKey); + build(jsonObj.get(singleKey), sb); + endTag(sb, singleKey); + return sb.toString(); + } + + // --- Case 2: Top-level array without explicit root --- + if (rootName == null && input instanceof JSONArray arr) { + startArray(sb); + buildArrayItems(sb, arr); // <- Important: NO nested array tag here + endArray(sb); + return sb.toString(); + } + + // --- Case 3: Normal case (object/primitive with root) --- + String effectiveRoot = rootName != null ? rootName : "root"; + + startTag(sb, effectiveRoot); + build(input, sb); + endTag(sb, effectiveRoot); + return sb.toString(); + } + + private static void endTag(StringBuilder sb, String singleKey) { + sb.append(""); + } + + private static void startTag(StringBuilder sb, String singleKey) { + sb.append("<").append(sanitizeXmlName( singleKey)).append(">"); + } + + static @NotNull Object parseLiteral(String t) { + + // Try numeric types first (Double first to avoid Long overflow) + if (t.matches("-?\\d+(\\.\\d+)?([eE][+-]?\\d+)?")) { + try { + double d = Double.parseDouble(t); + + // If integer without decimal/exponent → try Long if it fits + if (t.matches("-?\\d+")) { + try { + return Long.parseLong(t); + } catch (NumberFormatException ignored) { + // too large → keep as Double + } + } + + return d; + + } catch (NumberFormatException e) { + // fallback: treat as string + return t; + } + } + + // Check for quoted strings + if (t.startsWith("\"") && t.endsWith("\"")) + return t.substring(1, t.length() - 1); + + return t; + } + + + private Object parse(String jsonText) { + String t = jsonText.trim(); + + if (t.startsWith("{")) return new JSONObject(t); + if (t.startsWith("[")) return new JSONArray(t); + return switch (t) { + case "true" -> TRUE; + case "false" -> FALSE; + case "null" -> NULL; + default -> parseLiteral(t); + + }; + } + + private void build(Object value, StringBuilder sb) { + + if (value instanceof JSONObject jsonObj) { + buildObject(sb, jsonObj); + return; + } + + if (value instanceof JSONArray array) { + buildArray(sb, array); + return; + } + + if (value == null || value == NULL) { + return; + } + sb.append(escapeTextContent(String.valueOf(value))); + } + + private void buildArray(StringBuilder sb, JSONArray array) { + startArray(sb); + buildArrayItems(sb, array); + endArray(sb); + } + + private void buildArrayItems(StringBuilder sb, JSONArray array) { + for (int i = 0; i < array.length(); i++) { + startTag(sb, itemName); + build(array.get(i), sb); + endTag(sb, itemName); + } + } + + private static String sanitizeXmlName(String key) { + + // Replace any illegal XML characters + String sanitized = key.replaceAll("[^A-Za-z0-9_.-]", "_"); + + // XML element must NOT start with number or dot or hyphen + if (!sanitized.matches("[A-Za-z_].*")) { + sanitized = "_" + sanitized; + } + + return sanitized; + } + + private void buildObject(StringBuilder sb, JSONObject jsonObj) { + for (String key : jsonObj.keySet()) { + startTag(sb, key); + build(jsonObj.get(key), sb); + endTag(sb, key); + } + } + + private void endArray(StringBuilder sb) { + endTag(sb, arrayName); + } + + private void startArray(StringBuilder sb) { + startTag(sb, arrayName); + } + + /** + * Not suitable for attribute values cause " and ' must be escaped there + */ + private String escapeTextContent(String v) { + return v.replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/predic8/membrane/core/util/JsonUtil.java b/core/src/main/java/com/predic8/membrane/core/util/json/JsonUtil.java similarity index 98% rename from core/src/main/java/com/predic8/membrane/core/util/JsonUtil.java rename to core/src/main/java/com/predic8/membrane/core/util/json/JsonUtil.java index 17f71b8f45..5fb73f8092 100644 --- a/core/src/main/java/com/predic8/membrane/core/util/JsonUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/util/json/JsonUtil.java @@ -12,7 +12,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package com.predic8.membrane.core.util; +package com.predic8.membrane.core.util.json; import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.node.*; diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptorTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptorTest.java index dbe1354c5d..3d6620d5cb 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptorTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptorTest.java @@ -16,6 +16,8 @@ import com.predic8.membrane.core.*; import com.predic8.membrane.core.exchange.*; import com.predic8.membrane.core.http.*; +import io.restassured.path.json.*; +import io.restassured.path.xml.*; import org.junit.jupiter.api.*; import org.xml.sax.*; @@ -69,8 +71,8 @@ void normalRequest() throws Exception { assertEquals(CONTINUE, interceptor.handleRequest(exc)); Message msg = exc.getRequest(); assertTrue(msg.isXML()); - assertEquals("Mike", xPath(msg.getBodyAsStringDecoded(), "/person/name")); - assertEquals("San Francisco", xPath(msg.getBodyAsStringDecoded(), "/person/city")); + assertXPath("Mike", msg, "/person/name"); + assertXPath("San Francisco", msg, "/person/city"); assertTrue(msg.getBodyAsStringDecoded().contains(UTF_8.name())); } @@ -81,8 +83,8 @@ void normalResponse() throws Exception { assertEquals(CONTINUE, interceptor.handleResponse(exc)); Message msg = exc.getResponse(); assertTrue(msg.isXML()); - assertEquals("Mike", xPath(msg.getBodyAsStringDecoded(), "/person/name")); - assertEquals("San Francisco", xPath(msg.getBodyAsStringDecoded(), "/person/city")); + assertXPath("Mike", msg, "/person/name"); + assertXPath("San Francisco", msg, "/person/city"); } @Test @@ -91,7 +93,35 @@ void single() throws Exception { assertEquals(CONTINUE, interceptor.handleRequest(exc)); Message msg = exc.getRequest(); assertTrue(msg.isXML()); - assertEquals("Berlin", xPath(msg.getBodyAsStringDecoded(), "/place")); + assertXPath("Berlin", msg, "/place"); + } + + @Test + void nested() throws URISyntaxException { + Exchange exc = put("/nested").json(""" + { + "one": [1], + "nested": [[2]], + "three": [[[3]]] + } + """).buildExchange(); + + assertEquals(CONTINUE, interceptor.handleRequest(exc)); + + String xml = exc.getRequest().getBodyAsStringDecoded(); + XmlPath xp = new XmlPath(xml); + + // one = [1] + assertEquals(1, xp.getInt("root.one.array.item")); + assertEquals(1, xp.getList("root.one.array.item").size()); + + // nested = [[2]] + assertEquals(2, xp.getInt("root.nested.array.item.array.item")); + assertEquals(1, xp.getList("root.nested.array.item.array.item").size()); + + // three = [[[3]]] + assertEquals(3, xp.getInt("root.three.array.item.array.item.array.item")); + assertEquals(1, xp.getList("root.three.array.item.array.item.array.item").size()); } @Test @@ -100,31 +130,73 @@ void noRoot() throws Exception { assertEquals(CONTINUE, interceptor.handleRequest(exc)); Message msg = exc.getRequest(); assertTrue(msg.isXML()); - assertEquals("1", xPath(msg.getBodyAsStringDecoded(), "/root/a")); - assertEquals("2", xPath(msg.getBodyAsStringDecoded(), "/root/b")); + assertXPath("1", msg, "/root/a"); + assertXPath("2", msg, "/root/b"); } @Test void noRootWithRootNameSpecified() throws Exception { interceptor.setRoot("top"); + interceptor.init(); Exchange exc = put("/no-root").json(noRoot).buildExchange(); assertEquals(CONTINUE, interceptor.handleRequest(exc)); Message msg = exc.getRequest(); assertTrue(msg.isXML()); - assertEquals("1", xPath(msg.getBodyAsStringDecoded(), "/top/a")); - assertEquals("2", xPath(msg.getBodyAsStringDecoded(), "/top/b")); + assertXPath("1", msg, "/top/a"); + assertXPath("2", msg, "/top/b"); + } + + @Test + void array() throws URISyntaxException, XPathExpressionException { + Exchange exc = put("/array").json("[1,2,3]").buildExchange(); + assertEquals(CONTINUE, interceptor.handleRequest(exc)); + Message msg = exc.getRequest(); + assertXPath("1",msg,"/array/item[1]"); + assertXPath("2",msg,"/array/item[2]"); + assertXPath("3",msg,"/array/item[3]"); } @Test - void invalidJSON() throws URISyntaxException { - Exchange exc = put("/invalid").json("{ invalid").buildExchange(); + void arrayWithRoot() throws URISyntaxException, XPathExpressionException { + interceptor.setRoot("myRoot"); + interceptor.init(); + Exchange exc = put("/array").json("[1,2,3]").buildExchange(); + assertEquals(CONTINUE, interceptor.handleRequest(exc)); + var msg = exc.getRequest(); + assertXPath("1",msg,"/myRoot/array/item[1]"); + assertXPath("2",msg,"/myRoot/array/item[2]"); + assertXPath("3",msg,"/myRoot/array/item[3]"); + } + + @Test + void arrayOneElement() throws Exception { + Exchange exc = put("/array").json("[1]").buildExchange(); + assertEquals(CONTINUE, interceptor.handleRequest(exc)); + assertXPath("1", (Message) exc.getRequest(),"/array/item[1]"); + } + + @Test + void number() throws Exception { + Exchange exc = put("/number").json("1").buildExchange(); + assertEquals(CONTINUE, interceptor.handleRequest(exc)); + Message msg = exc.getRequest(); + assertXPath("1", msg, "/root"); + } + + @Test + void invalid() throws URISyntaxException { + Exchange exc = put("/invalid").json("{").buildExchange(); assertEquals(ABORT, interceptor.handleRequest(exc)); - assertTrue(exc.getResponse().getBodyAsStringDecoded().contains("Error parsing JSON")); + Response res = exc.getResponse(); + assertEquals(500, res.getStatusCode()); + assertEquals("Error parsing JSON", new JsonPath(res.getBodyAsStringDecoded()).get("title")); } private static String xPath(String body, String expression) throws XPathExpressionException { - System.out.println("body = " + body); return xPathFactory.newXPath().evaluate(expression, new InputSource(new StringReader(body))); } + private void assertXPath(String expected, Message msg, String xpath) throws XPathExpressionException { + assertEquals(expected, xPath(msg.getBodyAsStringDecoded(), xpath)); + } } diff --git a/core/src/test/java/com/predic8/membrane/core/util/JsonUtilTest.java b/core/src/test/java/com/predic8/membrane/core/util/JsonUtilTest.java index 30feb97005..921be3b3e8 100644 --- a/core/src/test/java/com/predic8/membrane/core/util/JsonUtilTest.java +++ b/core/src/test/java/com/predic8/membrane/core/util/JsonUtilTest.java @@ -17,7 +17,7 @@ import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; -import static com.predic8.membrane.core.util.JsonUtil.scalarAsJson; +import static com.predic8.membrane.core.util.json.JsonUtil.scalarAsJson; import static org.junit.jupiter.api.Assertions.*; class JsonUtilTest { diff --git a/core/src/test/java/com/predic8/membrane/core/util/json/JsonToXmlTest.java b/core/src/test/java/com/predic8/membrane/core/util/json/JsonToXmlTest.java new file mode 100644 index 0000000000..82a426d408 --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/util/json/JsonToXmlTest.java @@ -0,0 +1,228 @@ +package com.predic8.membrane.core.util.json; + +import io.restassured.path.xml.*; +import org.junit.jupiter.api.*; + +import static com.predic8.membrane.core.util.json.JsonToXml.XML_PROLOG; +import static com.predic8.membrane.core.util.json.JsonToXml.parseLiteral; +import static org.junit.jupiter.api.Assertions.*; + +class JsonToXmlTest { + + XmlPath xp; + JsonToXml conv; + + @BeforeEach + void setup() { + conv = new JsonToXml().rootName("root").arrayName("list").itemName("item"); + } + + @Nested + class Configuration { + + @Test + void noRootNameSet() { + conv.rootName(null); + XmlPath xp = new XmlPath(conv.toXml("{}")); + assertEquals("", xp.get("root")); + } + + @Test + void topLevelArray_withoutRoot_usesArrayNameAsRoot() { + conv.rootName(null).arrayName("items"); + assertEquals(2, new XmlPath(conv.toXml("[1,2]")).getList("items.item").size()); + } + } + + + @Nested + class ObjectsAndArrays { + + @BeforeEach + void setup() { + var json = """ + { + "one": [1], + "nested": [[2]], + "three": [[[3]]], + "different": [ + {"foo":4}, + [1,2,[3,[4]]], + 5 + ] + } + """; + xp = new XmlPath(conv.toXml(json)); + } + + @Test + void one_isPreserved() { + assertEquals(1, xp.getInt("root.one.list.item")); + assertEquals(1, xp.getList("root.one.list.item").size()); + } + + @Test + void nested_isPreserved() { + assertEquals(1, xp.getList("root.nested.list.item.list.item").size()); + assertEquals(2, xp.getInt("root.nested.list.item.list.item")); + } + + @Test + void three_isPreserved() { + assertEquals(3, xp.getInt("root.three.list.item.list.item.list.item")); + assertEquals(1, xp.getList("root.three.list.item.list.item.list.item").size()); + } + + @Test + void different_isPreserved() { + // {"foo":4} + assertEquals(4, xp.getInt("root.different.list.item[0].foo")); + + // [1,2,[3,[4]]] + assertEquals(2, xp.getInt("root.different.list.item[1].list.item[1]")); + assertEquals(3, xp.getInt("root.different.list.item[1].list.item[2].list.item[0]")); + assertEquals(4, xp.getInt("root.different.list.item[1].list.item[2].list.item[1].list.item")); + + // 5 + assertEquals(5, xp.getInt("root.different.list.item[2]")); + } + } + + @Nested + class Objects { + + @Test + void singleProperty() { + conv.rootName(null); + + String xml = conv.toXml(""" + { + "person": { + "name": "John", + "age": 30 + } + } + """); + XmlPath xp = new XmlPath(xml); + assertEquals("John", xp.getString("person.name")); + assertEquals("30", xp.getString("person.age")); + } + + } + + @Nested + class Arrays { + + @Test + void emptyArray_isConverted() { + String xml = conv.toXml("[]"); + XmlPath xp = new XmlPath(xml); + + assertNotNull(xp.get("root.list")); + assertEquals("", xp.get("root.list")); + assertTrue(xml.contains("")); + } + + @Test + void singleElementArray_isConverted() { + var xml = conv.toXml("[1]"); + XmlPath xp = new XmlPath(xml); + + assertEquals(1, xp.getInt("root.list.item[0]")); + assertEquals(1, xp.getList("root.list.item").size()); + } + + @Test + void flatArray_isConverted() { + XmlPath xp = new XmlPath(conv.toXml("[1,2,3]")); + + assertEquals(3, xp.getList("root.list.item").size()); + assertEquals(1, xp.getInt("root.list.item[0]")); + assertEquals(2, xp.getInt("root.list.item[1]")); + assertEquals(3, xp.getInt("root.list.item[2]")); + } + + @Test + void nestedArray_isConverted() { + XmlPath xp = new XmlPath(conv.toXml("[1,[2],[[3]]]")); + + assertEquals(1, xp.getInt("root.list.item[0]")); + assertEquals(2, xp.getInt("root.list.item[1].list.item[0]")); + assertEquals(3, xp.getInt("root.list.item[2].list.item[0].list.item[0]")); + } + } + + @Nested + class PrimitiveTypes { + + @Test + void stringValue_isConverted() { + assertEquals("hello", new XmlPath(conv.toXml("\"hello\"")).getString("root")); + } + + @Test + void numberValue_isConverted() { + assertEquals(123, new XmlPath(conv.toXml("123")).getInt("root")); + } + + @Test + void booleanValue_isConverted() { + assertTrue(new XmlPath(conv.toXml("true")).getBoolean("root")); + } + + @Test + void nullValue_isConvertedToEmptyNode() { + XmlPath xp = new XmlPath(conv.toXml("null")); + String v = xp.getString("root"); + assertTrue(v == null || v.isBlank()); + } + } + + @Test + void escape() { + assertTrue( conv.toXml(""" + { "chars": "> < & \\" '" } + """).contains("> < & \" '")); + } + + @Test + void strangeKeys() { + conv.rootName(null); + assertEquals( XML_PROLOG + "1",conv.toXml(""" + { "a b": 1 } + """)); + assertEquals( XML_PROLOG + "<_123>1",conv.toXml(""" + { "123": 1 } + """)); + } + + @Test + void testParseLiteral() { + // Integer + assertEquals(42L, parseLiteral("42")); + assertEquals(-7L, parseLiteral("-7")); + + // Float / Double + assertEquals(3.14d, parseLiteral("3.14")); + assertEquals(-0.1d, parseLiteral("-0.1")); + assertEquals(1.2e3d, parseLiteral("1.2e3")); + assertEquals(-5.6E-2d, parseLiteral("-5.6E-2")); + + // Overflow case → stays Double + Object big = parseLiteral("999999999999999999999999"); + assertInstanceOf(Double.class, big); + + // Quoted strings + assertEquals("hello", parseLiteral("\"hello\"")); + assertEquals("", parseLiteral("\"\"")); + + // Unquoted strings + assertEquals("abc", parseLiteral("abc")); + assertEquals("123abc", parseLiteral("123abc")); + assertEquals("true", parseLiteral("true")); // not converted to boolean + + // Edge cases + assertEquals("-", parseLiteral("-")); // not a number + assertEquals(".", parseLiteral(".")); // not a number + } +} From 642c0c5f43996f88bd7ded430edc2b9ee2b6139a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= <118011644+christiangoerdes@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:14:20 +0100 Subject: [PATCH 02/16] Restrict JAVA_OPTS path normalization to log4j.configurationFile in start_router.sh (for 6.X) (#2381) --- distribution/scripts/start_router.sh | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/distribution/scripts/start_router.sh b/distribution/scripts/start_router.sh index c775093946..5deb1ce70f 100644 --- a/distribution/scripts/start_router.sh +++ b/distribution/scripts/start_router.sh @@ -21,11 +21,14 @@ normalize_java_opts() { -D*=*) key=${tok%%=*} val=${tok#*=} - case "$val" in - /*|~/*|[A-Za-z]:/*|\\\\*|file:*|*://*) : ;; - *) val="$MEMBRANE_HOME/$val" ;; - esac - tok="$key=$val" + # Only normalize when the key is log4j.configurationFile + if [ "$key" = "-Dlog4j.configurationFile" ]; then + case "$val" in + /*|~/*|[A-Za-z]:/*|\\\\*|file:*|*://*) : ;; + *) val="$MEMBRANE_HOME/$val" ;; + esac + tok="$key=$val" + fi ;; esac NEW_OPTS="$NEW_OPTS $tok" From 28968c134fa3586b19bd5eb28274e6dd9a53c439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 5 Dec 2025 14:16:33 +0100 Subject: [PATCH 03/16] fix javadoc --- .../membrane/core/interceptor/xml/Json2XmlInterceptor.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptor.java index 58b169a412..4480483718 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptor.java @@ -32,8 +32,7 @@ * @description Converts JSON message bodies into XML. * The converter wraps the JSON document into a root element. The name of the * root element is configurable. If unset, JSON objects default to "root" and JSON arrays default to "array". - * - * @explanation + *

* This interceptor reads the JSON body, converts it into XML and updates the message * body and Content-Type header. The resulting XML is always UTF-8 encoded and starts with an XML prolog. * From 4ba8b064e50567888b3be6b69cffdaa45c934e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 5 Dec 2025 14:17:34 +0100 Subject: [PATCH 04/16] fix javadoc --- .../membrane/core/interceptor/xml/Json2XmlInterceptor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptor.java index 4480483718..0102e4c985 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/xml/Json2XmlInterceptor.java @@ -32,7 +32,7 @@ * @description Converts JSON message bodies into XML. * The converter wraps the JSON document into a root element. The name of the * root element is configurable. If unset, JSON objects default to "root" and JSON arrays default to "array". - *

+ * * This interceptor reads the JSON body, converts it into XML and updates the message * body and Content-Type header. The resulting XML is always UTF-8 encoded and starts with an XML prolog. * From 1a32a4c28ea2f8da25f3aa870eaf3470ceeb190c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:38:03 +0100 Subject: [PATCH 05/16] Release 6.3.11 (#2384) Co-authored-by: github-actions --- annot/pom.xml | 2 +- core/.factorypath | 2 +- core/pom.xml | 2 +- .../examples/extending-membrane/custom-interceptor/pom.xml | 2 +- distribution/examples/extending-membrane/embedding-java/pom.xml | 2 +- distribution/examples/web-services-soap/add-soap-header/pom.xml | 2 +- distribution/examples/xml/basic-xml-interceptor/pom.xml | 2 +- distribution/examples/xml/stax-interceptor/pom.xml | 2 +- distribution/pom.xml | 2 +- maven-plugin/pom.xml | 2 +- membrane.spec | 2 +- pom.xml | 2 +- test/pom.xml | 2 +- war/.factorypath | 2 +- war/pom.xml | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/annot/pom.xml b/annot/pom.xml index 935cb6d405..d266549cc3 100644 --- a/annot/pom.xml +++ b/annot/pom.xml @@ -23,7 +23,7 @@ org.membrane-soa service-proxy-parent ../pom.xml - 6.3.11-SNAPSHOT + 6.3.11 diff --git a/core/.factorypath b/core/.factorypath index 4d5b485791..5118d50132 100644 --- a/core/.factorypath +++ b/core/.factorypath @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/core/pom.xml b/core/pom.xml index ecd78eb30a..0d28c5c0e0 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -23,7 +23,7 @@ org.membrane-soa service-proxy-parent ../pom.xml - 6.3.11-SNAPSHOT + 6.3.11 diff --git a/distribution/examples/extending-membrane/custom-interceptor/pom.xml b/distribution/examples/extending-membrane/custom-interceptor/pom.xml index 4304c9e618..a783b36afc 100644 --- a/distribution/examples/extending-membrane/custom-interceptor/pom.xml +++ b/distribution/examples/extending-membrane/custom-interceptor/pom.xml @@ -25,7 +25,7 @@ org.membrane-soa service-proxy-core - 6.3.11-SNAPSHOT + 6.3.11 diff --git a/distribution/examples/extending-membrane/embedding-java/pom.xml b/distribution/examples/extending-membrane/embedding-java/pom.xml index 8738e38f44..e61a78779d 100644 --- a/distribution/examples/extending-membrane/embedding-java/pom.xml +++ b/distribution/examples/extending-membrane/embedding-java/pom.xml @@ -29,7 +29,7 @@ org.membrane-soa service-proxy-core - 6.3.11-SNAPSHOT + 6.3.11 org.projectlombok diff --git a/distribution/examples/web-services-soap/add-soap-header/pom.xml b/distribution/examples/web-services-soap/add-soap-header/pom.xml index 5a8ac1d20f..fcefe1849d 100644 --- a/distribution/examples/web-services-soap/add-soap-header/pom.xml +++ b/distribution/examples/web-services-soap/add-soap-header/pom.xml @@ -18,7 +18,7 @@ org.membrane-soa service-proxy-core - 6.3.11-SNAPSHOT + 6.3.11 diff --git a/distribution/examples/xml/basic-xml-interceptor/pom.xml b/distribution/examples/xml/basic-xml-interceptor/pom.xml index e0e6ecd4ee..5c347e7118 100644 --- a/distribution/examples/xml/basic-xml-interceptor/pom.xml +++ b/distribution/examples/xml/basic-xml-interceptor/pom.xml @@ -18,7 +18,7 @@ org.membrane-soa service-proxy-core - 6.3.11-SNAPSHOT + 6.3.11 diff --git a/distribution/examples/xml/stax-interceptor/pom.xml b/distribution/examples/xml/stax-interceptor/pom.xml index 5f7175ae22..de9128c05d 100644 --- a/distribution/examples/xml/stax-interceptor/pom.xml +++ b/distribution/examples/xml/stax-interceptor/pom.xml @@ -19,7 +19,7 @@ org.membrane-soa service-proxy-core - 6.3.11-SNAPSHOT + 6.3.11 diff --git a/distribution/pom.xml b/distribution/pom.xml index 1e74716278..39ae696b99 100644 --- a/distribution/pom.xml +++ b/distribution/pom.xml @@ -27,7 +27,7 @@ org.membrane-soa service-proxy-parent ../pom.xml - 6.3.11-SNAPSHOT + 6.3.11 diff --git a/maven-plugin/pom.xml b/maven-plugin/pom.xml index 2d81eaafdd..e439c482cc 100644 --- a/maven-plugin/pom.xml +++ b/maven-plugin/pom.xml @@ -25,7 +25,7 @@ org.membrane-soa service-proxy-parent ../pom.xml - 6.3.11-SNAPSHOT + 6.3.11 diff --git a/membrane.spec b/membrane.spec index 065c027c0b..b62cc82579 100644 --- a/membrane.spec +++ b/membrane.spec @@ -4,7 +4,7 @@ %global logdir %{_var}/log/%{name} Name: membrane -Version: 6.3.11-SNAPSHOT +Version: 6.3.11 Release: 1%{?dist} URL: https://github.com/membrane/api-gateway Summary: Membrane - Open Source API Gateway written in Java for REST APIs, WebSockets, STOMP and legacy Web Services diff --git a/pom.xml b/pom.xml index 4e32ec4879..a72d3ab464 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ 4.0.0 org.membrane-soa service-proxy-parent - 6.3.11-SNAPSHOT + 6.3.11 ${project.artifactId} Membrane is an open source API Gateway written in Java that features: - OpenAPI support with validation diff --git a/test/pom.xml b/test/pom.xml index 39d7fe0ae0..136dd5708b 100644 --- a/test/pom.xml +++ b/test/pom.xml @@ -23,7 +23,7 @@ org.membrane-soa service-proxy-parent ../pom.xml - 6.3.11-SNAPSHOT + 6.3.11 diff --git a/war/.factorypath b/war/.factorypath index 4d5b485791..5118d50132 100644 --- a/war/.factorypath +++ b/war/.factorypath @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/war/pom.xml b/war/pom.xml index c1e37be547..272944d76d 100644 --- a/war/pom.xml +++ b/war/pom.xml @@ -23,7 +23,7 @@ org.membrane-soa service-proxy-parent ../pom.xml - 6.3.11-SNAPSHOT + 6.3.11 From 3beaa7e27e69766b44049249eda65f11c540154c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:04:28 +0100 Subject: [PATCH 06/16] Snapshot version (#2385) Co-authored-by: github-actions --- annot/pom.xml | 2 +- core/.factorypath | 2 +- core/pom.xml | 2 +- .../examples/extending-membrane/custom-interceptor/pom.xml | 2 +- distribution/examples/extending-membrane/embedding-java/pom.xml | 2 +- distribution/examples/web-services-soap/add-soap-header/pom.xml | 2 +- distribution/examples/xml/basic-xml-interceptor/pom.xml | 2 +- distribution/examples/xml/stax-interceptor/pom.xml | 2 +- distribution/pom.xml | 2 +- maven-plugin/pom.xml | 2 +- membrane.spec | 2 +- pom.xml | 2 +- test/pom.xml | 2 +- war/.factorypath | 2 +- war/pom.xml | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/annot/pom.xml b/annot/pom.xml index d266549cc3..1ff7b72128 100644 --- a/annot/pom.xml +++ b/annot/pom.xml @@ -23,7 +23,7 @@ org.membrane-soa service-proxy-parent ../pom.xml - 6.3.11 + 6.3.12-SNAPSHOT diff --git a/core/.factorypath b/core/.factorypath index 5118d50132..472e583632 100644 --- a/core/.factorypath +++ b/core/.factorypath @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/core/pom.xml b/core/pom.xml index 0d28c5c0e0..3797107386 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -23,7 +23,7 @@ org.membrane-soa service-proxy-parent ../pom.xml - 6.3.11 + 6.3.12-SNAPSHOT diff --git a/distribution/examples/extending-membrane/custom-interceptor/pom.xml b/distribution/examples/extending-membrane/custom-interceptor/pom.xml index a783b36afc..954eff6aba 100644 --- a/distribution/examples/extending-membrane/custom-interceptor/pom.xml +++ b/distribution/examples/extending-membrane/custom-interceptor/pom.xml @@ -25,7 +25,7 @@ org.membrane-soa service-proxy-core - 6.3.11 + 6.3.12-SNAPSHOT diff --git a/distribution/examples/extending-membrane/embedding-java/pom.xml b/distribution/examples/extending-membrane/embedding-java/pom.xml index e61a78779d..f06ba78ee6 100644 --- a/distribution/examples/extending-membrane/embedding-java/pom.xml +++ b/distribution/examples/extending-membrane/embedding-java/pom.xml @@ -29,7 +29,7 @@ org.membrane-soa service-proxy-core - 6.3.11 + 6.3.12-SNAPSHOT org.projectlombok diff --git a/distribution/examples/web-services-soap/add-soap-header/pom.xml b/distribution/examples/web-services-soap/add-soap-header/pom.xml index fcefe1849d..1def05a8b4 100644 --- a/distribution/examples/web-services-soap/add-soap-header/pom.xml +++ b/distribution/examples/web-services-soap/add-soap-header/pom.xml @@ -18,7 +18,7 @@ org.membrane-soa service-proxy-core - 6.3.11 + 6.3.12-SNAPSHOT diff --git a/distribution/examples/xml/basic-xml-interceptor/pom.xml b/distribution/examples/xml/basic-xml-interceptor/pom.xml index 5c347e7118..b738175722 100644 --- a/distribution/examples/xml/basic-xml-interceptor/pom.xml +++ b/distribution/examples/xml/basic-xml-interceptor/pom.xml @@ -18,7 +18,7 @@ org.membrane-soa service-proxy-core - 6.3.11 + 6.3.12-SNAPSHOT diff --git a/distribution/examples/xml/stax-interceptor/pom.xml b/distribution/examples/xml/stax-interceptor/pom.xml index de9128c05d..ab9ff6ca80 100644 --- a/distribution/examples/xml/stax-interceptor/pom.xml +++ b/distribution/examples/xml/stax-interceptor/pom.xml @@ -19,7 +19,7 @@ org.membrane-soa service-proxy-core - 6.3.11 + 6.3.12-SNAPSHOT diff --git a/distribution/pom.xml b/distribution/pom.xml index 39ae696b99..27fe13a045 100644 --- a/distribution/pom.xml +++ b/distribution/pom.xml @@ -27,7 +27,7 @@ org.membrane-soa service-proxy-parent ../pom.xml - 6.3.11 + 6.3.12-SNAPSHOT diff --git a/maven-plugin/pom.xml b/maven-plugin/pom.xml index e439c482cc..770c07cf3f 100644 --- a/maven-plugin/pom.xml +++ b/maven-plugin/pom.xml @@ -25,7 +25,7 @@ org.membrane-soa service-proxy-parent ../pom.xml - 6.3.11 + 6.3.12-SNAPSHOT diff --git a/membrane.spec b/membrane.spec index b62cc82579..fc7c4f393d 100644 --- a/membrane.spec +++ b/membrane.spec @@ -4,7 +4,7 @@ %global logdir %{_var}/log/%{name} Name: membrane -Version: 6.3.11 +Version: 6.3.12-SNAPSHOT Release: 1%{?dist} URL: https://github.com/membrane/api-gateway Summary: Membrane - Open Source API Gateway written in Java for REST APIs, WebSockets, STOMP and legacy Web Services diff --git a/pom.xml b/pom.xml index a72d3ab464..b5ab35f5a5 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ 4.0.0 org.membrane-soa service-proxy-parent - 6.3.11 + 6.3.12-SNAPSHOT ${project.artifactId} Membrane is an open source API Gateway written in Java that features: - OpenAPI support with validation diff --git a/test/pom.xml b/test/pom.xml index 136dd5708b..2e79c559e6 100644 --- a/test/pom.xml +++ b/test/pom.xml @@ -23,7 +23,7 @@ org.membrane-soa service-proxy-parent ../pom.xml - 6.3.11 + 6.3.12-SNAPSHOT diff --git a/war/.factorypath b/war/.factorypath index 5118d50132..472e583632 100644 --- a/war/.factorypath +++ b/war/.factorypath @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/war/pom.xml b/war/pom.xml index 272944d76d..d5aad31f4b 100644 --- a/war/pom.xml +++ b/war/pom.xml @@ -23,7 +23,7 @@ org.membrane-soa service-proxy-parent ../pom.xml - 6.3.11 + 6.3.12-SNAPSHOT From 0757b57c1bf913164fb9627212006869a14349b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Thu, 18 Dec 2025 16:38:26 +0100 Subject: [PATCH 07/16] Add support for reference schemas in JSON/YAML schema validation (infrastructure) --- .../ValidatorInterceptor.java | 18 +++++- .../json/JSONYAMLSchemaValidator.java | 45 ++++++++------ .../json/ReferenceSchemas.java | 60 +++++++++++++++++++ 3 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/ReferenceSchemas.java diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java index f183ae349d..7d8e415b04 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java @@ -66,6 +66,8 @@ public class ValidatorInterceptor extends AbstractInterceptor implements Applica private ResolverMap resourceResolver; private ApplicationContext applicationContext; + private ReferenceSchemas referenceSchemas; + public ValidatorInterceptor() { name = "validator"; } @@ -95,10 +97,14 @@ private MessageValidator getMessageValidator() throws Exception { return new WSDLValidator(resourceResolver, combine(getBaseLocation(), wsdl), serviceName, createFailureHandler(), skipFaults); } if (schema != null) { + if (referenceSchemas != null) + log.warn("Schema References are only for Json and Yaml"); // TODO improve log statement return new XMLSchemaValidator(resourceResolver, combine(getBaseLocation(), schema), createFailureHandler()); } if (jsonSchema != null) { - return new JSONYAMLSchemaValidator(resourceResolver, combine(getBaseLocation(), jsonSchema), createFailureHandler(), schemaVersion); + return new JSONYAMLSchemaValidator(resourceResolver, combine(getBaseLocation(), jsonSchema), createFailureHandler(), schemaVersion) {{ + setSchemaMappings(referenceSchemas.getSchemaMap()); + }}; } if (schematron != null) { return new SchematronValidator(combine(getBaseLocation(), schematron), createFailureHandler(), router, applicationContext); @@ -319,4 +325,14 @@ private FailureHandler createFailureHandler() { throw new IllegalArgumentException("Unknown failureHandler type: " + failureHandler); } + @MCChildElement + public void setReferenceSchemas(ReferenceSchemas referenceSchemas) { + this.referenceSchemas = referenceSchemas; + } + + public ReferenceSchemas getReferenceSchemas() { + return referenceSchemas; + } + + } diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java index 15b3b8ca3c..e0b4e4f31f 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java @@ -14,32 +14,32 @@ package com.predic8.membrane.core.interceptor.schemavalidation.json; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLParser; import com.networknt.schema.*; -import com.networknt.schema.serialization.YamlMapperFactory; -import com.predic8.membrane.core.exchange.*; -import com.predic8.membrane.core.interceptor.Interceptor.*; -import com.predic8.membrane.core.interceptor.*; -import com.predic8.membrane.core.interceptor.schemavalidation.*; -import com.predic8.membrane.core.interceptor.schemavalidation.ValidatorInterceptor.*; -import com.predic8.membrane.core.resolver.*; -import org.jetbrains.annotations.*; -import org.slf4j.*; +import com.predic8.membrane.core.exchange.Exchange; +import com.predic8.membrane.core.interceptor.Interceptor.Flow; +import com.predic8.membrane.core.interceptor.Outcome; +import com.predic8.membrane.core.interceptor.schemavalidation.AbstractMessageValidator; +import com.predic8.membrane.core.interceptor.schemavalidation.ValidatorInterceptor.FailureHandler; +import com.predic8.membrane.core.resolver.Resolver; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; -import java.nio.charset.*; +import java.nio.charset.Charset; import java.util.*; -import java.util.concurrent.atomic.*; +import java.util.concurrent.atomic.AtomicLong; import static com.fasterxml.jackson.core.StreamReadFeature.STRICT_DUPLICATE_DETECTION; import static com.networknt.schema.InputFormat.JSON; import static com.networknt.schema.InputFormat.YAML; -import static com.predic8.membrane.core.exceptions.ProblemDetails.*; -import static com.predic8.membrane.core.interceptor.Outcome.*; -import static java.nio.charset.StandardCharsets.*; +import static com.predic8.membrane.core.exceptions.ProblemDetails.user; +import static com.predic8.membrane.core.interceptor.Outcome.ABORT; +import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE; +import static java.nio.charset.StandardCharsets.UTF_8; public class JSONYAMLSchemaValidator extends AbstractMessageValidator { @@ -57,6 +57,10 @@ public class JSONYAMLSchemaValidator extends AbstractMessageValidator { private final AtomicLong invalid = new AtomicLong(); private final SpecVersion.VersionFlag schemaId; + + private Map schemaMappings = new HashMap<>(); + + /** * JsonSchemaFactory instances are thread-safe provided its configuration is not modified. */ @@ -95,9 +99,12 @@ public String getName() { @Override public void init() { super.init(); - + System.out.println("schemaMappings = " + schemaMappings); jsonSchemaFactory = JsonSchemaFactory.getInstance(schemaId, builder -> - builder.schemaLoaders(loaders -> loaders.add(new MembraneSchemaLoader(resolver))) + builder + .schemaLoaders(loaders -> loaders.add(new MembraneSchemaLoader(resolver))) + .schemaMappers(mappers -> mappers.mappings(schemaMappings)) + // builder.schemaMappers(schemaMappers -> schemaMappers.mapPrefix("https://www.example.org/", "classpath:/")) ); @@ -212,4 +219,8 @@ public long getInvalid() { public String getErrorTitle() { return "JSON validation failed"; } + + public void setSchemaMappings(Map schemaMappings) { + this.schemaMappings = schemaMappings; + } } \ No newline at end of file diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/ReferenceSchemas.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/ReferenceSchemas.java new file mode 100644 index 0000000000..be9272bf4c --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/ReferenceSchemas.java @@ -0,0 +1,60 @@ +package com.predic8.membrane.core.interceptor.schemavalidation.json; + +import com.predic8.membrane.annot.MCAttribute; +import com.predic8.membrane.annot.MCChildElement; +import com.predic8.membrane.annot.MCElement; +import com.predic8.membrane.annot.Required; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@MCElement(name = "referenceSchemas") +public class ReferenceSchemas { + + List schemas = new ArrayList<>(); + + public Map getSchemaMap() { + Map referenceSchemas = new HashMap<>(); + schemas.forEach(schema -> referenceSchemas.put(schema.getId(), schema.getLocation())); + return referenceSchemas; + } + + @Required + @MCChildElement + public void setSchemas(List schemas) { + this.schemas = schemas; + } + + public List getSchemas() { + return schemas; + } + + @MCElement(name = "schema") + public static class Schema { + private String id; + + private String location; + + public Schema() {} + + @MCAttribute + public void setId(String id) { + this.id = id; + } + + public String getId() { + return id; + } + + @MCAttribute + public void setLocation(String location) { + this.location = location; + } + + public String getLocation() { + return location; + } + } +} From d164acc009980d69fb3255553891abd4beeaa044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Thu, 18 Dec 2025 16:52:01 +0100 Subject: [PATCH 08/16] Make `schemas` field private in ReferenceSchemas --- .../interceptor/schemavalidation/json/ReferenceSchemas.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/ReferenceSchemas.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/ReferenceSchemas.java index be9272bf4c..6bfd9a1da7 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/ReferenceSchemas.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/ReferenceSchemas.java @@ -13,7 +13,7 @@ @MCElement(name = "referenceSchemas") public class ReferenceSchemas { - List schemas = new ArrayList<>(); + private List schemas = new ArrayList<>(); public Map getSchemaMap() { Map referenceSchemas = new HashMap<>(); From 305a08c745ccfddf7348d51f60995d6c4c32f2bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Thu, 18 Dec 2025 17:00:14 +0100 Subject: [PATCH 09/16] Improve logging for unsupported referenceSchemas in schema validation --- .../interceptor/schemavalidation/ValidatorInterceptor.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java index 7d8e415b04..1b6d1cb846 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java @@ -46,6 +46,7 @@ public class ValidatorInterceptor extends AbstractInterceptor implements ApplicationContextAware { private static final Logger log = LoggerFactory.getLogger(ValidatorInterceptor.class.getName()); + public static final String IGNORING_SCHEMA_LOG = "Ignoring 'referenceSchemas': schema references are only supported for JSON/YAML validators"; private String wsdl; private String schema; @@ -94,11 +95,13 @@ public void init() { private MessageValidator getMessageValidator() throws Exception { if (wsdl != null) { + if (referenceSchemas != null) + log.warn(IGNORING_SCHEMA_LOG); return new WSDLValidator(resourceResolver, combine(getBaseLocation(), wsdl), serviceName, createFailureHandler(), skipFaults); } if (schema != null) { if (referenceSchemas != null) - log.warn("Schema References are only for Json and Yaml"); // TODO improve log statement + log.warn(IGNORING_SCHEMA_LOG); return new XMLSchemaValidator(resourceResolver, combine(getBaseLocation(), schema), createFailureHandler()); } if (jsonSchema != null) { @@ -107,6 +110,8 @@ private MessageValidator getMessageValidator() throws Exception { }}; } if (schematron != null) { + if (referenceSchemas != null) + log.warn(IGNORING_SCHEMA_LOG); return new SchematronValidator(combine(getBaseLocation(), schematron), createFailureHandler(), router, applicationContext); } From ab8d0f7fad33cd09a17b2e77b4a7006f63cadbec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 19 Dec 2025 09:12:03 +0100 Subject: [PATCH 10/16] Refactor schema validation logging and add example for JSON schema with reference mappings --- .../ValidatorInterceptor.java | 11 ++-- .../json/JSONYAMLSchemaValidator.java | 1 - .../json-schema/schema-mappings/README.md | 51 +++++++++++++++++++ .../json-schema/schema-mappings/bad2000.json | 7 +++ .../json-schema/schema-mappings/bad2001.json | 12 +++++ .../json-schema/schema-mappings/good2000.json | 7 +++ .../json-schema/schema-mappings/good2001.json | 16 ++++++ .../json-schema/schema-mappings/membrane.cmd | 24 +++++++++ .../json-schema/schema-mappings/membrane.sh | 21 ++++++++ .../json-schema/schema-mappings/proxies.xml | 41 +++++++++++++++ .../schema-mappings/schemas/base-param.json | 21 ++++++++ .../schema-mappings/schemas/meta.json | 24 +++++++++ .../schema-mappings/schemas/schema2000.json | 19 +++++++ .../schema-mappings/schemas/schema2001.json | 22 ++++++++ 14 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 distribution/examples/validation/json-schema/schema-mappings/README.md create mode 100644 distribution/examples/validation/json-schema/schema-mappings/bad2000.json create mode 100644 distribution/examples/validation/json-schema/schema-mappings/bad2001.json create mode 100644 distribution/examples/validation/json-schema/schema-mappings/good2000.json create mode 100644 distribution/examples/validation/json-schema/schema-mappings/good2001.json create mode 100644 distribution/examples/validation/json-schema/schema-mappings/membrane.cmd create mode 100755 distribution/examples/validation/json-schema/schema-mappings/membrane.sh create mode 100644 distribution/examples/validation/json-schema/schema-mappings/proxies.xml create mode 100644 distribution/examples/validation/json-schema/schema-mappings/schemas/base-param.json create mode 100644 distribution/examples/validation/json-schema/schema-mappings/schemas/meta.json create mode 100644 distribution/examples/validation/json-schema/schema-mappings/schemas/schema2000.json create mode 100644 distribution/examples/validation/json-schema/schema-mappings/schemas/schema2001.json diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java index 1b6d1cb846..9607abf355 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java @@ -46,7 +46,6 @@ public class ValidatorInterceptor extends AbstractInterceptor implements ApplicationContextAware { private static final Logger log = LoggerFactory.getLogger(ValidatorInterceptor.class.getName()); - public static final String IGNORING_SCHEMA_LOG = "Ignoring 'referenceSchemas': schema references are only supported for JSON/YAML validators"; private String wsdl; private String schema; @@ -96,12 +95,12 @@ public void init() { private MessageValidator getMessageValidator() throws Exception { if (wsdl != null) { if (referenceSchemas != null) - log.warn(IGNORING_SCHEMA_LOG); + logIgnoringRefSchemas(); return new WSDLValidator(resourceResolver, combine(getBaseLocation(), wsdl), serviceName, createFailureHandler(), skipFaults); } if (schema != null) { if (referenceSchemas != null) - log.warn(IGNORING_SCHEMA_LOG); + logIgnoringRefSchemas(); return new XMLSchemaValidator(resourceResolver, combine(getBaseLocation(), schema), createFailureHandler()); } if (jsonSchema != null) { @@ -111,7 +110,7 @@ private MessageValidator getMessageValidator() throws Exception { } if (schematron != null) { if (referenceSchemas != null) - log.warn(IGNORING_SCHEMA_LOG); + logIgnoringRefSchemas(); return new SchematronValidator(combine(getBaseLocation(), schematron), createFailureHandler(), router, applicationContext); } @@ -121,6 +120,10 @@ private MessageValidator getMessageValidator() throws Exception { throw new RuntimeException("Validator is not configured properly. must have an attribute specifying the validator."); } + private static void logIgnoringRefSchemas() { + log.warn("Ignoring 'referenceSchemas': schema references are only supported for JSON/YAML validators"); + } + private @Nullable WSDLValidator getWsdlValidatorFromSOAPProxy() { if (router.getParentProxy(this) instanceof SOAPProxy sp) { wsdl = sp.getWsdl(); diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java index e0b4e4f31f..cf0b48991d 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java @@ -99,7 +99,6 @@ public String getName() { @Override public void init() { super.init(); - System.out.println("schemaMappings = " + schemaMappings); jsonSchemaFactory = JsonSchemaFactory.getInstance(schemaId, builder -> builder .schemaLoaders(loaders -> loaders.add(new MembraneSchemaLoader(resolver))) diff --git a/distribution/examples/validation/json-schema/schema-mappings/README.md b/distribution/examples/validation/json-schema/schema-mappings/README.md new file mode 100644 index 0000000000..47af48b647 --- /dev/null +++ b/distribution/examples/validation/json-schema/schema-mappings/README.md @@ -0,0 +1,51 @@ +# Validation - JSON Schema with Schema Mappings + +This sample explains how to set up and use the `validator` plugin with JSON Schemas that reference external schemas +by `$ref` URN/ID, and how to map those references to local files via `referenceSchemas`. + +## Running the Example + +1. Go to the directory: + `/examples/validation/json-schema/schema-mappings` + +2. Start `membrane.cmd` or `membrane.sh`. + +3. Look at `schemas/schema2000.json` and note the `$ref` to `urn:app:base_parameter_def`. Then open `schemas/base-param.json` and compare the schema to `good2000.json` and `bad2000.json`. + +4. Run `curl -H "Content-Type: application/json" -d @good2000.json http://localhost:2000/` on the console. Observe that you get a successful response. + +5. Run `curl -H "Content-Type: application/json" -d @bad2000.json http://localhost:2000/`. Observe that you get a validation error response. + +Keeping the router running, you can try a more complex setup with multiple referenced schemas. + +1. Have a look at `schemas/schema2001.json` and note the `$ref`s to `urn:app:base_parameter_def` and `urn:app:meta_def`. Then open `schemas/base-param.json` and `schemas/meta.json` and compare the schemas to `good2001.json` and `bad2001.json`. + +2. Run `curl -H "Content-Type: application/json" -d @good2001.json http://localhost:2001/`. Observe that you get a successful response. + +3. Run `curl -H "Content-Type: application/json" -d @bad2001.json http://localhost:2001/`. Observe that you get a validation error response. + +## How it is done + +In `proxies.xml`, each API configures a ``. +The root schemas contain `$ref` references to URN/IDs, for example: + +- `urn:app:base_parameter_def#/$defs/BaseParameter` +- `urn:app:meta_def#/$defs/Meta` + +To let Membrane resolve these URNs, you map them in the validator using: + +```xml + + + + +```` + +Only if validation succeeds, the request is forwarded to the backend (port 2002). + +--- + +See: + +* [JSON Schema](https://json-schema.org/) documentation +* [validator](https://www.membrane-api.io/docs/current/validator.html) reference \ No newline at end of file diff --git a/distribution/examples/validation/json-schema/schema-mappings/bad2000.json b/distribution/examples/validation/json-schema/schema-mappings/bad2000.json new file mode 100644 index 0000000000..80cd797310 --- /dev/null +++ b/distribution/examples/validation/json-schema/schema-mappings/bad2000.json @@ -0,0 +1,7 @@ +{ + "param": { + "name": "limit" + }, + "timestamp": "not-a-date", + "extra": true +} diff --git a/distribution/examples/validation/json-schema/schema-mappings/bad2001.json b/distribution/examples/validation/json-schema/schema-mappings/bad2001.json new file mode 100644 index 0000000000..be22cf9ee3 --- /dev/null +++ b/distribution/examples/validation/json-schema/schema-mappings/bad2001.json @@ -0,0 +1,12 @@ +{ + "params": [ + { + "name": "mode", + "value": "fast", + "unexpected": "foo" + } + ], + "meta": { + "source": "" + } +} diff --git a/distribution/examples/validation/json-schema/schema-mappings/good2000.json b/distribution/examples/validation/json-schema/schema-mappings/good2000.json new file mode 100644 index 0000000000..c9214c7149 --- /dev/null +++ b/distribution/examples/validation/json-schema/schema-mappings/good2000.json @@ -0,0 +1,7 @@ +{ + "param": { + "name": "limit", + "value": 100 + }, + "timestamp": "2025-12-19T10:15:30Z" +} diff --git a/distribution/examples/validation/json-schema/schema-mappings/good2001.json b/distribution/examples/validation/json-schema/schema-mappings/good2001.json new file mode 100644 index 0000000000..0c64ff9d6f --- /dev/null +++ b/distribution/examples/validation/json-schema/schema-mappings/good2001.json @@ -0,0 +1,16 @@ +{ + "params": [ + { + "name": "mode", + "value": "fast" + }, + { + "name": "retries", + "value": 3 + } + ], + "meta": { + "source": "curl", + "requestId": "REQ-12345678" + } +} diff --git a/distribution/examples/validation/json-schema/schema-mappings/membrane.cmd b/distribution/examples/validation/json-schema/schema-mappings/membrane.cmd new file mode 100644 index 0000000000..8d2d64e9cf --- /dev/null +++ b/distribution/examples/validation/json-schema/schema-mappings/membrane.cmd @@ -0,0 +1,24 @@ +@echo off +setlocal EnableExtensions + +set "SCRIPT_DIR=%~dp0" +if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" + +set "dir=%SCRIPT_DIR%" + +:search_up +if exist "%dir%\LICENSE.txt" if exist "%dir%\scripts\run-membrane.cmd" goto found +for %%A in ("%dir%\..") do set "next=%%~fA" +if /I "%next%"=="%dir%" goto notfound +set "dir=%next%" +goto search_up + +:found +set "MEMBRANE_HOME=%dir%" +set "MEMBRANE_CALLER_DIR=%SCRIPT_DIR%" +call "%MEMBRANE_HOME%\scripts\run-membrane.cmd" %* +exit /b %ERRORLEVEL% + +:notfound +>&2 echo Could not locate Membrane root. Ensure directory structure is correct. +exit /b 1 diff --git a/distribution/examples/validation/json-schema/schema-mappings/membrane.sh b/distribution/examples/validation/json-schema/schema-mappings/membrane.sh new file mode 100755 index 0000000000..195dae51ec --- /dev/null +++ b/distribution/examples/validation/json-schema/schema-mappings/membrane.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Default: ./proxies.xml (next to this script); fallback -> $MEMBRANE_HOME/conf/proxies.xml +# JAVA_OPTS: relative -D paths are auto-resolved against $MEMBRANE_HOME (absolute/URI unchanged). +# Examples: +# export JAVA_OPTS='-Dlog4j.configurationFile=examples/logging/access/log4j2_access.xml' +# export JAVA_OPTS='-Dlog4j.configurationFile=/abs/path/log4j2.xml' + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) + +dir="$SCRIPT_DIR" +while [ "$dir" != "/" ]; do + if [ -f "$dir/LICENSE.txt" ] && [ -f "$dir/scripts/run-membrane.sh" ]; then + export MEMBRANE_HOME="$dir" + export MEMBRANE_CALLER_DIR="$SCRIPT_DIR" + exec sh "$dir/scripts/run-membrane.sh" "$@" + fi + dir=$(dirname "$dir") +done + +echo "Could not locate Membrane root. Ensure directory structure is correct." >&2 +exit 1 \ No newline at end of file diff --git a/distribution/examples/validation/json-schema/schema-mappings/proxies.xml b/distribution/examples/validation/json-schema/schema-mappings/proxies.xml new file mode 100644 index 0000000000..1699fb8580 --- /dev/null +++ b/distribution/examples/validation/json-schema/schema-mappings/proxies.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Response.ok("<response>good request</response>").build() + + + + + + diff --git a/distribution/examples/validation/json-schema/schema-mappings/schemas/base-param.json b/distribution/examples/validation/json-schema/schema-mappings/schemas/base-param.json new file mode 100644 index 0000000000..0da01fe2c3 --- /dev/null +++ b/distribution/examples/validation/json-schema/schema-mappings/schemas/base-param.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:app:base_parameter_def", + "$defs": { + "BaseParameter": { + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": {} + }, + "additionalProperties": false + } + } +} diff --git a/distribution/examples/validation/json-schema/schema-mappings/schemas/meta.json b/distribution/examples/validation/json-schema/schema-mappings/schemas/meta.json new file mode 100644 index 0000000000..3379d26ec6 --- /dev/null +++ b/distribution/examples/validation/json-schema/schema-mappings/schemas/meta.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:app:meta_def", + "$defs": { + "Meta": { + "type": "object", + "required": [ + "source", + "requestId" + ], + "properties": { + "source": { + "type": "string", + "minLength": 1 + }, + "requestId": { + "type": "string", + "minLength": 8 + } + }, + "additionalProperties": false + } + } +} diff --git a/distribution/examples/validation/json-schema/schema-mappings/schemas/schema2000.json b/distribution/examples/validation/json-schema/schema-mappings/schemas/schema2000.json new file mode 100644 index 0000000000..0293b82841 --- /dev/null +++ b/distribution/examples/validation/json-schema/schema-mappings/schemas/schema2000.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:app:schema2000", + "type": "object", + "required": [ + "param", + "timestamp" + ], + "properties": { + "param": { + "$ref": "urn:app:base_parameter_def#/$defs/BaseParameter" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false +} diff --git a/distribution/examples/validation/json-schema/schema-mappings/schemas/schema2001.json b/distribution/examples/validation/json-schema/schema-mappings/schemas/schema2001.json new file mode 100644 index 0000000000..ef886181f6 --- /dev/null +++ b/distribution/examples/validation/json-schema/schema-mappings/schemas/schema2001.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:app:schema2001", + "type": "object", + "required": [ + "params", + "meta" + ], + "properties": { + "params": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "urn:app:base_parameter_def#/$defs/BaseParameter" + } + }, + "meta": { + "$ref": "urn:app:meta_def#/$defs/Meta" + } + }, + "additionalProperties": false +} From 5ad0344416b0fd1d2e88950f24aa9e68252ff224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 19 Dec 2025 09:22:25 +0100 Subject: [PATCH 11/16] Add tests for JSON schema validation with reference mappings --- ...SONSchemaReferenceMappingsExampleTest.java | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaReferenceMappingsExampleTest.java diff --git a/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaReferenceMappingsExampleTest.java b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaReferenceMappingsExampleTest.java new file mode 100644 index 0000000000..c2df8c7275 --- /dev/null +++ b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaReferenceMappingsExampleTest.java @@ -0,0 +1,127 @@ +/* Copyright 2012 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.examples.withoutinternet.validation; + +import com.predic8.membrane.examples.util.*; +import org.junit.jupiter.api.*; + +import static com.predic8.membrane.core.http.MimeType.*; +import static io.restassured.RestAssured.*; +import static io.restassured.http.ContentType.*; +import static java.io.File.*; +import static org.hamcrest.Matchers.*; + +public class JSONSchemaReferenceMappingsExampleTest extends DistributionExtractingTestcase { + + @Override + protected String getExampleDirName() { + return "validation" + separator + "json-schema" + separator + "schema-mappings"; + } + + @Test + void port2000() throws Exception { + try (Process2 ignored = startServiceProxyScript()) { + + // @formatter:off + // Test good JSON + given() + .contentType(JSON) + .body(readFileFromBaseDir("good2000.json")) + .when() + .post("http://localhost:2000") + .then() + .statusCode(200); + + // Test bad JSON + given() + .contentType(JSON) + .body(readFileFromBaseDir("bad2000.json")) + .when() + .post("http://localhost:2000") + .then() + .statusCode(400) + .contentType(APPLICATION_PROBLEM_JSON) + .body("title", equalTo("JSON validation failed")) + .body("type", equalTo("https://membrane-api.io/problems/user/validation")) + .body("status", equalTo(400)) + .body("flow", equalTo("REQUEST")) + + // error 1: required value in param + .body("errors.find { it.pointer == '/properties/param/$ref/required' }.key", equalTo("required")) + .body("errors.find { it.pointer == '/properties/param/$ref/required' }.message", containsString("/param: required property 'value' not found")) + .body("errors.find { it.pointer == '/properties/param/$ref/required' }.code", equalTo("1028")) + + // error 2: extra additional property + .body("errors.find { it.pointer == '/additionalProperties' }.key", equalTo("additionalProperties")) + .body("errors.find { it.pointer == '/additionalProperties' }.message", containsString("property 'extra' is not defined in the schema")) + .body("errors.find { it.pointer == '/additionalProperties' }.code", equalTo("1001")) + + // meta fields + .body("see", equalTo("https://membrane-api.io/problems/user/validation/json-schema-validator")) + .body("attention", containsString("development mode")); + // @formatter:on + } + } + + @Test + void port2001() throws Exception { + try (Process2 ignored = startServiceProxyScript()) { + + // @formatter:off + // Test good JSON + given() + .contentType(JSON) + .body(readFileFromBaseDir("good2001.json")) + .when() + .post("http://localhost:2001") + .then() + .statusCode(200); + + // Test bad JSON + given() + .contentType(JSON) + .body(readFileFromBaseDir("bad2001.json")) + .when() + .post("http://localhost:2001") + .then() + .statusCode(400) + .contentType(APPLICATION_PROBLEM_JSON) + .body("title", equalTo("JSON validation failed")) + .body("type", equalTo("https://membrane-api.io/problems/user/validation")) + .body("status", equalTo(400)) + .body("flow", equalTo("REQUEST")) + + // error 1: unexpected additional property in params[0] + .body("errors.find { it.pointer == '/properties/params/items/$ref/additionalProperties' }.key", equalTo("additionalProperties")) + .body("errors.find { it.pointer == '/properties/params/items/$ref/additionalProperties' }.message", containsString("/params/0: property 'unexpected' is not defined in the schema")) + .body("errors.find { it.pointer == '/properties/params/items/$ref/additionalProperties' }.code", equalTo("1001")) + + // error 2: meta.source minLength + .body("errors.find { it.pointer == '/properties/meta/$ref/properties/source/minLength' }.key", equalTo("minLength")) + .body("errors.find { it.pointer == '/properties/meta/$ref/properties/source/minLength' }.message", containsString("/meta/source: must be at least 1 characters long")) + .body("errors.find { it.pointer == '/properties/meta/$ref/properties/source/minLength' }.code", equalTo("1017")) + + // error 3: meta.requestId required + .body("errors.find { it.pointer == '/properties/meta/$ref/required' }.key", equalTo("required")) + .body("errors.find { it.pointer == '/properties/meta/$ref/required' }.message", containsString("/meta: required property 'requestId' not found")) + .body("errors.find { it.pointer == '/properties/meta/$ref/required' }.code", equalTo("1028")) + + // meta fields + .body("see", equalTo("https://membrane-api.io/problems/user/validation/json-schema-validator")) + .body("attention", containsString("development mode")); + // @formatter:on + } + } +} From 51ba49406fbac727360c8f7fd07031fb42fd4892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 19 Dec 2025 14:29:01 +0100 Subject: [PATCH 12/16] improvements --- .../schemavalidation/ValidatorInterceptor.java | 18 +++++++++--------- ...ferenceSchemas.java => SchemaMappings.java} | 6 ++---- .../json-schema/schema-mappings/README.md | 6 +++--- .../json-schema/schema-mappings/proxies.xml | 8 ++++---- ...java => JSONSchemaMappingsExampleTest.java} | 2 +- 5 files changed, 19 insertions(+), 21 deletions(-) rename core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/{ReferenceSchemas.java => SchemaMappings.java} (93%) rename distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/{JSONSchemaReferenceMappingsExampleTest.java => JSONSchemaMappingsExampleTest.java} (98%) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java index 9607abf355..0b705f4158 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java @@ -66,7 +66,7 @@ public class ValidatorInterceptor extends AbstractInterceptor implements Applica private ResolverMap resourceResolver; private ApplicationContext applicationContext; - private ReferenceSchemas referenceSchemas; + private SchemaMappings schemaMappings; public ValidatorInterceptor() { name = "validator"; @@ -94,22 +94,22 @@ public void init() { private MessageValidator getMessageValidator() throws Exception { if (wsdl != null) { - if (referenceSchemas != null) + if (schemaMappings != null) logIgnoringRefSchemas(); return new WSDLValidator(resourceResolver, combine(getBaseLocation(), wsdl), serviceName, createFailureHandler(), skipFaults); } if (schema != null) { - if (referenceSchemas != null) + if (schemaMappings != null) logIgnoringRefSchemas(); return new XMLSchemaValidator(resourceResolver, combine(getBaseLocation(), schema), createFailureHandler()); } if (jsonSchema != null) { return new JSONYAMLSchemaValidator(resourceResolver, combine(getBaseLocation(), jsonSchema), createFailureHandler(), schemaVersion) {{ - setSchemaMappings(referenceSchemas.getSchemaMap()); + setSchemaMappings(schemaMappings.getSchemaMap()); }}; } if (schematron != null) { - if (referenceSchemas != null) + if (schemaMappings != null) logIgnoringRefSchemas(); return new SchematronValidator(combine(getBaseLocation(), schematron), createFailureHandler(), router, applicationContext); } @@ -334,12 +334,12 @@ private FailureHandler createFailureHandler() { } @MCChildElement - public void setReferenceSchemas(ReferenceSchemas referenceSchemas) { - this.referenceSchemas = referenceSchemas; + public void setReferenceSchemas(SchemaMappings schemaMappings) { + this.schemaMappings = schemaMappings; } - public ReferenceSchemas getReferenceSchemas() { - return referenceSchemas; + public SchemaMappings getReferenceSchemas() { + return schemaMappings; } diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/ReferenceSchemas.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/SchemaMappings.java similarity index 93% rename from core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/ReferenceSchemas.java rename to core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/SchemaMappings.java index 6bfd9a1da7..7f86583daf 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/ReferenceSchemas.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/SchemaMappings.java @@ -10,8 +10,8 @@ import java.util.List; import java.util.Map; -@MCElement(name = "referenceSchemas") -public class ReferenceSchemas { +@MCElement(name = "schemaMappings") +public class SchemaMappings { private List schemas = new ArrayList<>(); @@ -37,8 +37,6 @@ public static class Schema { private String location; - public Schema() {} - @MCAttribute public void setId(String id) { this.id = id; diff --git a/distribution/examples/validation/json-schema/schema-mappings/README.md b/distribution/examples/validation/json-schema/schema-mappings/README.md index 47af48b647..9b3e893a2c 100644 --- a/distribution/examples/validation/json-schema/schema-mappings/README.md +++ b/distribution/examples/validation/json-schema/schema-mappings/README.md @@ -1,7 +1,7 @@ # Validation - JSON Schema with Schema Mappings This sample explains how to set up and use the `validator` plugin with JSON Schemas that reference external schemas -by `$ref` URN/ID, and how to map those references to local files via `referenceSchemas`. +by `$ref` URN/ID, and how to map those references to local files via `schemaMappings`. ## Running the Example @@ -35,10 +35,10 @@ The root schemas contain `$ref` references to URN/IDs, for example: To let Membrane resolve these URNs, you map them in the validator using: ```xml - + - + ```` Only if validation succeeds, the request is forwarded to the backend (port 2002). diff --git a/distribution/examples/validation/json-schema/schema-mappings/proxies.xml b/distribution/examples/validation/json-schema/schema-mappings/proxies.xml index 1699fb8580..b0190904d3 100644 --- a/distribution/examples/validation/json-schema/schema-mappings/proxies.xml +++ b/distribution/examples/validation/json-schema/schema-mappings/proxies.xml @@ -9,9 +9,9 @@ - + - + @@ -20,10 +20,10 @@ - + - + diff --git a/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaReferenceMappingsExampleTest.java b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaMappingsExampleTest.java similarity index 98% rename from distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaReferenceMappingsExampleTest.java rename to distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaMappingsExampleTest.java index c2df8c7275..6ce9861985 100644 --- a/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaReferenceMappingsExampleTest.java +++ b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaMappingsExampleTest.java @@ -23,7 +23,7 @@ import static java.io.File.*; import static org.hamcrest.Matchers.*; -public class JSONSchemaReferenceMappingsExampleTest extends DistributionExtractingTestcase { +public class JSONSchemaMappingsExampleTest extends DistributionExtractingTestcase { @Override protected String getExampleDirName() { From e8aa34740906b804d753782d9e5d80801a811b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 19 Dec 2025 15:31:35 +0100 Subject: [PATCH 13/16] Refactor `JSONYAMLSchemaValidator` to use updated schema validation APIs and improve error assertion handling in tests --- .../json/JSONYAMLSchemaValidator.java | 60 +++++++++---------- .../JSONSchemaMappingsExampleTest.java | 39 ++++++------ 2 files changed, 51 insertions(+), 48 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java index 3a0274eddb..5f7e202dc8 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java @@ -17,32 +17,31 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLParser; -import com.networknt.schema.InputFormat; -import com.networknt.schema.JsonNodePath; -import com.networknt.schema.JsonSchemaFactory; -import com.networknt.schema.ValidationMessage; -import com.predic8.membrane.core.exchange.Exchange; -import com.predic8.membrane.core.interceptor.Interceptor.Flow; -import com.predic8.membrane.core.interceptor.Outcome; -import com.predic8.membrane.core.interceptor.schemavalidation.AbstractMessageValidator; -import com.predic8.membrane.core.interceptor.schemavalidation.ValidatorInterceptor.FailureHandler; -import com.predic8.membrane.core.resolver.Resolver; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import com.networknt.schema.*; +import com.networknt.schema.Error; +import com.networknt.schema.path.NodePath; +import com.networknt.schema.resource.SchemaLoader; +import com.predic8.membrane.core.exchange.*; +import com.predic8.membrane.core.interceptor.Interceptor.*; +import com.predic8.membrane.core.interceptor.*; +import com.predic8.membrane.core.interceptor.schemavalidation.*; +import com.predic8.membrane.core.interceptor.schemavalidation.ValidatorInterceptor.*; +import com.predic8.membrane.core.resolver.*; +import org.jetbrains.annotations.*; +import org.slf4j.*; import java.io.IOException; -import java.nio.charset.Charset; +import java.io.InputStream; +import java.nio.charset.*; import java.util.*; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.*; import static com.fasterxml.jackson.core.StreamReadFeature.STRICT_DUPLICATE_DETECTION; import static com.networknt.schema.InputFormat.JSON; import static com.networknt.schema.InputFormat.YAML; -import static com.predic8.membrane.core.exceptions.ProblemDetails.user; -import static com.predic8.membrane.core.interceptor.Outcome.ABORT; -import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE; -import static java.nio.charset.StandardCharsets.UTF_8; +import static com.predic8.membrane.core.exceptions.ProblemDetails.*; +import static com.predic8.membrane.core.interceptor.Outcome.*; +import static java.nio.charset.StandardCharsets.*; public class JSONYAMLSchemaValidator extends AbstractMessageValidator { @@ -62,10 +61,8 @@ public class JSONYAMLSchemaValidator extends AbstractMessageValidator { private final AtomicLong invalid = new AtomicLong(); private final SpecificationVersion schemaId; - private Map schemaMappings = new HashMap<>(); - /** * JsonSchemaFactory instances are thread-safe provided its configuration is not modified. */ @@ -82,7 +79,7 @@ public JSONYAMLSchemaValidator(Resolver resolver, String jsonSchema, FailureHand this.resolver = resolver; this.jsonSchema = jsonSchema; this.failureHandler = failureHandler; - this.schemaId = JSONSchemaVersionParser.parse( schemaVersion); + this.schemaId = JSONSchemaVersionParser.parse(schemaVersion); this.inputFormat = inputFormat; } @@ -102,16 +99,17 @@ public String getName() { @Override public void init() { super.init(); - jsonSchemaFactory = JsonSchemaFactory.getInstance(schemaId, builder -> - builder - .schemaLoaders(loaders -> loaders.add(new MembraneSchemaLoader(resolver))) - .schemaMappers(mappers -> mappers.mappings(schemaMappings)) - // builder.schemaMappers(schemaMappers -> schemaMappers.mapPrefix("https://www.example.org/", "classpath:/")) - ); + jsonSchemaFactory = SchemaRegistry.withDefaultDialect(schemaId, b -> b.schemaLoader(SchemaLoader.builder() + .schemaIdResolvers(r -> r.mappings(schemaMappings)) + .resourceLoaders(rl -> rl.values(list -> list.addFirst(new MembraneSchemaLoader(resolver)))) + .build())); try (InputStream in = resolver.resolve(jsonSchema)) { - schema = jsonSchemaFactory.getSchema((jsonSchema.endsWith(".yaml") || jsonSchema.endsWith(".yml") ? yamlObjectMapper: jsonObjectMapper).readTree(in)); + InputFormat schemaFormat = + (jsonSchema.endsWith(".yaml") || jsonSchema.endsWith(".yml")) ? YAML : JSON; + + schema = jsonSchemaFactory.getSchema(SchemaLocation.of(jsonSchema), in, schemaFormat); schema.initializeValidators(); } catch (IOException e) { throw new RuntimeException("Cannot read JSON Schema from: " + jsonSchema, e); @@ -125,8 +123,8 @@ public Outcome validateMessage(Exchange exc, Flow flow) throws Exception { public Outcome validateMessage(Exchange exc, Flow flow, Charset ignored) throws Exception { List assertions = inputFormat == YAML ? - handleMultipleYAMLDocuments(exc, flow) : - schema.validate(exc.getMessage(flow).getBodyAsStringDecoded(), inputFormat); + handleMultipleYAMLDocuments(exc, flow) : + schema.validate(exc.getMessage(flow).getBodyAsStringDecoded(), inputFormat); if (assertions.isEmpty()) { valid.incrementAndGet(); diff --git a/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaMappingsExampleTest.java b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaMappingsExampleTest.java index 6ce9861985..3804aef014 100644 --- a/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaMappingsExampleTest.java +++ b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaMappingsExampleTest.java @@ -41,7 +41,7 @@ void port2000() throws Exception { .body(readFileFromBaseDir("good2000.json")) .when() .post("http://localhost:2000") - .then() + .then() .statusCode(200); // Test bad JSON @@ -60,13 +60,15 @@ void port2000() throws Exception { // error 1: required value in param .body("errors.find { it.pointer == '/properties/param/$ref/required' }.key", equalTo("required")) - .body("errors.find { it.pointer == '/properties/param/$ref/required' }.message", containsString("/param: required property 'value' not found")) - .body("errors.find { it.pointer == '/properties/param/$ref/required' }.code", equalTo("1028")) + .body("errors.find { it.pointer == '/properties/param/$ref/required' }.keyword", equalTo("required")) + .body("errors.find { it.pointer == '/properties/param/$ref/required' }.message", equalTo("required property 'value' not found")) + .body("errors.find { it.pointer == '/properties/param/$ref/required' }.details.property", equalTo("value")) // error 2: extra additional property .body("errors.find { it.pointer == '/additionalProperties' }.key", equalTo("additionalProperties")) - .body("errors.find { it.pointer == '/additionalProperties' }.message", containsString("property 'extra' is not defined in the schema")) - .body("errors.find { it.pointer == '/additionalProperties' }.code", equalTo("1001")) + .body("errors.find { it.pointer == '/additionalProperties' }.keyword", equalTo("additionalProperties")) + .body("errors.find { it.pointer == '/additionalProperties' }.message", equalTo("property 'extra' is not defined in the schema and the schema does not allow additional properties")) + .body("errors.find { it.pointer == '/additionalProperties' }.details.property", equalTo("extra")) // meta fields .body("see", equalTo("https://membrane-api.io/problems/user/validation/json-schema-validator")) @@ -82,12 +84,12 @@ void port2001() throws Exception { // @formatter:off // Test good JSON given() - .contentType(JSON) - .body(readFileFromBaseDir("good2001.json")) - .when() - .post("http://localhost:2001") - .then() - .statusCode(200); + .contentType(JSON) + .body(readFileFromBaseDir("good2001.json")) + .when() + .post("http://localhost:2001") + .then() + .statusCode(200); // Test bad JSON given() @@ -105,18 +107,20 @@ void port2001() throws Exception { // error 1: unexpected additional property in params[0] .body("errors.find { it.pointer == '/properties/params/items/$ref/additionalProperties' }.key", equalTo("additionalProperties")) - .body("errors.find { it.pointer == '/properties/params/items/$ref/additionalProperties' }.message", containsString("/params/0: property 'unexpected' is not defined in the schema")) - .body("errors.find { it.pointer == '/properties/params/items/$ref/additionalProperties' }.code", equalTo("1001")) + .body("errors.find { it.pointer == '/properties/params/items/$ref/additionalProperties' }.keyword", equalTo("additionalProperties")) + .body("errors.find { it.pointer == '/properties/params/items/$ref/additionalProperties' }.message", equalTo("property 'unexpected' is not defined in the schema and the schema does not allow additional properties")) + .body("errors.find { it.pointer == '/properties/params/items/$ref/additionalProperties' }.details.property", equalTo("unexpected")) // error 2: meta.source minLength .body("errors.find { it.pointer == '/properties/meta/$ref/properties/source/minLength' }.key", equalTo("minLength")) - .body("errors.find { it.pointer == '/properties/meta/$ref/properties/source/minLength' }.message", containsString("/meta/source: must be at least 1 characters long")) - .body("errors.find { it.pointer == '/properties/meta/$ref/properties/source/minLength' }.code", equalTo("1017")) + .body("errors.find { it.pointer == '/properties/meta/$ref/properties/source/minLength' }.keyword", equalTo("minLength")) + .body("errors.find { it.pointer == '/properties/meta/$ref/properties/source/minLength' }.message", equalTo("must be at least 1 characters long")) // error 3: meta.requestId required .body("errors.find { it.pointer == '/properties/meta/$ref/required' }.key", equalTo("required")) - .body("errors.find { it.pointer == '/properties/meta/$ref/required' }.message", containsString("/meta: required property 'requestId' not found")) - .body("errors.find { it.pointer == '/properties/meta/$ref/required' }.code", equalTo("1028")) + .body("errors.find { it.pointer == '/properties/meta/$ref/required' }.keyword", equalTo("required")) + .body("errors.find { it.pointer == '/properties/meta/$ref/required' }.message", equalTo("required property 'requestId' not found")) + .body("errors.find { it.pointer == '/properties/meta/$ref/required' }.details.property", equalTo("requestId")) // meta fields .body("see", equalTo("https://membrane-api.io/problems/user/validation/json-schema-validator")) @@ -124,4 +128,5 @@ void port2001() throws Exception { // @formatter:on } } + } From 3974f47547992a48b8ff973c6cdab44824d26e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6rdes?= Date: Fri, 19 Dec 2025 15:43:52 +0100 Subject: [PATCH 14/16] Add license headers and null check for schemaMappings in ValidatorInterceptor --- .../predic8/membrane/annot/generator/Scope.java | 14 ++++++++++++++ .../membrane/annot/util/ReflectionUtil.java | 14 ++++++++++++++ .../membrane/annot/util/ReflectionUtilTest.java | 14 ++++++++++++++ .../schemavalidation/ValidatorInterceptor.java | 2 +- .../schemavalidation/json/SchemaMappings.java | 14 ++++++++++++++ 5 files changed, 57 insertions(+), 1 deletion(-) diff --git a/annot/src/main/java/com/predic8/membrane/annot/generator/Scope.java b/annot/src/main/java/com/predic8/membrane/annot/generator/Scope.java index d66ec77a73..9bad700504 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/generator/Scope.java +++ b/annot/src/main/java/com/predic8/membrane/annot/generator/Scope.java @@ -1,3 +1,17 @@ +/* Copyright 2025 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.annot.generator; public enum Scope { diff --git a/annot/src/main/java/com/predic8/membrane/annot/util/ReflectionUtil.java b/annot/src/main/java/com/predic8/membrane/annot/util/ReflectionUtil.java index c55848d6aa..c350c248f3 100644 --- a/annot/src/main/java/com/predic8/membrane/annot/util/ReflectionUtil.java +++ b/annot/src/main/java/com/predic8/membrane/annot/util/ReflectionUtil.java @@ -1,3 +1,17 @@ +/* Copyright 2025 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.annot.util; public class ReflectionUtil { diff --git a/annot/src/test/java/com/predic8/membrane/annot/util/ReflectionUtilTest.java b/annot/src/test/java/com/predic8/membrane/annot/util/ReflectionUtilTest.java index a62f428860..c72ccaa4e1 100644 --- a/annot/src/test/java/com/predic8/membrane/annot/util/ReflectionUtilTest.java +++ b/annot/src/test/java/com/predic8/membrane/annot/util/ReflectionUtilTest.java @@ -1,3 +1,17 @@ +/* Copyright 2025 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.annot.util; import org.junit.jupiter.api.*; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java index 7ff285e5d1..198210a690 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/ValidatorInterceptor.java @@ -107,7 +107,7 @@ private MessageValidator getMessageValidator() throws Exception { } if (jsonSchema != null) { return new JSONYAMLSchemaValidator(resourceResolver, combine(getBaseLocation(), jsonSchema), createFailureHandler(), schemaVersion) {{ - setSchemaMappings(schemaMappings.getSchemaMap()); + if(schemaMappings != null) setSchemaMappings(schemaMappings.getSchemaMap()); }}; } if (schematron != null) { diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/SchemaMappings.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/SchemaMappings.java index 7f86583daf..368c2625f6 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/SchemaMappings.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/SchemaMappings.java @@ -1,3 +1,17 @@ +/* Copyright 2025 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.interceptor.schemavalidation.json; import com.predic8.membrane.annot.MCAttribute; From 42f1a90f7c9fa9ba4d837d2e3aff3df5800390af Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Sat, 20 Dec 2025 15:59:05 +0100 Subject: [PATCH 15/16] refactor: simplify JSON Schema validation setup and update factorypath to use version 7.0.5-SNAPSHOT --- .../json/JSONYAMLSchemaValidator.java | 27 +++++++++---------- war/.factorypath | 2 +- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java index 5f7e202dc8..f533a18ca4 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java @@ -49,7 +49,6 @@ public class JSONYAMLSchemaValidator extends AbstractMessageValidator { private final YAMLFactory factory = YAMLFactory.builder().enable(STRICT_DUPLICATE_DETECTION).build(); private final ObjectMapper yamlObjectMapper = new ObjectMapper(factory); - private final ObjectMapper jsonObjectMapper = new ObjectMapper(); public static final String SCHEMA_VERSION_2020_12 = "2020-12"; @@ -63,11 +62,6 @@ public class JSONYAMLSchemaValidator extends AbstractMessageValidator { private Map schemaMappings = new HashMap<>(); - /** - * JsonSchemaFactory instances are thread-safe provided its configuration is not modified. - */ - SchemaRegistry jsonSchemaFactory; - /** * JsonSchema instances are thread-safe provided its configuration is not modified. */ @@ -100,22 +94,25 @@ public String getName() { public void init() { super.init(); - jsonSchemaFactory = SchemaRegistry.withDefaultDialect(schemaId, b -> b.schemaLoader(SchemaLoader.builder() - .schemaIdResolvers(r -> r.mappings(schemaMappings)) - .resourceLoaders(rl -> rl.values(list -> list.addFirst(new MembraneSchemaLoader(resolver)))) - .build())); - try (InputStream in = resolver.resolve(jsonSchema)) { - InputFormat schemaFormat = - (jsonSchema.endsWith(".yaml") || jsonSchema.endsWith(".yml")) ? YAML : JSON; - - schema = jsonSchemaFactory.getSchema(SchemaLocation.of(jsonSchema), in, schemaFormat); + schema = getJsonSchemaFactory().getSchema(SchemaLocation.of(jsonSchema), in, getSchemaFormat()); schema.initializeValidators(); } catch (IOException e) { throw new RuntimeException("Cannot read JSON Schema from: " + jsonSchema, e); } } + private @NotNull InputFormat getSchemaFormat() { + return (jsonSchema.endsWith(".yaml") || jsonSchema.endsWith(".yml")) ? YAML : JSON; + } + + private SchemaRegistry getJsonSchemaFactory() { + return SchemaRegistry.withDefaultDialect(schemaId, b -> b.schemaLoader(SchemaLoader.builder() + .schemaIdResolvers(r -> r.mappings(schemaMappings)) + .resourceLoaders(rl -> rl.values(list -> list.addFirst(new MembraneSchemaLoader(resolver)))) + .build())); + } + public Outcome validateMessage(Exchange exc, Flow flow) throws Exception { return validateMessage(exc, flow, UTF_8); } diff --git a/war/.factorypath b/war/.factorypath index 472e583632..72068d289c 100644 --- a/war/.factorypath +++ b/war/.factorypath @@ -1,3 +1,3 @@ - + \ No newline at end of file From 8c956325aae2149cbc7e22df01bb24373d751c2a Mon Sep 17 00:00:00 2001 From: Thomas Bayer Date: Sat, 20 Dec 2025 16:17:35 +0100 Subject: [PATCH 16/16] refactor: replace JsonSchema factory logic with schema registry and enhance format detection for YAML edge cases --- .../schemavalidation/json/JSONYAMLSchemaValidator.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java index f533a18ca4..ca88c683d9 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/JSONYAMLSchemaValidator.java @@ -95,7 +95,7 @@ public void init() { super.init(); try (InputStream in = resolver.resolve(jsonSchema)) { - schema = getJsonSchemaFactory().getSchema(SchemaLocation.of(jsonSchema), in, getSchemaFormat()); + schema = createSchemaRegistry().getSchema(SchemaLocation.of(jsonSchema), in, getSchemaFormat()); schema.initializeValidators(); } catch (IOException e) { throw new RuntimeException("Cannot read JSON Schema from: " + jsonSchema, e); @@ -103,10 +103,10 @@ public void init() { } private @NotNull InputFormat getSchemaFormat() { - return (jsonSchema.endsWith(".yaml") || jsonSchema.endsWith(".yml")) ? YAML : JSON; + return (jsonSchema.toLowerCase().endsWith(".yaml") || jsonSchema.toLowerCase().endsWith(".yml")) ? YAML : JSON; } - private SchemaRegistry getJsonSchemaFactory() { + private SchemaRegistry createSchemaRegistry() { return SchemaRegistry.withDefaultDialect(schemaId, b -> b.schemaLoader(SchemaLoader.builder() .schemaIdResolvers(r -> r.mappings(schemaMappings)) .resourceLoaders(rl -> rl.values(list -> list.addFirst(new MembraneSchemaLoader(resolver))))