diff --git a/.gitignore b/.gitignore index 4abee48854..272d92a4c7 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,5 @@ maven-plugin/target/surefire/ /docs/router-conf.xsd .vscode/ /core/derby.log +/distribution/conf/apis.yaml +/distribution/conf/membrane.log diff --git a/annot/pom.xml b/annot/pom.xml index 935cb6d405..cda8a346e7 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.17-SNAPSHOT diff --git a/core/.factorypath b/core/.factorypath index 4d5b485791..52a1c352c8 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..0e8a6d35c3 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.17-SNAPSHOT diff --git a/core/src/main/java/com/predic8/membrane/core/exchange/AbstractExchange.java b/core/src/main/java/com/predic8/membrane/core/exchange/AbstractExchange.java index e3e623f721..cc4a078ee9 100644 --- a/core/src/main/java/com/predic8/membrane/core/exchange/AbstractExchange.java +++ b/core/src/main/java/com/predic8/membrane/core/exchange/AbstractExchange.java @@ -18,6 +18,7 @@ import com.predic8.membrane.core.http.*; import com.predic8.membrane.core.interceptor.*; import com.predic8.membrane.core.interceptor.Interceptor.*; +import com.predic8.membrane.core.interceptor.balancer.ExchangeNodeStatusTracker; import com.predic8.membrane.core.model.*; import com.predic8.membrane.core.proxies.Proxy; import com.predic8.membrane.core.proxies.*; @@ -28,7 +29,9 @@ import java.text.*; import java.util.*; +import static com.predic8.membrane.core.exchange.Exchange.TRACK_NODE_STATUS; import static com.predic8.membrane.core.exchange.ExchangeState.*; +import static java.lang.Boolean.TRUE; public abstract class AbstractExchange { private static final Logger log = LoggerFactory.getLogger(AbstractExchange.class.getName()); @@ -68,6 +71,8 @@ public abstract class AbstractExchange { private ArrayList interceptorStack = new ArrayList<>(10); + private ExchangeNodeStatusTracker nodeStatusTracker; + private int estimatedHeapSize = -1; public AbstractExchange() {} @@ -408,6 +413,7 @@ public int resetHeapSizeEstimation() { protected int estimateHeapSize() { return 2600 + + (nodeStatusTracker != null ? nodeStatusTracker.estimateHeapSize() : 0) + (originalRequestUri != null ? originalRequestUri.length() : 0) + (request != null ? request.estimateHeapSize() : 0) + (response != null ? response.estimateHeapSize() : 0); @@ -436,6 +442,7 @@ public static T updateCopy(T source, T copy, Runnab copy.setRemoteAddr(source.getRemoteAddr()); copy.setRemoteAddrIp(source.getRemoteAddrIp()); copy.setInterceptorStack(new ArrayList<>(source.getInterceptorStack())); + copy.setNodeStatusTracker(source.getNodeStatusTracker() == null ? null : source.getNodeStatusTracker().clone()); return copy; } @@ -480,4 +487,16 @@ public Message getMessage(Flow flow) { return request; return response; // RESPONSE and ABORT flow both work on the response } + + public ExchangeNodeStatusTracker getNodeStatusTracker() { + return nodeStatusTracker; + } + + public void setNodeStatusTracker(ExchangeNodeStatusTracker nodeStatusTracker) { + this.nodeStatusTracker = nodeStatusTracker; + } + + public void initNodeStatusTracker() { + this.nodeStatusTracker = new ExchangeNodeStatusTracker(destinations.size()); + } } diff --git a/core/src/main/java/com/predic8/membrane/core/exchange/Exchange.java b/core/src/main/java/com/predic8/membrane/core/exchange/Exchange.java index c6f146d342..1757b1bf0a 100644 --- a/core/src/main/java/com/predic8/membrane/core/exchange/Exchange.java +++ b/core/src/main/java/com/predic8/membrane/core/exchange/Exchange.java @@ -54,9 +54,6 @@ public class Exchange extends AbstractExchange { private Connection targetConnection; - private int[] nodeStatusCodes; - - private Exception[] nodeExceptions; private long id; @@ -158,22 +155,6 @@ public Map getStringProperties() { .collect(toMap(Map.Entry::getKey, e -> (String) e.getValue())); } - public void setNodeStatusCode(int tryCounter, int code) { - if (nodeStatusCodes == null) { - nodeStatusCodes = new int[getDestinations().size()]; - } - nodeStatusCodes[tryCounter % getDestinations().size()] = code; - } - - public void trackNodeException(int tryCounter, Exception e) { - if (!TRUE.equals(properties.get(TRACK_NODE_STATUS))) - return; - if (nodeExceptions == null) { - nodeExceptions = new Exception[getDestinations().size()]; - } - nodeExceptions[tryCounter % getDestinations().size()] = e; - } - @Override public void detach() { super.detach(); @@ -200,14 +181,6 @@ public void setId(long id) { this.id = id; } - public int[] getNodeStatusCodes() { - return nodeStatusCodes; - } - - public Exception[] getNodeExceptions() { - return nodeExceptions; - } - public String getInboundProtocol() { Proxy rule = getProxy(); if (!(rule instanceof SSLableProxy sp)) diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java index cdea64e723..3ec323885e 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/DispatchingInterceptor.java @@ -57,7 +57,7 @@ public Outcome handleRequest(Exchange exc) { try { exc.getDestinations().add(getForwardingDestination(exc)); } catch (URISyntaxException e) { - ProblemDetails pd = user(false, "invalid-path") + ProblemDetails pd = user(getRouter().isProduction(), "invalid-path") .title("Invalid request path") .detail(getMessageForURISyntaxException(exc, e)) .internal("path", exc.getRequest().getUri()); @@ -109,12 +109,12 @@ protected String getAddressFromTargetElement(Exchange exc) throws MalformedURLEx if (p.getTargetURL() != null) { String targetURL = p.getTarget().compileUrl(exc, REQUEST); - if (targetURL.startsWith("http")) { + if (targetURL.startsWith("http") || targetURL.startsWith("internal")) { String basePath = UriUtil.getPathFromURL(router.getUriFactory(), targetURL); - if (basePath.isEmpty() || "/".equals(basePath)) { - URL base = new URL(targetURL); + if (basePath == null || basePath.isEmpty() || "/".equals(basePath)) { + URI base = new URI(targetURL); // Resolve and normalize slashes consistently with the branch below. - return new URL(base, getUri(exc)).toString(); + return base.resolve(getUri(exc)).toString(); } } return targetURL; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/administration/AdminPageBuilder.java b/core/src/main/java/com/predic8/membrane/core/interceptor/administration/AdminPageBuilder.java index 5edd1b2f01..d9a6d8a5ee 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/administration/AdminPageBuilder.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/administration/AdminPageBuilder.java @@ -903,6 +903,19 @@ protected void creatExchangeMetaTable(String id) { end(); } + protected void createExchangeNodeStatusTable(String id) { + table().attr("cellpadding", "0", "cellspacing", "0", "border", "0", + "class", "display", "id", id); + thead(); + tr(); + createThs("Node", "Result"); + end(); + end(); + tbody(); + end(); + end(); + } + protected void creatHeaderTable(String id) { table().attr("cellpadding", "0", "cellspacing", "0", "border", "0", "class", "display", "id", id); diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/administration/AdminRESTInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/administration/AdminRESTInterceptor.java index 31ddc58988..2f021884b6 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/administration/AdminRESTInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/administration/AdminRESTInterceptor.java @@ -225,7 +225,7 @@ public Response getExchange(QueryParameter params, String relativeRootPath) thro return Response.notFound().build(); } - return json(gen -> writeExchange(exc, gen)); + return json(gen -> writeExchange(exc, gen, true)); } @Mapping("/admin/rest/exchanges(/?\\?.*)?") @@ -241,7 +241,7 @@ public Response getExchanges(QueryParameter params, String relativeRootPath) thr gen.writeStartObject(); gen.writeArrayFieldStart("exchanges"); for (AbstractExchange e : res.getExchanges()) { - writeExchange(e, gen); + writeExchange(e, gen, false); } gen.writeEndArray(); gen.writeNumberField("total", res.getCount()); @@ -250,7 +250,7 @@ public Response getExchanges(QueryParameter params, String relativeRootPath) thr }); } - private void writeExchange(AbstractExchange exc, JsonGenerator gen) throws IOException { + private void writeExchange(AbstractExchange exc, JsonGenerator gen, boolean detail) throws IOException { gen.writeStartObject(); gen.writeNumberField("id", exc.getId()); if (exc.getResponse() != null) { @@ -310,9 +310,48 @@ private void writeExchange(AbstractExchange exc, JsonGenerator gen) throws IOExc gen.writeNumberField("duration", exc.getTimeResReceived() - exc.getTimeReqSent()); gen.writeStringField("msgFilePath", JDBCUtil.getFilePath(exc)); + + if (detail) + writeExchangeDetailFields(exc, gen); + gen.writeEndObject(); } + private static void writeExchangeDetailFields(AbstractExchange exc, JsonGenerator gen) throws IOException { + List destinations = exc.getDestinations(); + if (destinations != null) { + gen.writeArrayFieldStart("destinations"); + for (String destination : destinations) { + if (destination == null) + gen.writeNull(); + else + gen.writeString(destination); + } + gen.writeEndArray(); + } + if (exc.getNodeStatusTracker() != null) { + int[] nodeStatusCodes = exc.getNodeStatusTracker().getNodeStatusCodes(); + if (nodeStatusCodes != null) { + gen.writeArrayFieldStart("nodeStatusCodes"); + for (int statusCode : nodeStatusCodes) { + gen.writeNumber(statusCode); + } + gen.writeEndArray(); + } + Exception[] nodeExceptions = exc.getNodeStatusTracker().getNodeExceptions(); + if (nodeExceptions != null) { + gen.writeArrayFieldStart("nodeExceptions"); + for (Exception ex : nodeExceptions) { + if (ex == null || ex.getMessage() == null) + gen.writeNull(); + else + gen.writeString(ex.getMessage()); + } + gen.writeEndArray(); + } + } + } + public static String getClientAddr(boolean useXForwardedForAsClientAddr, AbstractExchange exc) { return AdminApiInterceptor.getClientAddr(useXForwardedForAsClientAddr, exc); } diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/administration/DynamicAdminPageInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/administration/DynamicAdminPageInterceptor.java index 1781558013..03afa2afcb 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/administration/DynamicAdminPageInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/administration/DynamicAdminPageInterceptor.java @@ -771,6 +771,10 @@ protected void createTabContent() { end(); div().id("tab1"); creatExchangeMetaTable("request-meta"); + div().id("node-status").style("display:none"); + h3().text("Node Status").end(); + createExchangeNodeStatusTable("request-node-status"); + end(); h3().text("Content").end(); div().align("right").a().id("request-download-button").text("Download").end().end(); div().id("requestContentTab"); diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/BalancerHealthMonitor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/BalancerHealthMonitor.java index 11aefeeac5..d024574b6d 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/BalancerHealthMonitor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/BalancerHealthMonitor.java @@ -28,6 +28,8 @@ import org.springframework.context.*; import java.io.*; +import java.net.InetSocketAddress; +import java.net.Socket; import java.util.*; import static com.predic8.membrane.core.http.Request.*; @@ -40,7 +42,9 @@ * Periodically checks the health of all clusters registered * on the router and updates each {@link Node}'s status accordingly. * When initialized, it schedules a task to call each {@link Node}'s health - * endpoint and marks nodes as {@link Status#UP} or {@link Status#DOWN} based on the HTTP response. + * endpoint and marks nodes as {@link Status#UP} or {@link Status#DOWN} based on the result: + * If a health URL is configured for the node, it performs an HTTP request against that endpoint. + * Otherwise, it performs a TCP check against the node's host and port. * This ensures the load balancer always has up-to-date status for routing decisions. * @example health monitor example * @topic 4. Monitoring, Logging and Statistics @@ -113,13 +117,16 @@ public void run() { } private Status isHealthy(Node node) { - String url = getNodeHealthEndpoint(node); - try { - return getStatus(node, doCall(url)); - } catch (Exception e) { - log.warn("Calling health endpoint failed: {}, {}", url, e.getMessage()); - return DOWN; + String healthUrl = node.getHealthUrl(); + if(healthUrl != null) { + try { + return getStatus(node, doCall(healthUrl)); + } catch (Exception e) { + log.warn("Calling health endpoint failed: {}, {}", healthUrl, e.getMessage()); + return DOWN; + } } + return tcpHealthy(node); } private static Status getStatus(Node node, Exchange exc) { @@ -140,10 +147,18 @@ private static Status getStatus(Node node, Exchange exc) { return UP; } - private static String getNodeHealthEndpoint(Node node) { - return node.getHealthUrl() != null - ? node.getHealthUrl() - : getUrl(node.getHost(), node.getPort()); + public Status tcpHealthy(Node node) { + final String host = node.getHost(); + final int port = node.getPort(); + + try(Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), httpClientConfig.getConnection().getTimeout()); + log.debug("Node {}:{} is healthy", host, port); + return UP; + } catch (Exception e) { + log.warn("TCP health check failed for {}:{} - {}", host, port, e); + return DOWN; + } } private Exchange doCall(String url) throws Exception { diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/BalancerUtil.java b/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/BalancerUtil.java index dbe9e62164..cf2ba9194b 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/BalancerUtil.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/BalancerUtil.java @@ -16,116 +16,123 @@ import com.predic8.membrane.core.*; import com.predic8.membrane.core.interceptor.*; +import com.predic8.membrane.core.interceptor.chain.ChainDef; import com.predic8.membrane.core.proxies.*; import java.util.*; +import java.util.function.Function; +import java.util.stream.Stream; public class BalancerUtil { - public static List collectClusters(Router router) { - ArrayList result = new ArrayList<>(); - for (Proxy r : router.getRuleManager().getRules()) { - List interceptors = r.getFlow(); - if (interceptors != null) - for (Interceptor i : interceptors) - if (i instanceof LoadBalancingInterceptor) - result.addAll(((LoadBalancingInterceptor)i).getClusterManager().getClusters()); - } - return result; - } - - public static List collectBalancers(Router router) { - ArrayList result = new ArrayList<>(); - for (Proxy r : router.getRuleManager().getRules()) { - List interceptors = r.getFlow(); - if (interceptors != null) - for (Interceptor i : interceptors) - if (i instanceof LoadBalancingInterceptor) - result.add((LoadBalancingInterceptor)i); - } - return result; - } - - public static Balancer lookupBalancer(Router router, String name) { - for (Proxy r : router.getRuleManager().getRules()) { - List interceptors = r.getFlow(); - if (interceptors != null) - for (Interceptor i : interceptors) - if (i instanceof LoadBalancingInterceptor) - if (((LoadBalancingInterceptor)i).getName().equalsIgnoreCase(name)) - return ((LoadBalancingInterceptor) i).getClusterManager(); - } - throw new RuntimeException("balancer with name \"" + name + "\" not found."); - } - - public static LoadBalancingInterceptor lookupBalancerInterceptor(Router router, String name) { - for (Proxy r : router.getRuleManager().getRules()) { - List interceptors = r.getFlow(); - if (interceptors != null) - for (Interceptor i : interceptors) - if (i instanceof LoadBalancingInterceptor) - if (((LoadBalancingInterceptor)i).getName().equalsIgnoreCase(name)) - return (LoadBalancingInterceptor) i; - } - throw new RuntimeException("balancer with name \"" + name + "\" not found."); - } - - public static boolean hasLoadBalancing(Router router) { - for (Proxy r : router.getRuleManager().getRules()) { - List interceptors = r.getFlow(); - if (interceptors == null) - continue; - for (Interceptor i : interceptors) - if (i instanceof LoadBalancingInterceptor) - return true; - } - return false; - } - - public static void up(Router router, String balancerName, String cName, String host, int port) { - lookupBalancer(router, balancerName).up(cName, host, port); - } - - public static void down(Router router, String balancerName, String cName, String host, int port) { - lookupBalancer(router, balancerName).down(cName, host, port); - } - - public static void takeout(Router router, String balancerName, String cName, String host, int port) { - lookupBalancer(router, balancerName).takeout(cName, host, port); - } - - public static List getAllNodesByCluster(Router router, String balancerName, String cName) { - return lookupBalancer(router, balancerName).getAllNodesByCluster(cName); - } - - public static List getAvailableNodesByCluster(Router router, String balancerName, String cName) { - return lookupBalancer(router, balancerName).getAvailableNodesByCluster(cName); - } - - public static void addSession2Cluster(Router router, String balancerName, String sessionId, String cName, Node n) { - lookupBalancer(router, balancerName).addSession2Cluster(sessionId, cName, n); - } - - public static void removeNode(Router router, String balancerName, String cluster, String host, int port) { - lookupBalancer(router, balancerName).removeNode(cluster, host, port); - } - - public static Node getNode(Router router, String balancerName, String cluster, String host, int port) { - return lookupBalancer(router, balancerName).getNode(cluster, host, port); - } - - public static Map getSessions(Router router, String balancerName, String cluster) { - return lookupBalancer(router, balancerName).getSessions(cluster); - } - - public static List getSessionsByNode(Router router, String balancerName, String cName, Node node) { - return lookupBalancer(router, balancerName).getSessionsByNode(cName, node); - } - - public static String getSingleClusterNameOrDefault(Balancer balancer){ - if(balancer.getClusters().size() == 1) - return balancer.getClusters().getFirst().getName(); - return Cluster.DEFAULT_NAME; - } + private static Stream> allFlows(Router router) { + return Stream.of( + router.getRuleManager() + .getRules() + .stream() + .map(Proxy::getFlow), + Optional.ofNullable(router.getBeanFactory()) + .map(ctx -> ctx.getBeansOfType(ChainDef.class) + .values() + .stream() + .map(ChainDef::getFlow)) + .orElseGet(Stream::empty), + Stream.of(router.getGlobalInterceptor().getFlow()) + ).flatMap(Function.identity()); + } + + public static List collectBalancers(Router router) { + return allFlows(router) + .filter(Objects::nonNull) + .flatMap(List::stream) + .filter(LoadBalancingInterceptor.class::isInstance) + .map(LoadBalancingInterceptor.class::cast) + .distinct() + .toList(); + } + + public static List collectClusters(Router router) { + return Stream.concat( + collectBalancers(router).stream() + .flatMap(lbi -> lbi.getClusterManager().getClusters().stream()), + Optional.ofNullable(router.getBeanFactory()) + .map(ctx -> ctx.getBeansOfType(Balancer.class).values().stream() + .flatMap(b -> b.getClusters().stream())) + .orElseGet(Stream::empty) + ).distinct().toList(); + } + + public static Balancer lookupBalancer(Router router, String name) { + return collectBalancers(router).stream() + .filter(lbi -> lbi.getName() != null && lbi.getName().equalsIgnoreCase(name)) + .map(LoadBalancingInterceptor::getClusterManager) + .findFirst() + .orElseThrow(() -> new RuntimeException("balancer with name %s not found.".formatted(name))); + } + + public static LoadBalancingInterceptor lookupBalancerInterceptor(Router router, String name) { + return collectBalancers(router).stream() + .filter(lbi -> lbi.getName() != null && lbi.getName().equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(() -> new RuntimeException("balancer with name %s not found.".formatted(name))); + } + + public static boolean hasLoadBalancing(Router router) { + for (Proxy r : router.getRuleManager().getRules()) { + List interceptors = r.getFlow(); + if (interceptors == null) + continue; + for (Interceptor i : interceptors) + if (i instanceof LoadBalancingInterceptor) + return true; + } + return false; + } + + public static void up(Router router, String balancerName, String cName, String host, int port) { + lookupBalancer(router, balancerName).up(cName, host, port); + } + + public static void down(Router router, String balancerName, String cName, String host, int port) { + lookupBalancer(router, balancerName).down(cName, host, port); + } + + public static void takeout(Router router, String balancerName, String cName, String host, int port) { + lookupBalancer(router, balancerName).takeout(cName, host, port); + } + + public static List getAllNodesByCluster(Router router, String balancerName, String cName) { + return lookupBalancer(router, balancerName).getAllNodesByCluster(cName); + } + + public static List getAvailableNodesByCluster(Router router, String balancerName, String cName) { + return lookupBalancer(router, balancerName).getAvailableNodesByCluster(cName); + } + + public static void addSession2Cluster(Router router, String balancerName, String sessionId, String cName, Node n) { + lookupBalancer(router, balancerName).addSession2Cluster(sessionId, cName, n); + } + + public static void removeNode(Router router, String balancerName, String cluster, String host, int port) { + lookupBalancer(router, balancerName).removeNode(cluster, host, port); + } + + public static Node getNode(Router router, String balancerName, String cluster, String host, int port) { + return lookupBalancer(router, balancerName).getNode(cluster, host, port); + } + + public static Map getSessions(Router router, String balancerName, String cluster) { + return lookupBalancer(router, balancerName).getSessions(cluster); + } + + public static List getSessionsByNode(Router router, String balancerName, String cName, Node node) { + return lookupBalancer(router, balancerName).getSessionsByNode(cName, node); + } + + public static String getSingleClusterNameOrDefault(Balancer balancer) { + if (balancer.getClusters().size() == 1) + return balancer.getClusters().getFirst().getName(); + return Cluster.DEFAULT_NAME; + } } diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/ExchangeNodeStatusTracker.java b/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/ExchangeNodeStatusTracker.java new file mode 100644 index 0000000000..2871894ccb --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/ExchangeNodeStatusTracker.java @@ -0,0 +1,65 @@ +package com.predic8.membrane.core.interceptor.balancer; + +import static com.predic8.membrane.core.exchange.Exchange.TRACK_NODE_STATUS; +import static java.lang.Boolean.TRUE; + +/** + * Used by the {@link LoadBalancingInterceptor} to track the status of nodes during load balancing. + * + * The order of the elements in the arrays correspond to exchange.getDestinations(). Each destination either results + * in a status code or an exception. + */ +public class ExchangeNodeStatusTracker { + private final int destinationCount; + private int[] nodeStatusCodes; + private Exception[] nodeExceptions; + + public ExchangeNodeStatusTracker(int destinationCount) { + this.destinationCount = destinationCount; + } + + public void setNodeStatusCode(int tryCounter, int code) { + if (nodeStatusCodes == null) { + nodeStatusCodes = new int[destinationCount]; + } + nodeStatusCodes[tryCounter % destinationCount] = code; + } + + public void trackNodeException(int tryCounter, Exception e) { + if (nodeExceptions == null) { + nodeExceptions = new Exception[destinationCount]; + } + nodeExceptions[tryCounter % destinationCount] = e; + } + + void setNodeStatusCodes(int[] nodeStatusCodes) { + this.nodeStatusCodes = nodeStatusCodes; + } + + public int[] getNodeStatusCodes() { + return nodeStatusCodes; + } + + void setNodeExceptions(Exception[] nodeExceptions) { + this.nodeExceptions = nodeExceptions; + } + + public Exception[] getNodeExceptions() { + return nodeExceptions; + } + + public ExchangeNodeStatusTracker clone() { + ExchangeNodeStatusTracker copy = new ExchangeNodeStatusTracker(destinationCount); + int[] nodeStatusCodes = getNodeStatusCodes(); + if (nodeStatusCodes != null) + copy.setNodeStatusCodes(nodeStatusCodes.clone()); + Exception[] nodeExceptions = getNodeExceptions(); + if (nodeExceptions != null) + copy.setNodeExceptions(nodeExceptions.clone()); + return copy; + } + + public int estimateHeapSize() { + return 50 + destinationCount * 4096; + } +} diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/LoadBalancingInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/LoadBalancingInterceptor.java index 9ef5749275..4f32fbaf7e 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/LoadBalancingInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/LoadBalancingInterceptor.java @@ -50,6 +50,7 @@ public class LoadBalancingInterceptor extends AbstractInterceptor { private SessionIdExtractor sessionIdExtractor; private boolean failOver = true; private final Balancer balancer = new Balancer(); + private boolean trackNodeStatus; public LoadBalancingInterceptor() { name = "balancer"; @@ -106,6 +107,9 @@ public Outcome handleRequest(Exchange exc) { setFailOverNodes(exc, dispatchedNode); + if (trackNodeStatus) + exc.initNodeStatusTracker(); + return CONTINUE; } @@ -297,4 +301,17 @@ public void setSessionTimeout(long sessionTimeout) { public String getShortDescription() { return "Performs load-balancing between several nodes."; } + + public boolean isTrackNodeStatus() { + return trackNodeStatus; + } + + /** + * @description Whether to track the per-node result on every Exchange. + * @default false + */ + @MCAttribute + public void setTrackNodeStatus(boolean trackNodeStatus) { + this.trackNodeStatus = trackNodeStatus; + } } diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/Node.java b/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/Node.java index 63b33f203a..3529d8678d 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/Node.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/balancer/Node.java @@ -137,7 +137,8 @@ public void setPort(int port) { } /** - * @description Sets the node's health-check URL. If not set, the default URL derived from host and port will be used. + * @description Sets the node's health-check URL. If not set, a TCP check against the host and port is used. + * Only works when a balancerHealthMonitor is set. * @param healthUrl the full HTTP(s) endpoint for this node's health check * @example <node host="localhost" port="8080" healthUrl="http://localhost:8080/health"/> */ diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java b/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java index 7621b45802..5610f54bc6 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/Jwks.java @@ -19,9 +19,12 @@ import com.predic8.membrane.annot.MCAttribute; import com.predic8.membrane.annot.MCChildElement; import com.predic8.membrane.annot.MCElement; +import com.predic8.membrane.core.Router; import com.predic8.membrane.core.config.security.Blob; import com.predic8.membrane.core.interceptor.oauth2.authorizationservice.AuthorizationService; +import com.predic8.membrane.core.resolver.HTTPSchemaResolver; import com.predic8.membrane.core.resolver.ResolverMap; +import com.predic8.membrane.core.transport.http.client.HttpClientConfiguration; import com.predic8.membrane.core.util.TextUtil; import java.io.IOException; @@ -57,14 +60,14 @@ public Jwks setJwksUris(String jwksUris) { return this; } - public void init(ResolverMap resolverMap, String baseLocation) { + public void init(Router router) { if(jwksUris == null || jwksUris.isEmpty()) return; ObjectMapper mapper = new ObjectMapper(); for (String uri : jwksUris.split(" ")) { try { - for (Object jwkRaw : parseJwksUriIntoList(resolverMap, baseLocation, mapper, uri)) { + for (Object jwkRaw : parseJwksUriIntoList(router.getResolverMap(), router.getBaseLocation(), mapper, uri)) { Jwk jwk = new Jwk(); jwk.setContent(mapper.writeValueAsString(jwkRaw)); this.jwks.add(jwk); @@ -96,6 +99,8 @@ public static class Jwk extends Blob { String kid; + private HttpClientConfiguration httpClientConfig; + public String getKid() { return kid; } @@ -106,12 +111,36 @@ public Jwk setKid(String kid) { return this; } - public String getJwk(ResolverMap resolverMap, String baseLocation, ObjectMapper mapper) throws IOException { - String maybeJwk = get(resolverMap, baseLocation); + /** + * @description Sets the HTTP client configuration. + * + * @param httpClientConfig the configuration to set for the HTTP client + */ + @MCAttribute + public void setHttpClientConfig(HttpClientConfiguration httpClientConfig) { + this.httpClientConfig = httpClientConfig; + } + + public HttpClientConfiguration getHttpClientConfig() { + return httpClientConfig; + } - Map mapped = mapper.readValue(maybeJwk, new TypeReference<>() {}); - if(mapped.containsKey("keys")) + public String getJwk(Router router, ObjectMapper mapper) throws IOException { + ResolverMap rm = router.getResolverMap(); + + if (httpClientConfig != null) { + HTTPSchemaResolver httpSR = new HTTPSchemaResolver(router.getHttpClientFactory()); + httpSR.setHttpClientConfig(httpClientConfig); + + rm = rm.clone(); + rm.addSchemaResolver(httpSR); + } + + String maybeJwk = get(rm, router.getBaseLocation()); + + Map mapped = mapper.readValue(maybeJwk, new TypeReference<>() {}); + if (mapped.containsKey("keys")) return handleJwks(mapper, mapped); return maybeJwk; diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java b/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java index f76d47db45..95f8f40ed5 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/jwt/JwtAuthInterceptor.java @@ -69,12 +69,12 @@ public void init() { if(jwtRetriever == null) jwtRetriever = new HeaderJwtRetriever("Authorization","Bearer"); - jwks.init(router.getResolverMap(),router.getBaseLocation()); + jwks.init(router); kidToKey = jwks.getJwks().stream() .map(jwk -> { try { - return new RsaJsonWebKey(mapper.readValue(jwk.getJwk(router.getResolverMap(), router.getBaseLocation(),mapper),Map.class)); + return new RsaJsonWebKey(mapper.readValue(jwk.getJwk(router, mapper),Map.class)); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/core/src/main/java/com/predic8/membrane/core/interceptor/log/access/AccessLogInterceptorService.java b/core/src/main/java/com/predic8/membrane/core/interceptor/log/access/AccessLogInterceptorService.java index 280fd1f102..110e0a62e3 100644 --- a/core/src/main/java/com/predic8/membrane/core/interceptor/log/access/AccessLogInterceptorService.java +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/log/access/AccessLogInterceptorService.java @@ -15,27 +15,38 @@ package com.predic8.membrane.core.interceptor.log.access; -import com.predic8.membrane.core.exchange.*; -import com.predic8.membrane.core.http.*; -import com.predic8.membrane.core.interceptor.log.AccessLogInterceptor; +import com.predic8.membrane.core.exchange.Exchange; +import com.predic8.membrane.core.http.Message; import com.predic8.membrane.core.interceptor.log.AdditionalVariable; -import com.predic8.membrane.core.lang.spel.*; -import org.slf4j.*; - -import java.io.*; -import java.text.*; -import java.util.*; -import java.util.function.*; -import java.util.stream.*; - -import static com.predic8.membrane.core.interceptor.Interceptor.Flow.*; -import static com.predic8.membrane.core.util.TextUtil.*; +import com.predic8.membrane.core.lang.spel.SpELExchangeEvaluationContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import java.io.IOException; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import static com.predic8.membrane.core.interceptor.Interceptor.Flow.REQUEST; +import static com.predic8.membrane.core.util.TextUtil.escapeQuotes; +import static java.lang.Long.parseLong; +import static java.time.Instant.ofEpochMilli; +import static java.time.ZoneId.systemDefault; +import static java.time.format.DateTimeFormatter.ofPattern; public class AccessLogInterceptorService { private static final Logger log = LoggerFactory.getLogger(AccessLogInterceptorService.class); - private final SimpleDateFormat dateTimeFormat; + // Fixes AccessLogInterceptor: Synchronization Problem with SimpleDateFormat #2672. Thanks, Bernd + private final DateTimeFormatter dateTimeFormat; private final String defaultValue; private final List additionalVariables; private final boolean excludePayloadSize; @@ -46,7 +57,7 @@ public AccessLogInterceptorService( List additionalVariables, boolean excludePayloadSize ) { - this.dateTimeFormat = new SimpleDateFormat(dateTimePattern); + this.dateTimeFormat = ofPattern(dateTimePattern).withZone(systemDefault()); this.defaultValue = defaultValue; this.additionalVariables = additionalVariables; this.excludePayloadSize = excludePayloadSize; @@ -159,6 +170,10 @@ private Function> ad } private String convert(String timestamp) { - return dateTimeFormat.format(Long.parseLong(timestamp)); + try { + return escapeQuotes(dateTimeFormat.format(ofEpochMilli(parseLong(timestamp)))); + } catch (Exception e) { + return defaultValue; + } } } 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..748f1b45d0 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 SchemaMappings schemaMappings = new SchemaMappings(); + public ValidatorInterceptor() { name = "validator"; } @@ -92,15 +94,23 @@ public void init() { private MessageValidator getMessageValidator() throws Exception { if (wsdl != null) { + if (schemaMappings != null) + logIgnoringRefSchemas(); return new WSDLValidator(resourceResolver, combine(getBaseLocation(), wsdl), serviceName, createFailureHandler(), skipFaults); } if (schema != 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); + return new JSONYAMLSchemaValidator(resourceResolver, combine(getBaseLocation(), jsonSchema), createFailureHandler(), schemaVersion) {{ + setSchemaMappings(schemaMappings.getSchemaMap()); + }}; } if (schematron != null) { + if (schemaMappings != null) + logIgnoringRefSchemas(); return new SchematronValidator(combine(getBaseLocation(), schematron), createFailureHandler(), router, applicationContext); } @@ -110,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(); @@ -319,4 +333,14 @@ private FailureHandler createFailureHandler() { throw new IllegalArgumentException("Unknown failureHandler type: " + failureHandler); } + @MCChildElement + public void setReferenceSchemas(SchemaMappings schemaMappings) { + this.schemaMappings = schemaMappings; + } + + public SchemaMappings getReferenceSchemas() { + return schemaMappings; + } + + } 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..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 @@ -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,11 @@ public String getName() { @Override public void init() { super.init(); - 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 +218,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/SchemaMappings.java b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/SchemaMappings.java new file mode 100644 index 0000000000..7f86583daf --- /dev/null +++ b/core/src/main/java/com/predic8/membrane/core/interceptor/schemavalidation/json/SchemaMappings.java @@ -0,0 +1,58 @@ +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 = "schemaMappings") +public class SchemaMappings { + + private 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; + + @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; + } + } +} 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..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 @@ -18,37 +18,45 @@ 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". + * + * 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 +74,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 +91,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 +110,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/StringValidator.java b/core/src/main/java/com/predic8/membrane/core/openapi/validators/StringValidator.java index b31f3fb4a9..47195f6a40 100644 --- a/core/src/main/java/com/predic8/membrane/core/openapi/validators/StringValidator.java +++ b/core/src/main/java/com/predic8/membrane/core/openapi/validators/StringValidator.java @@ -24,6 +24,7 @@ import java.util.regex.*; import static com.predic8.membrane.core.openapi.util.Utils.*; +import static com.predic8.membrane.core.openapi.validators.ValidationContext.ValidatedEntityType.QUERY_PARAMETER; import static java.lang.String.*; @SuppressWarnings("rawtypes") @@ -61,11 +62,14 @@ public ValidationErrors validate(ValidationContext ctx, Object obj) { String value; if (obj instanceof JsonNode node) { - if (!JsonNodeType.STRING.equals(node.getNodeType())) { + if (JsonNodeType.STRING.equals(node.getNodeType())) { + value = node.textValue(); + } else if (QUERY_PARAMETER.equals(ctx.getValidatedEntityType())) { + value = node.asText(); + } else { errors.add(ctx, format("String expected but got %s of type %s", node, node.getNodeType())); return errors; } - value = node.textValue(); } else if (obj instanceof String s) { value = s; } else { 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/transport/http/ByteStreamLogging.java b/core/src/main/java/com/predic8/membrane/core/transport/http/ByteStreamLogging.java index b769501747..d691ec6872 100644 --- a/core/src/main/java/com/predic8/membrane/core/transport/http/ByteStreamLogging.java +++ b/core/src/main/java/com/predic8/membrane/core/transport/http/ByteStreamLogging.java @@ -24,6 +24,7 @@ import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import static java.lang.System.lineSeparator; import static java.nio.charset.StandardCharsets.US_ASCII; /** @@ -32,11 +33,12 @@ *

* It will print all bytes transmitted or received by the HttpClient on a HTTP or HTTPS connection. *

- * HTTPS content will be printed unencrypted. + * HTTPS content will be printed unencryptedly. *

* This will create log entries like the following: * - * 08:15:50,047 TRACE 56 router /127.0.0.1:50835 ByteStreamLogging:52 {} - [c-569960249 out] [ 71 69 84 32 47 32 ... ] GET / HTTP/1.1 + * 08:15:50,047 TRACE 56 router /127.0.0.1:50835 ByteStreamLogging:52 {} - [c-569960249 out] [ 71 69 84 32 47 32 ... ] + * GET / HTTP/1.1 * User-Agent: Jakarta Commons-HttpClient/3.1 * ... * @@ -59,7 +61,7 @@ public static void log(String name, int b){ } public static void log(String name, byte... b){ - log(name, b,0,b.length); + log(name, b, 0, b.length); } public static void log(String name, byte[] b, int off, int len){ @@ -68,8 +70,9 @@ public static void log(String name, byte[] b, int off, int len){ for(int i = off; i < off+len; i++) { sb.append(b[i]).append(" "); } - sb.append("] "); - sb.append(new String(b,off,len, US_ASCII)); + sb.append("]"); + sb.append(lineSeparator()); + sb.append(new String(b, off, len, US_ASCII)); log.trace(sb.toString()); } @@ -89,7 +92,7 @@ public void write(byte[] b) throws IOException { @Override public void write(byte[] b, int off, int len) throws IOException { - log(name, b,off,len); + log(name, b, off, len); out.write(b, off, len); } @@ -110,21 +113,24 @@ public static InputStream wrapConnectionInputStream(InputStream in, String name) @Override public int read() throws IOException { int res = in.read(); - log(name, res); + if (res != -1) + log(name, res); return res; } @Override public int read(byte[] b) throws IOException { int res = in.read(b); - log(name, b); + if (res != -1) + log(name, b, 0, res); return res; } @Override public int read(byte[] b, int off, int len) throws IOException { int res = in.read(b, off, len); - log(name, b,off,len); + if (res != -1) + log(name, b, off, res); return res; } diff --git a/core/src/main/java/com/predic8/membrane/core/transport/http/HttpClient.java b/core/src/main/java/com/predic8/membrane/core/transport/http/HttpClient.java index 8c3d9a4c87..bcc9b17ade 100644 --- a/core/src/main/java/com/predic8/membrane/core/transport/http/HttpClient.java +++ b/core/src/main/java/com/predic8/membrane/core/transport/http/HttpClient.java @@ -85,9 +85,8 @@ private boolean dispatchCall(Exchange exc, String target, int attempt) throws Ex protocolHandlerFactory.getHandlerForConnection(exc, outConType).handle(exc, outConType, hcp); - if (trackNodeStatus(exc)) { - exc.setNodeStatusCode(attempt, exc.getResponse().getStatusCode()); - } + if (exc.getNodeStatusTracker() != null) + exc.getNodeStatusTracker().setNodeStatusCode(attempt, exc.getResponse().getStatusCode()); // Check for protocol upgrades String upgradedProtocol = exc.getProperty(UPGRADED_PROTOCOL, String.class); diff --git a/core/src/main/java/com/predic8/membrane/core/transport/http/client/RetryHandler.java b/core/src/main/java/com/predic8/membrane/core/transport/http/client/RetryHandler.java index 50616284f5..705933defe 100644 --- a/core/src/main/java/com/predic8/membrane/core/transport/http/client/RetryHandler.java +++ b/core/src/main/java/com/predic8/membrane/core/transport/http/client/RetryHandler.java @@ -110,7 +110,8 @@ public void executeWithRetries(Exchange exc, RetryableCall call) throws Exceptio } log.debug("Retryable failure on attempt #{} to {}: {}", attempt, dest, e.getMessage()); - exc.trackNodeException(attempt, e); + if (exc.getNodeStatusTracker() != null) + exc.getNodeStatusTracker().trackNodeException(attempt, e); } if (attempt < retries) { delayBetweenCalls(exc, currentDelay); 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/main/resources/com/predic8/membrane/core/interceptor/administration/docBase/admin/js/membrane.js b/core/src/main/resources/com/predic8/membrane/core/interceptor/administration/docBase/admin/js/membrane.js index 28baf354c6..53f78c74ac 100644 --- a/core/src/main/resources/com/predic8/membrane/core/interceptor/administration/docBase/admin/js/membrane.js +++ b/core/src/main/resources/com/predic8/membrane/core/interceptor/administration/docBase/admin/js/membrane.js @@ -147,7 +147,11 @@ var membrane = function() { }); } else { loadText('#request-body', requestBodyUrl); - } + } + + if (exc.nodeStatusCodes) { + $('#node-status').attr('style', ''); + } }, 'json'); $('#request-meta').dataTable(getMetaTableConfiguration(function(data) { @@ -161,6 +165,18 @@ var membrane = function() { ["Content Type", data.reqContentType], ["Length", data.reqContentLength] ];})); + $('#request-node-status').dataTable(getMetaTableConfiguration(function(data) { + var res = []; + for (var i = 0; i < data.destinations.length; i++) { + var status = ""; + if (data.nodeExceptions && i < data.nodeExceptions.length && data.nodeExceptions[i]) + status = data.nodeExceptions[i]; + else if (data.nodeStatusCodes && i < data.nodeStatusCodes.length && data.nodeStatusCodes[i]) + status = data.nodeStatusCodes[i]; + res.push([data.destinations[i], status]); + } + return res; + })); $('#request-headers').dataTable(getHeaderTableConfiguration(requestHeaderUrl)); loadText('#request-raw', requestRawUrl); diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/AbortInResponseFlowInternalRoutingTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/AbortInResponseFlowInternalRoutingTest.java index a1043d2161..ca5a5a2a23 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/AbortInResponseFlowInternalRoutingTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/AbortInResponseFlowInternalRoutingTest.java @@ -27,7 +27,7 @@ protected void configure() throws Exception { api(api -> { api.setKey(new ServiceProxyKey("*", "*", null, 2000)); api.add(A); - api.getTarget().setUrl("internal:a"); + api.getTarget().setUrl("internal://a"); }); internal(api -> { diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/AbortInternalRoutingTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/AbortInternalRoutingTest.java index af3628cde9..6987077e06 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/AbortInternalRoutingTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/AbortInternalRoutingTest.java @@ -29,7 +29,7 @@ protected void configure() throws Exception { api(api -> { api.setKey(new ServiceProxyKey("*", "*", null, 2000)); api.add(A); - api.getTarget().setUrl("internal:a"); + api.getTarget().setUrl("internal://a"); }); internal(api -> { diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/RequestResponseInternalRoutingTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/RequestResponseInternalRoutingTest.java index d11330b9f0..740fa47919 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/RequestResponseInternalRoutingTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/RequestResponseInternalRoutingTest.java @@ -29,7 +29,7 @@ protected void configure() throws Exception { api(api -> { api.setKey(new ServiceProxyKey("*","*",null,2000)); api.add(A); - api.getTarget().setUrl("internal:a"); + api.getTarget().setUrl("internal://a"); }); internal(api -> { diff --git a/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/SimpleInternalRoutingTest.java b/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/SimpleInternalRoutingTest.java index d999eefef5..0fefe7ef36 100644 --- a/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/SimpleInternalRoutingTest.java +++ b/core/src/test/java/com/predic8/membrane/core/interceptor/flow/invocation/internalservice/SimpleInternalRoutingTest.java @@ -29,7 +29,7 @@ protected void configure() throws Exception { api(api -> { api.setKey(new ServiceProxyKey("*","*",null,2000)); api.add(A); - api.getTarget().setUrl("internal:a"); + api.getTarget().setUrl("internal://a"); }); internal(api -> { 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/transport/http/ConnectionKeepAliveTest.java b/core/src/test/java/com/predic8/membrane/core/transport/http/ConnectionKeepAliveTest.java new file mode 100644 index 0000000000..2fae536cdb --- /dev/null +++ b/core/src/test/java/com/predic8/membrane/core/transport/http/ConnectionKeepAliveTest.java @@ -0,0 +1,104 @@ +package com.predic8.membrane.core.transport.http; + +import com.predic8.membrane.core.HttpRouter; +import com.predic8.membrane.core.Router; +import com.predic8.membrane.core.config.Path; +import com.predic8.membrane.core.exchange.Exchange; +import com.predic8.membrane.core.http.Request; +import com.predic8.membrane.core.interceptor.flow.ResponseInterceptor; +import com.predic8.membrane.core.interceptor.flow.ReturnInterceptor; +import com.predic8.membrane.core.interceptor.groovy.GroovyInterceptor; +import com.predic8.membrane.core.interceptor.templating.StaticInterceptor; +import com.predic8.membrane.core.openapi.serviceproxy.APIProxy; +import com.predic8.membrane.core.openapi.serviceproxy.OpenAPISpec; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static com.predic8.membrane.core.openapi.serviceproxy.OpenAPISpec.YesNoOpenAPIOption.YES; + +public class ConnectionKeepAliveTest { + + Router gateway; + Router backend; + + @BeforeEach + void setUp() throws Exception { + gateway = getGateway(); + backend = getBackend(); + } + + @Test + void foo() throws Exception{ + HttpClient client = new HttpClient(); + Exchange exc = Request.get("http://localhost:2000/health").buildExchange(); + client.call(exc); + client.call(exc); + } + + private @NotNull Router getGateway() throws Exception{ + HttpRouter router = new HttpRouter(); + router.getRuleManager().addProxyAndOpenPortIfNew(getOpenApiProxy()); + router.setBaseLocation("src/test/resources/"); + router.init(); + return router; + } + + private static @NotNull Router getBackend() throws Exception { + HttpRouter router = new HttpRouter(); + router.getRuleManager().addProxyAndOpenPortIfNew(getHealthProxy()); + router.getRuleManager().addProxyAndOpenPortIfNew(getFooProxy()); + router.init(); + return router; + } + + private @NotNull APIProxy getOpenApiProxy() { + APIProxy openApiProxy = new APIProxy(); + openApiProxy.setPort(2000); + openApiProxy.setSpecs(List.of(new OpenAPISpec() {{ + setLocation("health.yaml"); + setValidateRequests(YES); + setValidationDetails(YES); + setValidateResponses(YES); + setValidateSecurity(YES); + }})); + return openApiProxy; + } + + private static @NotNull APIProxy getFooProxy() { + APIProxy fooProxy = new APIProxy(); + fooProxy.setPort(3000); + fooProxy.setPath(new Path(false, "/foo")); + fooProxy.setFlow(List.of( + new ResponseInterceptor() {{ + setFlow(List.of( + new StaticInterceptor() {{ + setContentType("application/json"); + setSrc("{\"status\": \"ok\"}"); + }}) + ); + }}, new ReturnInterceptor() + )); + return fooProxy; + } + + private static @NotNull APIProxy getHealthProxy() { + APIProxy healthProxy = new APIProxy(); + healthProxy.setPort(3000); + healthProxy.setPath(new Path(false, "/health")); + healthProxy.setFlow(List.of(new GroovyInterceptor() {{ + setSrc(""" + exchange.setResponse(Response.ok("No Matching Rule matched.").build()) + exchange.getResponse().getHeader().add("Transfer-Encoding", "chunked") + exchange.getResponse().getHeader().removeFields("Content-length") + exchange.getResponse().setStatusCode(400) + sleep(100) + return RETURN + """); + }})); + return healthProxy; + } + +} diff --git a/core/src/test/java/com/predic8/membrane/core/transport/http/ServiceInvocationTest.java b/core/src/test/java/com/predic8/membrane/core/transport/http/ServiceInvocationTest.java index f2b5c0326d..513af03008 100644 --- a/core/src/test/java/com/predic8/membrane/core/transport/http/ServiceInvocationTest.java +++ b/core/src/test/java/com/predic8/membrane/core/transport/http/ServiceInvocationTest.java @@ -56,7 +56,7 @@ public void tearDown() { private ServiceProxy createFirstRule() { ServiceProxy rule = new ServiceProxy(new ServiceProxyKey("localhost", METHOD_POST, "*", 2000), "localhost", 80); - rule.getTarget().setUrl("internal:log"); + rule.getTarget().setUrl("internal://log"); rule.getFlow().add(new MockInterceptor("process")); return rule; } 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 + } +} diff --git a/core/src/test/resources/health.yaml b/core/src/test/resources/health.yaml new file mode 100644 index 0000000000..7cc38e518e --- /dev/null +++ b/core/src/test/resources/health.yaml @@ -0,0 +1,37 @@ +openapi: 3.0.3 +info: + title: HCC + version: 1.0.0 +servers: + - url: http://localhost:3000/ +tags: + - name: HEALTH + +paths: + /foo: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + description: Ok + + /health: + get: + tags: + - HEALTH + summary: API health check + description: Checks availability of provided API + responses: + '204': + $ref: '#/components/responses/NoContentResponse' + '4XX': + description: Bad Request + 5XX: + description: Server Error +components: + responses: + NoContentResponse: + description: No Content diff --git a/core/src/test/resources/internal-invocation/proxies.xml b/core/src/test/resources/internal-invocation/proxies.xml index 1250e33470..9d1b214341 100644 --- a/core/src/test/resources/internal-invocation/proxies.xml +++ b/core/src/test/resources/internal-invocation/proxies.xml @@ -74,13 +74,13 @@ - + - + @@ -92,14 +92,14 @@ - + - + diff --git a/distribution/examples/extending-membrane/custom-interceptor/pom.xml b/distribution/examples/extending-membrane/custom-interceptor/pom.xml index 4304c9e618..6a2199a35f 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.17-SNAPSHOT diff --git a/distribution/examples/extending-membrane/embedding-java/pom.xml b/distribution/examples/extending-membrane/embedding-java/pom.xml index 8738e38f44..399aa8f819 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.17-SNAPSHOT org.projectlombok 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..9b3e893a2c --- /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 `schemaMappings`. + +## 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..b0190904d3 --- /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 +} 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..83bb2afd86 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.17-SNAPSHOT diff --git a/distribution/examples/xml/basic-xml-interceptor/pom.xml b/distribution/examples/xml/basic-xml-interceptor/pom.xml index e0e6ecd4ee..63dbd51d8b 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.17-SNAPSHOT diff --git a/distribution/examples/xml/stax-interceptor/pom.xml b/distribution/examples/xml/stax-interceptor/pom.xml index 5f7175ae22..f31bf69320 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.17-SNAPSHOT diff --git a/distribution/pom.xml b/distribution/pom.xml index 1e74716278..c8824edc7a 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.17-SNAPSHOT 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" 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 new file mode 100644 index 0000000000..6ce9861985 --- /dev/null +++ b/distribution/src/test/java/com/predic8/membrane/examples/withoutinternet/validation/JSONSchemaMappingsExampleTest.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 JSONSchemaMappingsExampleTest 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 + } + } +} diff --git a/maven-plugin/pom.xml b/maven-plugin/pom.xml index 2d81eaafdd..20173797ee 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.17-SNAPSHOT diff --git a/membrane.spec b/membrane.spec index 065c027c0b..555eafe153 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.17-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 4e32ec4879..f1371dd649 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.17-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 39d7fe0ae0..d1d158340c 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.17-SNAPSHOT diff --git a/war/.factorypath b/war/.factorypath index 4d5b485791..52a1c352c8 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..fe8a4dce78 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.17-SNAPSHOT