From aa04d269e272e646a9c34c11d21210f346042845 Mon Sep 17 00:00:00 2001 From: Freddy Rodriguez Date: Fri, 27 Jan 2023 10:08:24 -0600 Subject: [PATCH] =?UTF-8?q?Creating=20Util=20classes=20to=20build=20a=20Cu?= =?UTF-8?q?beJS=20Query=20and=20sent=20a=20Request=20to=20a=E2=80=A6=20(#2?= =?UTF-8?q?3895)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Creating Util classes to build a CubeJS Query and sent a Request to a CubeJs Server * Feedback --- .../java/com/dotcms/cube/CubeJSClient.java | 101 +++ .../java/com/dotcms/cube/CubeJSQuery.java | 182 ++++++ .../java/com/dotcms/cube/CubeJSResultSet.java | 54 ++ .../java/com/dotcms/cube/filters/Filter.java | 21 + .../dotcms/cube/filters/LogicalFilter.java | 130 ++++ .../com/dotcms/cube/filters/SimpleFilter.java | 107 ++++ .../http/server/mock/MockHttpServer.java | 126 ++++ .../server/mock/MockHttpServerContext.java | 191 ++++++ .../java/com/dotcms/util/network/IPUtils.java | 14 +- .../com/dotcms/cube/CubeJSClientTest.java | 166 +++++ .../test/java/com/dotcms/cube/CubeJSTest.java | 573 ++++++++++++++++++ 11 files changed, 1661 insertions(+), 4 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotcms/cube/CubeJSClient.java create mode 100644 dotCMS/src/main/java/com/dotcms/cube/CubeJSQuery.java create mode 100644 dotCMS/src/main/java/com/dotcms/cube/CubeJSResultSet.java create mode 100644 dotCMS/src/main/java/com/dotcms/cube/filters/Filter.java create mode 100644 dotCMS/src/main/java/com/dotcms/cube/filters/LogicalFilter.java create mode 100644 dotCMS/src/main/java/com/dotcms/cube/filters/SimpleFilter.java create mode 100644 dotCMS/src/main/java/com/dotcms/http/server/mock/MockHttpServer.java create mode 100644 dotCMS/src/main/java/com/dotcms/http/server/mock/MockHttpServerContext.java create mode 100644 dotCMS/src/test/java/com/dotcms/cube/CubeJSClientTest.java create mode 100644 dotCMS/src/test/java/com/dotcms/cube/CubeJSTest.java diff --git a/dotCMS/src/main/java/com/dotcms/cube/CubeJSClient.java b/dotCMS/src/main/java/com/dotcms/cube/CubeJSClient.java new file mode 100644 index 000000000000..61bf7e5896e6 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cube/CubeJSClient.java @@ -0,0 +1,101 @@ +package com.dotcms.cube; + +import static com.dotcms.util.CollectionsUtils.map; + +import com.dotcms.http.CircuitBreakerUrl; +import com.dotcms.http.CircuitBreakerUrl.Method; +import com.dotcms.http.CircuitBreakerUrl.Response; +import com.dotcms.jitsu.EventLogRunnable; +import com.dotcms.util.DotPreconditions; +import com.dotcms.util.JsonUtil; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.liferay.util.StringPool; +import io.vavr.control.Try; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * CubeJS Client it allow to send a Request to a Cube JS Server. + * Example: + * + * + * + * final String cubeServerIp = "127.0.0.1"; + * final int cubeJsServerPort = 5000; + * + * final CubeJSQuery cubeJSQuery = new Builder() + * .dimensions("Events.experiment", "Events.variant") + * .build(); + * + * final CubeClient cubeClient = new CubeClient(String.format("http://%s:%s", cubeServerIp, cubeJsServerPort)); + * final CubeJSResultSet cubeJSResultSet = cubeClient.send(cubeJSQuery); + * + */ +public class CubeJSClient { + private String url; + + public CubeJSClient(final String url) { + this.url = url; + } + + /** + * Send a request to a CubeJS Server. + * + * Example: + * + * + * + * final String cubeServerIp = "127.0.0.1"; + * final int cubeJsServerPort = 5000; + * + * final CubeJSQuery cubeJSQuery = new Builder() + * .dimensions("Events.experiment", "Events.variant", "Events.utcTime") + * .build(); + * + * final CubeClient cubeClient = new CubeClient(String.format("http://%s:%s", cubeServerIp, cubeJsServerPort)); + * final CubeJSResultSet cubeJSResultSet = cubeClient.send(cubeJSQuery); + * + * for (ResultSetItem resultSetItem : cubeJSResultSet) { + * System.out.println("Events.experiment", resultSetItem.get("Events.experiment").get()) + * System.out.println("Events.variant", resultSetItem.get("Events.variant").get()) + * System.out.println("Events.utcTime", resultSetItem.get("Events.utcTime").get()) + * } + * + * + * @param query Query to be run in the CubeJS Server + * @return + */ + public CubeJSResultSet send(final CubeJSQuery query) { + + DotPreconditions.notNull(query, "Query not must be NULL"); + + final CircuitBreakerUrl cubeJSClient = CircuitBreakerUrl.builder() + .setMethod(Method.GET) + .setUrl(String.format("%s/cubejs-api/v1/load", url)) + .setParams(map("query", query.toString())) + .setTimeout(4000) + .build(); + + final Response response = Try.of(cubeJSClient::doResponse) + .onFailure(e -> Logger.warnAndDebug(EventLogRunnable.class, e.getMessage(), e)) + .getOrElse(CircuitBreakerUrl.EMPTY_RESPONSE); + + try { + final String responseAsString = UtilMethods.isSet(response) ? response.getResponse() : + StringPool.BLANK; + final Map responseAsMap = UtilMethods.isSet(responseAsString) ? + JsonUtil.getJsonFromString(responseAsString) : new HashMap<>(); + final List> data = (List>) responseAsMap.get("data"); + + return new CubeJSResultSet(UtilMethods.isSet(data) ? data : Collections.emptyList()); + } catch (IOException e) { + throw new RuntimeException(e); + } + + + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cube/CubeJSQuery.java b/dotCMS/src/main/java/com/dotcms/cube/CubeJSQuery.java new file mode 100644 index 000000000000..28c1cf289580 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cube/CubeJSQuery.java @@ -0,0 +1,182 @@ +package com.dotcms.cube; + + + +import com.dotcms.cube.filters.Filter.Order; +import com.dotcms.cube.filters.LogicalFilter; +import com.dotcms.cube.filters.SimpleFilter; +import com.dotcms.cube.filters.SimpleFilter.Operator; +import com.dotcms.cube.filters.Filter; +import com.dotcms.util.JsonUtil; +import com.dotmarketing.util.UtilMethods; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Represents a Cube JS Query + * You can use the {@link Builder} to create a CubeJSQuery and later using the + * {@link CubeJSQuery#toString()}. + * + * Examples: + * + * + * final CubeJSQuery cubeJSQuery = new Builder() + * .dimensions("Events.experiment") + * .measures("Events.count") + * .filter("Events.variant", SimpleFilter.Operator.EQUALS, "B") + * .build(); + * + * + * To get: + * + * + * { + * "dimensions": [ + * "Events.experiment" + * ], + * { + * "measures": [ + * "Events.count" + * ], + * filters: [ + * { + * member: "Events.variant", + * operator: "equals", + * values: ["B"] + * } + * ] + * } + * + * + * @see CubeJS Query format + */ +public class CubeJSQuery { + + private String[] dimensions; + private String[] measures; + private Filter[] filters; + + private OrderItem[] orders; + + private CubeJSQuery(final String[] dimensions, + final String[] measures, + final Filter[] filters, + final OrderItem[] orderItems) { + + this.dimensions = dimensions; + this.measures = measures; + this.filters = filters; + this.orders = orderItems; + } + + @Override + public String toString() { + try { + return JsonUtil.getJsonAsString(getMap()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private Map getMap() { + final Map map = new HashMap<>(); + + if (UtilMethods.isSet(dimensions)) { + map.put("dimensions", dimensions); + } + + if (UtilMethods.isSet(measures)) { + map.put("measures", measures); + } + + if (filters.length > 0) { + map.put("filters", getFiltersAsMap()); + } + + if (orders.length > 0) { + map.put("order", getOrdersAsMap()); + } + + return map; + } + + private Map getOrdersAsMap() { + final Map resultMap = new HashMap(); + + for (final OrderItem order : orders) { + resultMap.put(order.orderBy, order.order.name().toLowerCase()); + } + + return resultMap; + } + private List> getFiltersAsMap() { + return Arrays.stream(filters) + .map(filter -> filter.asMap()) + .collect(Collectors.toList()); + } + + public static class Builder { + private String[] dimensions; + private String[] measures; + private List filters = new ArrayList<>(); + private List orders = new ArrayList<>(); + + public CubeJSQuery build(){ + if (!UtilMethods.isSet(dimensions) && !UtilMethods.isSet(measures)) { + throw new IllegalStateException("Must set dimensions or measures"); + } + + return new CubeJSQuery(dimensions, measures, + filters.toArray(new Filter[filters.size()]), + orders.toArray(new OrderItem[orders.size()])); + } + + public Builder dimensions(final String... dimensions) { + this.dimensions = dimensions; + return this; + } + + public Builder measures(final String... measures) { + this.measures = measures; + return this; + } + + public Builder filter(final String member, Operator operator, final String... values) { + filters.add(new SimpleFilter(member, operator, values)); + return this; + } + + public Builder filter(final LogicalFilter logicalFilter) { + filters.add(logicalFilter); + return this; + } + + public Builder order(final String orderBy, final Order order) { + orders.add(new OrderItem(orderBy, order)); + return this; + } + } + + private static class OrderItem { + private String orderBy; + private Order order; + + public OrderItem(final String orderBy, final Order order) { + this.orderBy = orderBy; + this.order = order; + } + + public String getOrderBy() { + return orderBy; + } + + public Order getOrder() { + return order; + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cube/CubeJSResultSet.java b/dotCMS/src/main/java/com/dotcms/cube/CubeJSResultSet.java new file mode 100644 index 000000000000..0ff2be9a4c5c --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cube/CubeJSResultSet.java @@ -0,0 +1,54 @@ +package com.dotcms.cube; + +import com.dotcms.cube.CubeJSResultSet.ResultSetItem; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Spliterator; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.jetbrains.annotations.NotNull; + +/** + * Represent a Result from running a CubeJS Query in a CubeJS Server. + */ +public class CubeJSResultSet implements Iterable { + private List data; + public CubeJSResultSet(final List> data){ + this.data = data.stream().map(map -> new ResultSetItem(map)).collect(Collectors.toList()); + } + + public int size() { + return data.size(); + } + + @NotNull + @Override + public Iterator iterator() { + return data.iterator(); + } + + @Override + public void forEach(Consumer action) { + Iterable.super.forEach(action); + } + + @Override + public Spliterator spliterator() { + return Iterable.super.spliterator(); + } + + public static class ResultSetItem { + + private Map item; + ResultSetItem(final Map item) { + this.item = item; + } + + public Optional get(final String name){ + return Optional.ofNullable(item.get(name)); + } + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/cube/filters/Filter.java b/dotCMS/src/main/java/com/dotcms/cube/filters/Filter.java new file mode 100644 index 000000000000..5092ae147991 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cube/filters/Filter.java @@ -0,0 +1,21 @@ +package com.dotcms.cube.filters; + +import java.util.Map; + +/** + * Represents a CubeJs Query Filter + * + * @see Filters format + */ +public interface Filter { + + static LogicalFilter.Builder and(){ + return null; + } + + Map asMap(); + + public enum Order { + ASC, DESC; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cube/filters/LogicalFilter.java b/dotCMS/src/main/java/com/dotcms/cube/filters/LogicalFilter.java new file mode 100644 index 000000000000..3fd40c544750 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cube/filters/LogicalFilter.java @@ -0,0 +1,130 @@ +package com.dotcms.cube.filters; + +import static com.dotcms.util.CollectionsUtils.map; + +import com.dotcms.cube.filters.SimpleFilter.Operator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Represents a Boolean or Logical Operator to be used in a CubeJS Filters Query. + * + * Example how it could be used: + * + * + * final CubeJSQuery cubeJSQuery = new Builder() + * .dimensions("Events.experiment", "Events.variant") + * .filter( + * LogicalFilter.Builder.or() + * .add("Events.variant", SimpleFilter.Operator.EQUALS, "B") + * .add("Events.experiment", SimpleFilter.Operator.EQUALS, "B") + * .build() + * ) + * .build(); + * + * + * To get: + * + * + * { + * "dimensions": [ + * "Events.experiment" + * ], + * filters: [ + * { + * "or": [ + * { + * member: "Events.variant", + * operator: "equals", + * values: ["B"] + * }, + * { + * member: "Events.experiment", + * operator: "equals", + * values: ["B"] + * } + * ] + * } + * ] + * } + * + * + * @see Boolean logical operator + */ +public class LogicalFilter implements Filter { + + public static enum Type { + AND, OR; + } + + private Type type; + private Filter[] filters; + private LogicalFilter(final Type type, final Filter[] filters) { + this.type = type; + this.filters = filters; + } + + @Override + public Map asMap() { + final String logicalOperator = type.name().toLowerCase(); + + return map(logicalOperator, + Arrays.stream(filters) + .map(filter -> filter.asMap()) + .collect(Collectors.toList())); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + LogicalFilter that = (LogicalFilter) o; + return type == that.type && Arrays.equals(filters, that.filters); + } + + @Override + public int hashCode() { + int result = Objects.hash(type); + result = 31 * result + Arrays.hashCode(filters); + return result; + } + + public static class Builder { + private Type type; + private List filters = new ArrayList<>(); + + private Builder(final Type type){ + this.type = type; + } + + public Builder add(final String member, Operator operator, final String... values){ + filters.add(new SimpleFilter(member, operator, values)); + return this; + } + + public Builder add(final LogicalFilter logicalFilter){ + filters.add(logicalFilter); + return this; + } + + public LogicalFilter build(){ + return new LogicalFilter(type, filters.toArray(new Filter[filters.size()])); + } + + public static Builder and(){ + return new Builder(Type.AND); + } + + public static Builder or(){ + return new Builder(Type.OR); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cube/filters/SimpleFilter.java b/dotCMS/src/main/java/com/dotcms/cube/filters/SimpleFilter.java new file mode 100644 index 000000000000..3a9cc4148cb0 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/cube/filters/SimpleFilter.java @@ -0,0 +1,107 @@ +package com.dotcms.cube.filters; + +import static com.dotcms.util.CollectionsUtils.map; + +import com.dotcms.cube.filters.Filter; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; + +/** + * Represents a Filter to a CubeJS Query with the following properties: + * + *
    + *
  • member: Dimensions or measure to be used in the filter.
  • + *
  • operator: Any values in {@link Operator}
  • + *
  • values: An Array of values for the filter
  • + *
+ * + * Example: + * + * + * final CubeJSQuery cubeJSQuery = new Builder() + * .dimensions("Events.experiment") + * .filter("Events.variant", SimpleFilter.Operator.EQUALS, "B") + * .build(); + * + * + * To get: + * + * + * { + * "dimensions": [ + * "Events.experiment" + * ], + * filters: [ + * { + * member: "Events.variant", + * operator: "equals", + * values: ["B"] + * } + * ] + * } + * + * + * @see
Filters Format + */ +public class SimpleFilter implements Filter { + private String member; + private Operator operator; + private String[] values; + + public SimpleFilter(final String member, final Operator operator, final String[] values) { + this.member = member; + this.operator = operator; + this.values = values; + } + + public String getMember() { + return member; + } + + public Operator getOperator() { + return operator; + } + + public String[] getValues() { + return values; + } + + @Override + public Map asMap(){ + return map("member", member, "operator", operator.getKey(), "values", values); + } + + public enum Operator { + EQUALS("equals"), NOT_EQUALS("notEquals"), CONTAINS("contains"), NOT_CONTAINS("notContains"); + + private String key; + Operator(final String key) { + this.key = key; + } + + public String getKey() { + return key; + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SimpleFilter that = (SimpleFilter) o; + return member.equals(that.member) && operator == that.operator && Arrays.equals( + values, that.values); + } + + @Override + public int hashCode() { + int result = Objects.hash(member, operator); + result = 31 * result + Arrays.hashCode(values); + return result; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/http/server/mock/MockHttpServer.java b/dotCMS/src/main/java/com/dotcms/http/server/mock/MockHttpServer.java new file mode 100644 index 000000000000..4f5bed460f09 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/http/server/mock/MockHttpServer.java @@ -0,0 +1,126 @@ +package com.dotcms.http.server.mock; + +import com.dotcms.http.server.mock.MockHttpServerContext.Condition; +import com.dotcms.http.server.mock.MockHttpServerContext.RequestContext; +import com.dotcms.repackage.twitter4j.internal.http.HttpResponseCode; +import com.dotmarketing.util.UtilMethods; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import graphql.AssertException; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Mock for a HttpServer + */ +public class MockHttpServer { + + private String ip; + private int port; + + private List mockHttpServerContexts = new ArrayList<>(); + private HttpServer httpServer; + private List errors = new ArrayList<>(); + private List uris = new ArrayList<>(); + + public MockHttpServer(String ip, int port) { + this.ip =ip; + this.port = port; + } + + /** + * Add a {@link MockHttpServerContext} to the MockHttpServer + * @param mockHttpServerContext + */ + public void addContext(final MockHttpServerContext mockHttpServerContext) { + this.mockHttpServerContexts.add(mockHttpServerContext); + } + + /** + * Start the Mock Http Server + */ + public void start() { + + try { + httpServer = HttpServer.create(new InetSocketAddress(ip, port), 0); + + for (MockHttpServerContext mockHttpServerContext : mockHttpServerContexts) { + httpServer.createContext(mockHttpServerContext.getUri(), exchange -> { + this.uris.add(exchange.getRequestURI()); + + if (!validate(mockHttpServerContext, exchange)){ + exchange.sendResponseHeaders(HttpResponseCode.INTERNAL_SERVER_ERROR, 0); + } else { + + exchange.sendResponseHeaders(mockHttpServerContext.getStatus(), + mockHttpServerContext.getBody().length()); + + if (UtilMethods.isSet(mockHttpServerContext.getBody())) { + writeBody(mockHttpServerContext, exchange); + } + } + + exchange.close(); + }); + } + + httpServer.start(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Stop the Mock Http Server + */ + public void stop(){ + httpServer.stop(0); + } + private static void writeBody(final MockHttpServerContext mockHttpServerContext, + final HttpExchange exchange) throws IOException { + final OutputStream responseBody = exchange.getResponseBody(); + responseBody.write(mockHttpServerContext.getBody().getBytes()); + responseBody.close(); + } + + private boolean validate(MockHttpServerContext mockHttpServerContext, + HttpExchange exchange) { + final List requestConditions = + mockHttpServerContext.getRequestConditions(); + + final RequestContext requestContext = new RequestContext(exchange); + + for (Condition requestCondition : requestConditions) { + if (!requestCondition.getValidation().apply(requestContext)) { + this.errors.add(requestCondition.getMessage()); + return false; + } + } + + return true; + } + + /** + * Validate if + */ + public void validate() { + if (!errors.isEmpty()) { + throw new AssertionError(errors.stream().collect(Collectors.joining("\n")) ); + } + } + + /** + * Check if the path was hit, if it is not then throw an {@link AssertionError} + * @param path + */ + public void mustNeverCalled(final String path) { + if (uris.stream().anyMatch(uri -> uri.getPath().equals(path))) { + throw new AssertException(path + " Must never called"); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/http/server/mock/MockHttpServerContext.java b/dotCMS/src/main/java/com/dotcms/http/server/mock/MockHttpServerContext.java new file mode 100644 index 000000000000..11798f26ad37 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/http/server/mock/MockHttpServerContext.java @@ -0,0 +1,191 @@ +package com.dotcms.http.server.mock; + + +import com.dotcms.http.CircuitBreakerUrlBuilder; +import com.dotcms.repackage.com.sun.xml.ws.client.ResponseContext; +import com.dotmarketing.util.UtilMethods; +import com.liferay.util.StringPool; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import org.apache.commons.io.IOUtils; + +/** + * Represent a url to mock into a {@link MockHttpServer}, you can set the URL to response and also + * what is going to be the URL. + * + * Examples: + * + * To listing to http://127.0.0.1:5000/testing and response with a http code of 200 and a response + * body of "It is working" + * + * + * final String cubeServerIp = "127.0.0.1"; + * final int cubeJsServerPort = 5000; + * + * final MockHttpServer mockhttpServer = new MockHttpServer(cubeServerIp, cubeJsServerPort); + * + * final MockHttpServerContext mockHttpServerContext = new MockHttpServerContext.Builder() + * .uri("/testing") + * .responseStatus(HttpURLConnection.HTTP_OK) + * .responseBody("It is working") + * .build(); + * + * mockhttpServer.addContext(mockHttpServerContext); + * mockhttpServer.start(); + * + * + * If you want to check a condition to every request you can do: + * + * + * final String cubeServerIp = "127.0.0.1"; + * final int cubeJsServerPort = 5000; + * + * final MockHttpServer mockhttpServer = new MockHttpServer(cubeServerIp, cubeJsServerPort); + * + * final MockHttpServerContext mockHttpServerContext = new MockHttpServerContext.Builder() + * .uri("/testing") + * .responseStatus(HttpURLConnection.HTTP_OK) + * .responseBody("It is working") + * .requestCondition("Cube JS Query is not right", + * context -> context.getRequestParameter("mode") + * .orElse(StringPool.BLANK) + * .equals("test") + * .build(); + * + * mockhttpServer.addContext(mockHttpServerContext); + * mockhttpServer.start(); + * + * + * In this case we are checking that the URl include a "mode" query parameters with a "test" value. + */ +public class MockHttpServerContext { + + private String uri; + private int status; + private String body; + private List conditions; + + private MockHttpServerContext(final String uri, + final int status, + final String body, + final List conditions){ + + this.uri = uri; + this.status = status; + this.body = body; + this.conditions = conditions; + } + + public String getUri() { + return uri; + } + + public int getStatus() { + return status; + } + + public String getBody() { + return body; + } + + public List getRequestConditions() { + return conditions; + } + + public static class Builder { + private String uri; + private int status; + private String body; + private List requestConditions = new ArrayList<>(); + + public Builder uri(final String uri) { + this.uri = uri; + return this; + } + + public MockHttpServerContext build(){ + return new MockHttpServerContext(uri, status, body, requestConditions); + } + + public Builder requestCondition(final String message, final Function handler) { + this.requestConditions.add(new Condition(handler, message)); + return this; + } + + public Builder responseStatus(final int status) { + this.status = status; + return this; + } + + public Builder responseBody(String body) { + this.body = body; + return this; + } + } + + public static class RequestContext { + final HttpExchange httpExchange; + final Map requestParameters = new HashMap<>(); + + public RequestContext(HttpExchange httpExchange) { + this.httpExchange = httpExchange; + + final String query = httpExchange.getRequestURI().getQuery(); + + if (UtilMethods.isSet(query)) { + final String[] querySplited = query.split(StringPool.AMPERSAND); + + for (String queryParameter : querySplited) { + final String[] parameter = queryParameter.split(StringPool.EQUAL); + this.requestParameters.put(parameter[0], parameter[1]); + } + } + } + + public String getRequestBody() { + final InputStream requestBody = this.httpExchange.getRequestBody(); + + try { + return IOUtils.toString(requestBody, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public Optional getRequestParameter(final String name) { + return this.requestParameters.get(name) != null ? Optional.of(this.requestParameters.get(name)) : + Optional.empty(); + } + + public Headers getHeaders() { + return this.httpExchange.getRequestHeaders(); + } + } + + public static class Condition { + private Function validation; + private String message; + + public Condition(Function validation, String message) { + this.validation = validation; + this.message = message; + } + + public Function getValidation() { + return validation; + } + + public String getMessage() { + return message; + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/util/network/IPUtils.java b/dotCMS/src/main/java/com/dotcms/util/network/IPUtils.java index 09819ef77aa7..d12de85af7bf 100644 --- a/dotCMS/src/main/java/com/dotcms/util/network/IPUtils.java +++ b/dotCMS/src/main/java/com/dotcms/util/network/IPUtils.java @@ -10,7 +10,7 @@ import io.vavr.control.Try; public class IPUtils { - + private static boolean disabledIpPrivateSubnet = false; private IPUtils() { throw new IllegalStateException("static Utility class"); @@ -71,7 +71,10 @@ public static boolean isIpInCIDR(final String ip, final String CIDR) { public static boolean isIpPrivateSubnet(final String ipOrHostName) { - + if (disabledIpPrivateSubnet) { + return false; + } + if (ipOrHostName == null) { return true; } @@ -96,6 +99,9 @@ public static boolean isIpPrivateSubnet(final String ipOrHostName) { } - - + + + public static void disabledIpPrivateSubnet(final boolean disabledIpPrivateSubnet) { + IPUtils.disabledIpPrivateSubnet = disabledIpPrivateSubnet; + } } diff --git a/dotCMS/src/test/java/com/dotcms/cube/CubeJSClientTest.java b/dotCMS/src/test/java/com/dotcms/cube/CubeJSClientTest.java new file mode 100644 index 000000000000..2887588d31b0 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/cube/CubeJSClientTest.java @@ -0,0 +1,166 @@ +package com.dotcms.cube; + +import static com.dotcms.util.CollectionsUtils.list; +import static com.dotcms.util.CollectionsUtils.map; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.dotcms.cube.CubeJSQuery.Builder; +import com.dotcms.cube.CubeJSResultSet.ResultSetItem; + +import com.dotcms.http.server.mock.MockHttpServer; +import com.dotcms.http.server.mock.MockHttpServerContext; + +import com.dotcms.util.JsonUtil; +import com.dotcms.util.network.IPUtils; + +import com.liferay.util.StringPool; + + +import java.net.HttpURLConnection; + +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public class CubeJSClientTest { + + /** + * Method to test: {@link CubeJSClient#send(CubeJSQuery)} + * When: Send a request to Cube JS + * Should: Return the right data end send the right Query + */ + @Test + public void sendAllOk() { + + final String cubeServerIp = "127.0.0.1"; + final int cubeJsServerPort = 5000; + + final MockHttpServer mockhttpServer = new MockHttpServer(cubeServerIp, cubeJsServerPort); + + try { + IPUtils.disabledIpPrivateSubnet(true); + + final List> dataList = list( + map( + "Events.experiment", "A", + "Events.variant", "B", + "Events.utcTime", "2022-09-20T15:24:21.000" + ), + map( + "Events.experiment", "A", + "Events.variant", "C", + "Events.utcTime", "2022-09-20T15:24:21.000" + ), + map( + "Events.experiment", "B", + "Events.variant", "C", + "Events.utcTime", "2022-09-20T15:24:21.000" + ) + ); + + final Map>> dataExpected = map("data", dataList); + + final CubeJSQuery cubeJSQuery = new Builder() + .dimensions("Events.experiment", "Events.variant") + .build(); + + final MockHttpServerContext mockHttpServerContext = new MockHttpServerContext.Builder() + .uri("/cubejs-api/v1/load") + .requestCondition("Cube JS Query is not right", + context -> context.getRequestParameter("query") + .orElse(StringPool.BLANK) + .equals(cubeJSQuery.toString())) + .responseStatus(HttpURLConnection.HTTP_OK) + .responseBody(JsonUtil.getJsonStringFromObject(dataExpected)) + .build(); + + mockhttpServer.addContext(mockHttpServerContext); + mockhttpServer.start(); + + final CubeJSClient cubeClient = new CubeJSClient(String.format("http://%s:%s", cubeServerIp, cubeJsServerPort)); + final CubeJSResultSet cubeJSResultSet = cubeClient.send(cubeJSQuery); + + mockhttpServer.validate(); + assertEquals(3, cubeJSResultSet.size()); + + int i = 0; + for (ResultSetItem resultSetItem : cubeJSResultSet) { + final Map objectMap = dataList.get(i); + + assertEquals(objectMap.get("Events.experiment"), resultSetItem.get("Events.experiment").get()); + assertEquals(objectMap.get("Events.variant"), resultSetItem.get("Events.variant").get()); + assertEquals(objectMap.get("Events.utcTime"), resultSetItem.get("Events.utcTime").get()); + i++; + } + } finally { + IPUtils.disabledIpPrivateSubnet(false); + mockhttpServer.stop(); + } + } + + /** + * Method to test: {@link CubeJSClient#send(CubeJSQuery)} + * When: Send a request to Cube JS but the CubeJS Server is down + * Should: Return an empty {@lik CubeJSResultSet} Also it print in the console the follow: + *
+     * Connection attempts failed Connect to 127.0.0.1:8000 [/127.0.0.1] failed: Connection refused (Connection refused)
+     * 
+ */ + @Test + public void http404() { + + final String cubeServerIp = "127.0.0.1"; + final int cubeJsServerPort = 8000; + + IPUtils.disabledIpPrivateSubnet(true); + + final CubeJSQuery cubeJSQuery = new Builder() + .dimensions("Events.experiment", "Events.variant") + .build(); + + final CubeJSClient cubeClient = new CubeJSClient(String.format("http://%s:%s", cubeServerIp, cubeJsServerPort)); + final CubeJSResultSet cubeJSResultSet = cubeClient.send(cubeJSQuery); + + assertEquals(0, cubeJSResultSet.size()); + } + + /** + * Method to test: {@link CubeJSClient#send(CubeJSQuery)} + * When: Send a request with a Null {@link CubeJSQuery} + * Should: throw {@link IllegalArgumentException} + */ + @Test + public void sendWrongQuery() { + + final String cubeServerIp = "127.0.0.1"; + final int cubeJsServerPort = 6000; + + final MockHttpServer mockhttpServer = new MockHttpServer(cubeServerIp, cubeJsServerPort); + + try { + IPUtils.disabledIpPrivateSubnet(true); + + final MockHttpServerContext mockHttpServerContext = new MockHttpServerContext.Builder() + .uri("/cubejs-api/v1/load") + .responseStatus(HttpURLConnection.HTTP_OK) + .build(); + + mockhttpServer.addContext(mockHttpServerContext); + mockhttpServer.start(); + + final CubeJSClient cubeClient = new CubeJSClient(String.format("http://%s:%s", cubeServerIp, cubeJsServerPort)); + + try { + cubeClient.send(null); + throw new AssertionError("NullPointerException Expected"); + } catch (IllegalArgumentException e) { + mockhttpServer.mustNeverCalled("/cubejs-api/v1/load"); + } + } finally { + IPUtils.disabledIpPrivateSubnet(false); + mockhttpServer.stop(); + } + } + +} diff --git a/dotCMS/src/test/java/com/dotcms/cube/CubeJSTest.java b/dotCMS/src/test/java/com/dotcms/cube/CubeJSTest.java new file mode 100644 index 000000000000..ef90a142e2bf --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/cube/CubeJSTest.java @@ -0,0 +1,573 @@ +package com.dotcms.cube; + +import static org.junit.Assert.assertEquals; + +import com.dotcms.cube.CubeJSQuery.Builder; +import com.dotcms.cube.filters.Filter.Order; +import com.dotcms.cube.filters.LogicalFilter; + +import com.dotcms.cube.filters.SimpleFilter; +import org.junit.Test; + +public class CubeJSTest { + + /** + * Method to test: {@link Builder#build()} + * When: Create a CubeJS Query with dimentions equals to: Events.experiment and Events.variant + * Should: Create the follow query: + * + * { + * "dimensions": [ + * "Events.experiment", "Events.variant" + * ] + * } + * + */ + @Test + public void dimensions(){ + final CubeJSQuery cubeJSQuery = new Builder() + .dimensions("Events.experiment", "Events.variant") + .build(); + + final String queryExpected = "{" + + "\"dimensions\":[" + + "\"Events.experiment\"," + + "\"Events.variant\"" + + "]" + + "}"; + + assertEquals(queryExpected, cubeJSQuery.toString()); + } + + /** + * Method to test: {@link Builder#build()} + * When: Create a CubeJS Query with measures equals to: Events.count + * Should: Create the follow query: + * + * { + * "measures": [ + * "Events.count" + * ] + * } + * + */ + @Test + public void measures(){ + final CubeJSQuery cubeJSQuery = new Builder() + .measures("Events.count") + .build(); + + final String queryExpected = "{" + + "\"measures\":[" + + "\"Events.count\"" + + "]" + + "}"; + + assertEquals(queryExpected, cubeJSQuery.toString()); + } + + /** + * Method to test: {@link Builder#build()} + * When: Create a CubeJS Query with dimentions equals to: Events.experiment and Events.variant + * and measures equals to Events.count + * Should: Create the follow query: + * + * { + * "dimensions": [ + * "Events.experiment", "Events.variant" + * ], + * "measures": [ + * "Events.count" + * ] + * } + * + */ + @Test + public void dimensionsAndMeasures(){ + final CubeJSQuery cubeJSQuery = new Builder() + .measures("Events.count") + .dimensions("Events.experiment", "Events.variant") + .build(); + + final String queryExpected = "{" + + "\"measures\":[" + + "\"Events.count\"" + + "]," + + "\"dimensions\":[" + + "\"Events.experiment\"," + + "\"Events.variant\"" + + "]" + + "}"; + + assertEquals(queryExpected, cubeJSQuery.toString()); + } + + /** + * Method to test: {@link Builder#build()} + * When: Create a CubeJS Query with dimentions equals to: Events.experiment + * and filters equals to Events.variant EQUALS TO "B" + * Should: Create the follow query: + * + * { + * "dimensions": [ + * "Events.experiment" + * ], + * filters: [ + * { + * member: "Events.variant", + * operator: "equals", + * values: ["B"] + * } + * ] + * } + * + */ + @Test + public void dimensionsAndFilter(){ + final CubeJSQuery cubeJSQuery = new Builder() + .dimensions("Events.experiment") + .filter("Events.variant", SimpleFilter.Operator.EQUALS, "B") + .build(); + + final String queryExpected = "{" + + "\"filters\":[" + + "{" + + "\"values\":[\"B\"]," + + "\"member\":\"Events.variant\"," + + "\"operator\":\"equals\"" + + "}" + + "]," + + "\"dimensions\":[" + + "\"Events.experiment\"" + + "]" + + "}"; + + assertEquals(queryExpected, cubeJSQuery.toString()); + } + + /** + * Method to test: {@link Builder#build()} + * When: Create a CubeJS Query with measures equals to: Events.count + * and filters equals to Events.variant EQUALS TO "B" + * Should: Create the follow query: + * + * { + * "measures": [ + * "Events.count" + * ], + * filters: [ + * { + * member: "Events.variant", + * operator: "equals", + * values: ["B"] + * } + * ] + * } + * + */ + @Test + public void measuresAndFilter(){ + final CubeJSQuery cubeJSQuery = new Builder() + .measures("Events.count") + .filter("Events.variant", SimpleFilter.Operator.EQUALS, "B") + .build(); + + final String queryExpected = "{" + + "\"measures\":[" + + "\"Events.count\"" + + "]," + + "\"filters\":[" + + "{" + + "\"values\":[\"B\"]," + + "\"member\":\"Events.variant\"," + + "\"operator\":\"equals\"" + + "}" + + "]" + + "}"; + + assertEquals(queryExpected, cubeJSQuery.toString()); + } + + /** + * Method to test: {@link Builder#build()} + * When: Create a CubeJS Query with dimentions equals to: Events.experiment + * and filters equals to Events.variant EQUALS TO "B" + * Should: Create the follow query: + * + * { + * "dimensions": [ + * "Events.experiment" + * ], + * { + * "measures": [ + * "Events.count" + * ], + * filters: [ + * { + * member: "Events.variant", + * operator: "equals", + * values: ["B"] + * } + * ] + * } + * + */ + @Test + public void dimensionAndMeasuresAndFilter(){ + final CubeJSQuery cubeJSQuery = new Builder() + .dimensions("Events.experiment") + .measures("Events.count") + .filter("Events.variant", SimpleFilter.Operator.EQUALS, "B") + .build(); + + final String queryExpected = "{" + + "\"measures\":[" + + "\"Events.count\"" + + "]," + + "\"filters\":[" + + "{" + + "\"values\":[\"B\"]," + + "\"member\":\"Events.variant\"," + + "\"operator\":\"equals\"" + + "}" + + "]," + + "\"dimensions\":[" + + "\"Events.experiment\"" + + "]" + + "}"; + + assertEquals(queryExpected, cubeJSQuery.toString()); + } + + /** + * Method to test: {@link Builder#build()} + * When: Create a CubeJS Query with filters but without dimensions and measures + * Should: Throw a {@link IllegalStateException} + */ + @Test(expected = IllegalStateException.class) + public void filterWithoutDimensions(){ + final CubeJSQuery cubeJSQuery = new Builder() + .filter("Events.variant", SimpleFilter.Operator.EQUALS, "B") + .build(); + } + + /** + * Method to test: {@link Builder#build()} + * When: Create a CubeJS Query with dimentions equals to: Events.experiment and Events.variant + * and two filters: + * - Events.variant EQUALS TO "B" + * - Events.experiment EQUALS TO "B" + * + * Should: Create the follow query: + * + * { + * "dimensions": [ + * "Events.experiment" + * ], + * filters: [ + * { + * member: "Events.variant", + * operator: "equals", + * values: ["B"] + * }, + * { + * member: "Events.experiment", + * operator: "equals", + * values: ["B"] + * } + * ] + * } + * + */ + @Test + public void dimensionAndTwoFilters(){ + final CubeJSQuery cubeJSQuery = new Builder() + .dimensions("Events.experiment", "Events.variant") + .filter("Events.variant", SimpleFilter.Operator.EQUALS, "B") + .filter("Events.experiment", SimpleFilter.Operator.EQUALS, "B") + .build(); + + final String queryExpected = "{" + + "\"filters\":[" + + "{" + + "\"values\":[\"B\"]," + + "\"member\":\"Events.variant\"," + + "\"operator\":\"equals\"" + + "}," + + "{" + + "\"values\":[\"B\"]," + + "\"member\":\"Events.experiment\"," + + "\"operator\":\"equals\"" + + "}" + + "]," + + "\"dimensions\":[" + + "\"Events.experiment\"," + + "\"Events.variant\"" + + "]" + + "}"; + + assertEquals(queryExpected, cubeJSQuery.toString()); + } + + /** + * Method to test: {@link Builder#build()} + * When: Create a CubeJS Query with dimentions equals to: Events.experiment and Events.variant + * and a AND filter with: + * - Events.variant EQUALS TO "B" + * - Events.experiment EQUALS TO "B" + * + * Should: Create the follow query: + * + * { + * "dimensions": [ + * "Events.experiment" + * ], + * filters: [ + * { + * "and": [ + * { + * member: "Events.variant", + * operator: "equals", + * values: ["B"] + * }, + * { + * member: "Events.experiment", + * operator: "equals", + * values: ["B"] + * } + * ] + * } + * ] + * } + * + */ + @Test + public void andFilter(){ + final CubeJSQuery cubeJSQuery = new Builder() + .dimensions("Events.experiment", "Events.variant") + .filter( + LogicalFilter.Builder.and() + .add("Events.variant", SimpleFilter.Operator.EQUALS, "B") + .add("Events.experiment", SimpleFilter.Operator.EQUALS, "B") + .build() + ) + .build(); + + final String queryExpected = "{" + + "\"filters\":[" + + "{" + + "\"and\":[" + + "{" + + "\"values\":[\"B\"]," + + "\"member\":\"Events.variant\"," + + "\"operator\":\"equals\"" + + "}," + + "{" + + "\"values\":[\"B\"]," + + "\"member\":\"Events.experiment\"," + + "\"operator\":\"equals\"" + + "}" + + "]" + + "}" + + "]," + + "\"dimensions\":[\"Events.experiment\",\"Events.variant\"]" + + "}"; + + assertEquals(queryExpected, cubeJSQuery.toString()); + } + + /** + * Method to test: {@link Builder#build()} + * When: Create a CubeJS Query with dimentions equals to: Events.experiment and Events.variant + * and a OR filter with: + * - Events.variant EQUALS TO "B" + * - Events.experiment EQUALS TO "B" + * + * Should: Create the follow query: + * + * { + * "dimensions": [ + * "Events.experiment" + * ], + * filters: [ + * { + * "or": [ + * { + * member: "Events.variant", + * operator: "equals", + * values: ["B"] + * }, + * { + * member: "Events.experiment", + * operator: "equals", + * values: ["B"] + * } + * ] + * } + * ] + * } + * + */ + @Test + public void orFilter(){ + final CubeJSQuery cubeJSQuery = new Builder() + .dimensions("Events.experiment", "Events.variant") + .filter( + LogicalFilter.Builder.or() + .add("Events.variant", SimpleFilter.Operator.EQUALS, "B") + .add("Events.experiment", SimpleFilter.Operator.EQUALS, "B") + .build() + ) + .build(); + + final String queryExpected = "{" + + "\"filters\":[" + + "{" + + "\"or\":[" + + "{" + + "\"values\":[\"B\"]," + + "\"member\":\"Events.variant\"," + + "\"operator\":\"equals\"" + + "}," + + "{" + + "\"values\":[\"B\"]," + + "\"member\":\"Events.experiment\"," + + "\"operator\":\"equals\"" + + "}" + + "]" + + "}" + + "]," + + "\"dimensions\":[\"Events.experiment\",\"Events.variant\"]" + + "}"; + + assertEquals(queryExpected, cubeJSQuery.toString()); + } + + /** + * Method to test: {@link Builder#build()} + * When: Create a CubeJS Query with dimentions equals to: Events.experiment and Events.variant + * and a OR filter with: + * (Events.variant EQUALS TO "B" AND Events.experiment EQUALS TO "A") OR Events.variant EQUALS TO "B" + * + * Should: Create the follow query: + * + * { + * "dimensions": [ + * "Events.experiment" + * ], + * filters: [ + * { + * "or": [ + * { + * "and": [ + * { + * member: "Events.variant", + * operator: "equals", + * values: ["B"] + * }, + * { + * member: "Events.experiment", + * operator: "equals", + * values: ["A"] + * } + * ] + * }, + * { + * member: "Events.variant", + * operator: "equals", + * values: ["B"] + * } + * ] + * } + * ] + * } + * + */ + @Test + public void andOrFilter(){ + final CubeJSQuery cubeJSQuery = new Builder() + .dimensions("Events.experiment", "Events.variant") + .filter( + LogicalFilter.Builder.or() + .add(LogicalFilter.Builder.and() + .add("Events.variant", SimpleFilter.Operator.EQUALS, "B") + .add("Events.experiment", SimpleFilter.Operator.EQUALS, "A") + .build() + ) + .add("Events.variant", SimpleFilter.Operator.EQUALS, "B") + .build() + ) + .build(); + + final String queryExpected = "{" + + "\"filters\":[" + + "{" + + "\"or\":[" + + "{" + + "\"and\":[" + + "{" + + "\"values\":[\"B\"]," + + "\"member\":\"Events.variant\"," + + "\"operator\":\"equals\"" + + "}," + + "{" + + "\"values\":[\"A\"]," + + "\"member\":\"Events.experiment\"," + + "\"operator\":\"equals\"" + + "}" + + "]" + + "}," + + "{" + + "\"values\":[\"B\"]," + + "\"member\":\"Events.variant\"," + + "\"operator\":\"equals\"" + + "}" + + "]" + + "}" + + "]," + + "\"dimensions\":[\"Events.experiment\",\"Events.variant\"]" + + "}"; + + assertEquals(queryExpected, cubeJSQuery.toString()); + } + + /** + * Method to test: {@link Builder#build()} + * When: Create a CubeJS Query with dimentions equals to: Events.experiment, Events.variant and Events.utcTime + * and order by Events.utcTime + * Should: Create the follow query: + * + * { + * "dimensions": [ + * "Events.experiment", "Events.variant", "Events.utcTime" + * ], + * "order": { + * "Events.experiment": "asc", + * "Events.utcTime": "desc" + * } + * } + * + */ + @Test + public void order(){ + final CubeJSQuery cubeJSQuery = new Builder() + .dimensions("Events.experiment", "Events.variant", "Events.utcTime") + .order("Events.experiment", Order.ASC) + .order("Events.utcTime", Order.DESC) + .build(); + + final String queryExpected = "{" + + "\"dimensions\":[" + + "\"Events.experiment\"," + + "\"Events.variant\"," + + "\"Events.utcTime\"" + + "]," + + "\"order\":{" + + "\"Events.experiment\":\"asc\"," + + "\"Events.utcTime\":\"desc\"" + + "}" + + "}"; + + assertEquals(queryExpected, cubeJSQuery.toString()); + } +}