Skip to content

Commit

Permalink
feat(content analytics) fixes #30521 : Allow users to pass down simpl…
Browse files Browse the repository at this point in the history
…e Strings to query for Content Analytics data
  • Loading branch information
jcastro-dotcms committed Nov 29, 2024
1 parent f26b313 commit 9a4c159
Show file tree
Hide file tree
Showing 3 changed files with 369 additions and 116 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,40 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

import javax.annotation.Nonnull;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import static com.liferay.util.StringPool.COLON;
import static com.liferay.util.StringPool.COMMA;

/**
* This class represents the parameters of a Content Analytics Query abstracting the complexity
* of the underlying JSON format. The simplified REST Endpoint and the Content Analytics ViewTool
* use this class so that parameters can be entered in a more user-friendly way.
*
* @author Jose Castro
* @since Nov 28th, 2024
*/
@JsonDeserialize(builder = ContentAnalyticsQuery.Builder.class)
public class ContentAnalyticsQuery implements Serializable {

public static final String MEASURES_ATTR = "measures";
public static final String DIMENSIONS_ATTR = "dimensions";
public static final String TIME_DIMENSIONS_ATTR = "timeDimensions";
public static final String FILTERS_ATTR = "filters";
public static final String ORDER_ATTR = "order";
public static final String LIMIT_ATTR = "limit";
public static final String OFFSET_ATTR = "offset";
public static final String GRANULARITY_ATTR = "granularity";
public static final String DATE_RANGE_ATTR = "dateRange";
public static final String MEMBER_ATTR = "member";
public static final String OPERATOR_ATTR = "operator";
public static final String VALUES_ATTR = "values";

@JsonProperty()
private final Set<String> measures;
@JsonProperty()
Expand All @@ -35,7 +53,7 @@ public class ContentAnalyticsQuery implements Serializable {
@JsonProperty()
private final int offset;

private static final String SEPARATOR = ":";
private static final String SEPARATOR = COLON;

private ContentAnalyticsQuery(final Builder builder) {
this.measures = builder.measures;
Expand Down Expand Up @@ -92,6 +110,10 @@ public String toString() {
'}';
}

/**
* This builder creates the appropriate data structures that match the JSON format of the final
* CubeJS query.
*/
public static class Builder {

private Set<String> measures;
Expand All @@ -102,32 +124,71 @@ public static class Builder {
private int limit = 1000;
private int offset = 0;

public Builder measures(@Nonnull final String measures) {
/**
* The measures parameter contains a set of measures and each measure is an aggregation over
* a certain column in your ClickHouse database table.
*
* @param measures A string with the measures separated by a space.
*
* @return The builder instance.
*/
public Builder measures(final String measures) {
this.measures = Set.of(measures.split("\\s+"));
return this;
}

public Builder dimensions(@Nonnull final String dimensions) {
/**
* The dimensions property contains a set of dimensions. You can think about a dimension as
* an attribute related to a measure, e.g. the measure user_count can have dimensions like
* country, age, occupation, etc.
*
* @param dimensions A string with the dimensions separated by a space.
*
* @return The builder instance.
*/
public Builder dimensions(final String dimensions) {
this.dimensions = Set.of(dimensions.split("\\s+"));
return this;
}

/**
* Time dimensions provide a convenient way to specify a time dimension with a filter. It is
* an array of objects in timeDimension format. If no date range is provided, the default
* value will be "Last week".
*
* @param timeDimensions A string with the time dimensions separated by a colon.
*
* @return The builder instance.
*/
public Builder timeDimensions(final String timeDimensions) {
if (UtilMethods.isNotSet(timeDimensions)) {
return this;
}
final String[] timeParams = timeDimensions.split(SEPARATOR);
final Map<String, String> timeDimensionsData = new HashMap<>();
timeDimensionsData.put("dimension", timeParams[0]);
if (timeParams.length > 1) {
timeDimensionsData.put("dateRange", timeParams[1]);
timeDimensionsData.put(DIMENSIONS_ATTR, timeParams[0]);
if (timeParams.length > 2) {
timeDimensionsData.put(GRANULARITY_ATTR, timeParams[1]);
timeDimensionsData.put(DATE_RANGE_ATTR, timeParams[2]);
} else if (timeParams.length > 1) {
timeDimensionsData.put(DATE_RANGE_ATTR, timeParams[1]);
} else {
timeDimensionsData.put("dateRange", "Last week");
timeDimensionsData.put(DATE_RANGE_ATTR, "Last week");
}
this.timeDimensions.add(timeDimensionsData);
return this;
}

/**
* Filters are applied differently to dimensions and measures. When you filter on a
* dimension, you are restricting the raw data before any calculations are made. When you
* filter on a measure, you are restricting the results after the measure has been
* calculated. They are composed of: member, operator, and values.
*
* @param filters A string with the filters separated by a colon.
*
* @return The builder instance.
*/
public Builder filters(final String filters) {
if (UtilMethods.isNotSet(filters)) {
return this;
Expand All @@ -136,15 +197,25 @@ public Builder filters(final String filters) {
for (final String filter : filterArr) {
final String[] filterParams = filter.split("\\s+");
final Map<String, Object> filterDataMap = new HashMap<>();
filterDataMap.put("member", filterParams[0]);
filterDataMap.put("operator", filterParams[1]);
final String[] filterValues = filterParams[2].split(",");
filterDataMap.put("values", filterValues);
filterDataMap.put(MEMBER_ATTR, filterParams[0]);
filterDataMap.put(OPERATOR_ATTR, filterParams[1]);
final String[] filterValues = filterParams[2].split(COMMA);
filterDataMap.put(VALUES_ATTR, filterValues);
this.filters.add(filterDataMap);
}
return this;
}

/**
* This is an object where the keys are measures or dimensions to order by and their
* corresponding values are either asc or desc. The order of the fields to order on is based
* on the order of the keys in the object. If not provided, default ordering is applied. If
* an empty object ([]) is provided, no ordering is applied.
*
* @param order A string with the order separated by a colon.
*
* @return The builder instance.
*/
public Builder order(final String order) {
if (UtilMethods.isNotSet(order)) {
return this;
Expand All @@ -157,21 +228,39 @@ public Builder order(final String order) {
return this;
}

/**
* A row limit for your query.
*
* @param limit The number of rows to limit the query. The default value is 1000.
*
* @return The builder instance.
*/
public Builder limit(final int limit) {
this.limit = limit;
return this;
}

/**
* The number of initial rows to be skipped for your query. The default value is 0.
*
* @param offset The number of rows to skip.
*
* @return The builder instance.
*/
public Builder offset(final int offset) {
this.offset = offset;
return this;
}

/**
* This method builds the ContentAnalyticsQuery object.
*
* @return The ContentAnalyticsQuery object.
*/
public ContentAnalyticsQuery build() {
return new ContentAnalyticsQuery(this);
}

}

}

Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@
import com.dotcms.rest.ResponseEntityStringView;
import com.dotcms.rest.WebResource;
import com.dotcms.rest.annotation.NoCache;
import com.dotcms.util.DotPreconditions;
import com.dotcms.util.JsonUtil;
import com.dotmarketing.beans.Host;
import com.dotmarketing.business.web.WebAPILocator;
import com.dotmarketing.exception.DotSecurityException;
import com.dotmarketing.util.Config;
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.UUIDUtil;
import com.dotmarketing.util.UtilMethods;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.google.common.annotations.VisibleForTesting;
import com.liferay.portal.model.User;
Expand Down Expand Up @@ -55,6 +55,16 @@
import java.util.function.Supplier;
import java.util.stream.Collectors;

import static com.dotcms.analytics.content.ContentAnalyticsQuery.DIMENSIONS_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.FILTERS_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.LIMIT_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.MEASURES_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.OFFSET_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.ORDER_ATTR;
import static com.dotcms.analytics.content.ContentAnalyticsQuery.TIME_DIMENSIONS_ATTR;
import static com.dotcms.util.DotPreconditions.checkArgument;
import static com.dotcms.util.DotPreconditions.checkNotNull;

/**
* Resource class that exposes endpoints to query content analytics data.
* This REST Endpoint exposes different operations to query Content Analytics data. Content
Expand Down Expand Up @@ -157,7 +167,7 @@ public ReportResponseEntityView query(@Context final HttpServletRequest request,
.init();

final User user = initDataObject.getUser();
DotPreconditions.checkNotNull(queryForm, IllegalArgumentException.class, "The 'query' JSON data cannot be null");
checkNotNull(queryForm, IllegalArgumentException.class, "The 'query' JSON data cannot be null");
Logger.debug(this, () -> "Querying content analytics data with the form: " + queryForm);
final ReportResponse reportResponse =
this.contentAnalyticsAPI.runReport(queryForm.getQuery(), user);
Expand Down Expand Up @@ -195,7 +205,7 @@ public ReportResponseEntityView query(@Context final HttpServletRequest request,
)
),
@ApiResponse(responseCode = "400", description = "Bad Request"),
@ApiResponse(responseCode = "403", description = "Forbidden"),
@ApiResponse(responseCode = "401", description = "Unauthorized"),
@ApiResponse(responseCode = "500", description = "Internal Server Error")
}
)
Expand All @@ -216,7 +226,7 @@ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest re
.init();

final User user = initDataObject.getUser();
DotPreconditions.checkNotNull(cubeJsQueryJson, IllegalArgumentException.class, "The 'query' JSON data cannot be null");
checkNotNull(cubeJsQueryJson, IllegalArgumentException.class, "The 'query' JSON data cannot be null");
Logger.debug(this, ()->"Querying content analytics data with the cube query json: " + cubeJsQueryJson);
final ReportResponse reportResponse =
this.contentAnalyticsAPI.runRawReport(cubeJsQueryJson, user);
Expand All @@ -225,37 +235,47 @@ public ReportResponseEntityView queryCubeJs(@Context final HttpServletRequest re


/**
* Query Content Analytics data.
* Returns information of specific dotCMS objects whose health and engagement data is tracked,
* using Path Parameters instead of a CubeJS JSON query. This helps abstract the complexity of
* the underlying JSON format for users that need an easier way to query for specific data.
*
* @param request the HTTP request.
* @param response the HTTP response.
* @param cubeJsQueryJson the query form.
* @return the report response entity view.
* @param request The current instance of the {@link HttpServletRequest} object.
* @param response The current instance of the {@link HttpServletResponse} object.
* @param params The query parameters provided in the URL path.
*
* @return The request information from the Content Analytics server.
*/
@Operation(
operationId = "postContentAnalyticsQuery",
summary = "Retrieve Content Analytics data",
description = "Returns information of specific dotCMS objects whose health and " +
"engagement data is tracked, using a CubeJS JSON query.",
"engagement data is tracked, using Path Parameters instead of a CubeJS JSON " +
"query. This helps abstract the complexity of the underlying JSON format for " +
"users that need an easier way to query for specific data.",
tags = {"Content Analytics"},
responses = {
@ApiResponse(responseCode = "200", description = "Content Analytics data " +
"being queried",
content = @Content(mediaType = "application/json",
examples = {
@ExampleObject(
value = "{\n" +
" \"dimensions\": [\n" +
" \"Events.experiment\",\n" +
" \"Events.variant\"\n" +
" ]\n" +
"}"
value = "http://localhost:8080/api/v1" +
"/analytics/content/query/measures" +
"/request.count request" +
".totalSessions/dimensions/request" +
".host request.whatAmI request" +
".url/timeDimensions/request" +
".createdAt:day:This " +
"month/filters/request.totalRequest " +
"gt 0:request.whatAmI contains PAGE," +
"FILE/order/request.count asc:request" +
".createdAt asc/limit/5/offset/0"
)
}
)
),
@ApiResponse(responseCode = "400", description = "Bad Request"),
@ApiResponse(responseCode = "403", description = "Forbidden"),
@ApiResponse(responseCode = "401", description = "Unauthorized"),
@ApiResponse(responseCode = "500", description = "Internal Server Error")
}
)
Expand All @@ -276,26 +296,29 @@ public ReportResponseEntityView query(@Context final HttpServletRequest request,
.init();
final User user = initDataObject.getUser();
final Map<String, String> paramsMap = initDataObject.getParamsMap();
Logger.debug(this, () -> "Querying content analytics data with the following parameters: " + paramsMap);
checkArgument(!(UtilMethods.isNotSet(paramsMap.get(MEASURES_ATTR))
&& UtilMethods.isNotSet(paramsMap.get(DIMENSIONS_ATTR))
&& UtilMethods.isNotSet(paramsMap.get(TIME_DIMENSIONS_ATTR.toLowerCase()))),
IllegalArgumentException.class, "Query should contain either measures, dimensions or timeDimensions with granularities in order to be valid");
final ContentAnalyticsQuery.Builder builder = new ContentAnalyticsQuery.Builder()
.dimensions(paramsMap.get("dimensions"))
.measures(paramsMap.get("measures"))
.filters(paramsMap.get("filters"))
.order(paramsMap.get("order"))
.timeDimensions(paramsMap.get("timedimensions"));
if (paramsMap.containsKey("limit")) {
builder.limit(Integer.parseInt(paramsMap.get("limit")));
.measures(paramsMap.get(MEASURES_ATTR))
.dimensions(paramsMap.get(DIMENSIONS_ATTR))
.timeDimensions(paramsMap.get(TIME_DIMENSIONS_ATTR.toLowerCase()))
.filters(paramsMap.get(FILTERS_ATTR))
.order(paramsMap.get(ORDER_ATTR));
if (paramsMap.containsKey(LIMIT_ATTR)) {
builder.limit(Integer.parseInt(paramsMap.get(LIMIT_ATTR)));
}
if (paramsMap.containsKey("offset")) {
builder.offset(Integer.parseInt(paramsMap.get("offset")));
if (paramsMap.containsKey(OFFSET_ATTR)) {
builder.offset(Integer.parseInt(paramsMap.get(OFFSET_ATTR)));
}
final ContentAnalyticsQuery contentAnalyticsQuery = builder.build();
final String cubeJsQuery = JsonUtil.getJsonStringFromObject(contentAnalyticsQuery);

Logger.debug(this, () -> "Querying content analytics data with parameters: " + paramsMap);
final ReportResponse reportResponse = this.contentAnalyticsAPI.runRawReport(cubeJsQuery,
user);
return new ReportResponseEntityView(reportResponse.getResults().stream()
.map(ResultSetItem::getAll).collect(Collectors.toList()));
Logger.debug(this, ()-> "Generated query: " + cubeJsQuery);
final ReportResponse reportResponse = this.contentAnalyticsAPI.runRawReport(cubeJsQuery, user);
return new ReportResponseEntityView(reportResponse.getResults()
.stream().map(ResultSetItem::getAll).collect(Collectors.toList()));
}

/**
Expand Down Expand Up @@ -336,7 +359,7 @@ public ResponseEntityStringView fireUserCustomEvent(@Context final HttpServletRe
@Context final HttpServletResponse response,
final Map<String, Serializable> userEventPayload) throws DotSecurityException {

DotPreconditions.checkNotNull(userEventPayload, IllegalArgumentException.class, "The 'userEventPayload' JSON cannot be null");
checkNotNull(userEventPayload, IllegalArgumentException.class, "The 'userEventPayload' JSON cannot be null");
if (userEventPayload.containsKey(Collector.EVENT_SOURCE)) {
throw new IllegalArgumentException("The 'event_source' field is reserved and cannot be used");
}
Expand Down
Loading

0 comments on commit 9a4c159

Please sign in to comment.