Add REST endpoint for headless Google Analytics queries#3
Add REST endpoint for headless Google Analytics queries#3
Conversation
Adds /api/v1/googleanalytics/query endpoint for headless/JavaScript clients.
## Changes
- New REST resource at GoogleAnalyticsResource.java
- POST /api/v1/googleanalytics/query endpoint
- Accepts JSON request with propertyId, dates, metrics, dimensions, filters, sort, maxResults
- Returns JSON-friendly response format (rows, dimensions, metrics, metadata)
- Requires backend user authentication
- Registers REST resource in Activator
## Example Request
```json
{
"propertyId": "123456789",
"startDate": "2026-02-09",
"endDate": "2026-02-16",
"metrics": ["sessions", "activeUsers"],
"dimensions": ["date"],
"maxResults": 100
}
```
Version bumped to 0.5.0 (minor version for new feature).
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Improved REST API response format from positional arrays to self-documenting
named objects for better developer experience.
**Before:**
```json
{
"rows": [
{
"dimensions": ["2026-02-09", "/products"],
"metrics": ["150", "120"]
}
]
}
```
**After:**
```json
{
"dimensions": ["date", "pagePath"],
"metrics": ["sessions", "activeUsers"],
"rows": [
{
"date": "2026-02-09",
"pagePath": "/products",
"sessions": "150",
"activeUsers": "120"
}
]
}
```
**Changes:**
- Added dimension/metric name arrays to response metadata
- Flattened row structure with named fields instead of positional arrays
- Updated README with REST API usage examples
- Added dimensions & metrics explainer section
**Breaking change:** Response format changed, but acceptable since endpoint
not yet released.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This pull request adds a REST API endpoint for querying Google Analytics 4 data, enabling headless and API-first use cases beyond the existing Velocity template-based approach. The endpoint provides a self-documenting JSON response format with named fields rather than positional arrays.
Changes:
- New POST endpoint at
/api/v1/googleanalytics/queryfor programmatic GA4 data queries with flattened response format - Added JAX-RS dependency and OSGi bundle service registration
- Documentation updates with REST API usage examples and dimensions/metrics explainer
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 14 comments.
| File | Description |
|---|---|
| src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java | New REST resource implementing the /query endpoint with authentication, request validation, and response transformation |
| src/main/java/com/dotcms/google/analytics/osgi/Activator.java | Added REST resource registration via publishBundleServices call |
| build.gradle | Added javax.ws.rs-api dependency and bumped version to 0.5.0 |
| README.md | Added REST API usage documentation with curl examples and dimensions/metrics educational content |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Dimensions are categorical attributes that describe your data—they answer "what are we breaking this down by?" | ||
|
|
||
| Common dimensions: | ||
| - `date` - When the activity happened (e.g., "20260209") |
There was a problem hiding this comment.
Documentation mentions future date format. Line 150 shows an example date "20260209" which is in the future. While this is consistent with the API examples, consider clarifying that users should replace these with actual historical dates when testing, as Google Analytics typically only contains data for past dates.
| - `date` - When the activity happened (e.g., "20260209") | |
| - `date` - When the activity happened (e.g., "20240209" — use a past date that has data in your GA property when testing) |
| @POST | ||
| @Path("/query") | ||
| @Produces(MediaType.APPLICATION_JSON) |
There was a problem hiding this comment.
| } | ||
|
|
||
| // Get analytics app configuration for current site | ||
| final String siteId = request.getServerName(); // Or extract from request |
There was a problem hiding this comment.
Incorrect site/host identification method. Using request.getServerName() returns the hostname from the HTTP request (e.g., "localhost" or "example.com"), which is not the proper way to identify the dotCMS site. According to the pattern used in AnalyticsViewTool.java (lines 61-62), the correct approach is to use WebAPILocator.getHostWebAPI().getHost(request).getIdentifier() to get the site identifier. This is critical for multi-site setups where the same hostname might serve different content.
| } catch (Exception e) { | ||
| Logger.error(this, "Error querying Google Analytics", e); | ||
| return Response.status(Response.Status.INTERNAL_SERVER_ERROR) | ||
| .entity(Map.of("error", e.getMessage())) |
There was a problem hiding this comment.
Potential information disclosure in error handling. The error message returned in line 206 exposes the raw exception message which could leak sensitive information such as file paths, internal configuration details, or stack traces. Consider returning a generic error message to the client and logging the detailed error server-side for debugging purposes.
| .entity(Map.of("error", e.getMessage())) | |
| .entity(Map.of("error", "Error querying Google Analytics")) |
| "startDate": "2026-02-09", | ||
| "endDate": "2026-02-16", |
There was a problem hiding this comment.
Documentation contains future dates. The example dates "2026-02-09" and "2026-02-16" are in the future. While this may be intentional for the example, it could be confusing for users trying to test the API since Google Analytics typically only has data for past dates. Consider using relative date references (e.g., "7daysAgo", "today") or clearly indicating that these are example dates that should be replaced with actual historical dates.
| // Set filters | ||
| if (queryRequest.getFilters() != null) { | ||
| if (queryRequest.getFilters().getDimension() != null) { | ||
| for (FilterRequestDTO filter : queryRequest.getFilters().getDimension()) { | ||
| final FilterRequest filterRequest = new FilterRequest( | ||
| filter.getField(), | ||
| filter.getOperator(), | ||
| filter.getValue() | ||
| ); | ||
| analyticsRequest.getDimensionFilterList().add(filterRequest); | ||
| } | ||
| } | ||
|
|
||
| if (queryRequest.getFilters().getMetric() != null) { | ||
| for (FilterRequestDTO filter : queryRequest.getFilters().getMetric()) { | ||
| final FilterRequest filterRequest = new FilterRequest( | ||
| filter.getField(), | ||
| filter.getOperator(), | ||
| filter.getValue() | ||
| ); | ||
| analyticsRequest.getMetricFilterList().add(filterRequest); | ||
| } | ||
| } |
There was a problem hiding this comment.
Missing input validation for filter operators. If an invalid operator value is provided in the filters, it will cause an IllegalArgumentException when Filter.StringFilter.MatchType.valueOf() is called in GoogleAnalyticsService.getFilterExpression() (line 152). This will be caught by the generic exception handler but will result in an unclear error message. Consider validating the operator values against the allowed MatchType enum values (EXACT, BEGINS_WITH, ENDS_WITH, CONTAINS, etc.) and returning a clear BAD_REQUEST response with a list of valid operators.
| @Context final HttpServletResponse response, | ||
| final GoogleAnalyticsQueryRequest queryRequest) { | ||
|
|
||
| try { |
There was a problem hiding this comment.
Missing null check for queryRequest parameter. The method should validate that queryRequest is not null before accessing its properties, otherwise a NullPointerException could occur. Add validation at the start of the method to return a BAD_REQUEST response if queryRequest is null.
| try { | |
| try { | |
| // Validate request body | |
| if (queryRequest == null) { | |
| return Response.status(Response.Status.BAD_REQUEST) | |
| .entity(Map.of("error", "queryRequest is required")) | |
| .build(); | |
| } |
| if (queryRequest.getStartDate() != null) { | ||
| analyticsRequest.setStartDate(queryRequest.getStartDate()); | ||
| } | ||
| if (queryRequest.getEndDate() != null) { | ||
| analyticsRequest.setEndDate(queryRequest.getEndDate()); | ||
| } |
There was a problem hiding this comment.
Missing input validation for date formats. The startDate and endDate fields are accepted as strings without validation. According to AnalyticsRequest.java (line 16), dates should follow the "YYYY-MM-dd" format. Invalid date formats could cause downstream errors in the Google Analytics API. Consider validating the date format and returning a clear error message if the format is invalid.
| if (queryRequest.getMetrics() != null && !queryRequest.getMetrics().isEmpty()) { | ||
| analyticsRequest.setMetrics(String.join(",", queryRequest.getMetrics())); | ||
| } | ||
|
|
||
| // Set dimensions | ||
| if (queryRequest.getDimensions() != null && !queryRequest.getDimensions().isEmpty()) { | ||
| analyticsRequest.setDimensions(String.join(",", queryRequest.getDimensions())); |
There was a problem hiding this comment.
Missing validation for empty lists. The code checks if metrics and dimensions are not null and not empty (lines 107, 112), but if the lists are provided but empty (e.g., "metrics": []), the validation passes but an empty string is passed to AnalyticsRequest.setMetrics() on line 108, which could lead to unexpected behavior. Consider validating that the lists contain at least one element or handle empty lists explicitly.
| if (queryRequest.getMetrics() != null && !queryRequest.getMetrics().isEmpty()) { | |
| analyticsRequest.setMetrics(String.join(",", queryRequest.getMetrics())); | |
| } | |
| // Set dimensions | |
| if (queryRequest.getDimensions() != null && !queryRequest.getDimensions().isEmpty()) { | |
| analyticsRequest.setDimensions(String.join(",", queryRequest.getDimensions())); | |
| if (queryRequest.getMetrics() != null) { | |
| final List<String> metrics = queryRequest.getMetrics() | |
| .stream() | |
| .filter(m -> m != null && !m.trim().isEmpty()) | |
| .collect(Collectors.toList()); | |
| if (!metrics.isEmpty()) { | |
| analyticsRequest.setMetrics(String.join(",", metrics)); | |
| } | |
| } | |
| // Set dimensions | |
| if (queryRequest.getDimensions() != null) { | |
| final List<String> dimensions = queryRequest.getDimensions() | |
| .stream() | |
| .filter(d -> d != null && !d.trim().isEmpty()) | |
| .collect(Collectors.toList()); | |
| if (!dimensions.isEmpty()) { | |
| analyticsRequest.setDimensions(String.join(",", dimensions)); | |
| } |
| if (queryRequest.getMaxResults() != null && queryRequest.getMaxResults() > 0) { | ||
| analyticsRequest.setMaxResults(queryRequest.getMaxResults()); |
There was a problem hiding this comment.
Missing upper bound validation for maxResults. While the code checks that maxResults is greater than 0 (line 147), there's no validation for an upper limit. Google Analytics API typically has limits on the number of results per request (often around 100,000). Allowing arbitrary large values could lead to performance issues or API errors. Consider adding a reasonable upper limit (e.g., 10,000) and returning a BAD_REQUEST error if exceeded.
| if (queryRequest.getMaxResults() != null && queryRequest.getMaxResults() > 0) { | |
| analyticsRequest.setMaxResults(queryRequest.getMaxResults()); | |
| if (queryRequest.getMaxResults() != null) { | |
| final int MAX_RESULTS = 10_000; | |
| final int maxResults = queryRequest.getMaxResults(); | |
| if (maxResults <= 0 || maxResults > MAX_RESULTS) { | |
| return Response.status(Response.Status.BAD_REQUEST) | |
| .entity("Invalid maxResults value. It must be between 1 and " + MAX_RESULTS + ".") | |
| .build(); | |
| } | |
| analyticsRequest.setMaxResults(maxResults); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated 9 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for (int i = 0; i < dimensionNames.size() && i < dimensionValues.size(); i++) { | ||
| rowData.put(dimensionNames.get(i), dimensionValues.get(i).getValue()); | ||
| } | ||
| } |
There was a problem hiding this comment.
The loop uses i < dimensionNames.size() && i < dimensionValues.size() to prevent index out of bounds, but this silently truncates data if there's a mismatch between the requested dimensions and the returned dimension values. If Google Analytics returns fewer dimension values than requested (which could indicate a problem), this would be silently ignored. Consider logging a warning when the sizes don't match, or returning an error to indicate incomplete data.
| { | ||
| "date": "20260209", |
There was a problem hiding this comment.
The example response shows date format as "20260209" (YYYYMMDD without separators) which differs from the input date format "2026-02-09" (YYYY-MM-DD with hyphens). This inconsistency could confuse API consumers. Consider documenting this format transformation clearly, explaining that Google Analytics returns dates in YYYYMMDD format regardless of how they're submitted in the request.
| // Set metrics | ||
| if (queryRequest.getMetrics() != null && !queryRequest.getMetrics().isEmpty()) { | ||
| analyticsRequest.setMetrics(String.join(",", queryRequest.getMetrics())); | ||
| } | ||
|
|
||
| // Set dimensions | ||
| if (queryRequest.getDimensions() != null && !queryRequest.getDimensions().isEmpty()) { | ||
| analyticsRequest.setDimensions(String.join(",", queryRequest.getDimensions())); | ||
| } |
There was a problem hiding this comment.
Missing validation for the list fields (metrics, dimensions, filters). There's no check to ensure these lists are not empty when provided, or that they don't exceed Google Analytics API limits. For example, GA4 API limits requests to 10 metrics and 9 dimensions maximum. Without validation, invalid requests will fail at the Google Analytics API layer with less clear error messages.
| metadata.put("currencyCode", gaResponse.getMetadata().getCurrencyCode()); | ||
| metadata.put("timeZone", gaResponse.getMetadata().getTimeZone()); |
There was a problem hiding this comment.
Potential NullPointerException when gaResponse.getMetadata() returns null. If the Google Analytics API response doesn't include metadata (which could happen in error scenarios or with certain query types), this code will create an empty metadata map but won't check for null before calling getCurrencyCode() and getTimeZone(). While the current code has a null check on line 195, the subsequent calls on lines 196-197 don't guard against the metadata object itself being null. Consider adding proper null checks for the metadata fields themselves.
| metadata.put("currencyCode", gaResponse.getMetadata().getCurrencyCode()); | |
| metadata.put("timeZone", gaResponse.getMetadata().getTimeZone()); | |
| final String currencyCode = gaResponse.getMetadata().getCurrencyCode(); | |
| if (currencyCode != null) { | |
| metadata.put("currencyCode", currencyCode); | |
| } | |
| final String timeZone = gaResponse.getMetadata().getTimeZone(); | |
| if (timeZone != null) { | |
| metadata.put("timeZone", timeZone); | |
| } |
| .entity(Map.of("error", "propertyId is required")) | ||
| .build(); |
There was a problem hiding this comment.
The error response uses Map.of() which creates an immutable map. While this works, it's inconsistent with the rest of the response structure which uses HashMap. Consider using a consistent approach throughout the error responses, or using a proper error response DTO to ensure consistency in error message format across the API.
| public static class GoogleAnalyticsQueryRequest { | ||
| private String propertyId; | ||
| private String startDate; | ||
| private String endDate; | ||
| private List<String> metrics; | ||
| private List<String> dimensions; | ||
| private FiltersDTO filters; | ||
| private String sort; | ||
| private Integer maxResults; | ||
|
|
||
| // Getters and setters | ||
| public String getPropertyId() { return propertyId; } | ||
| public void setPropertyId(String propertyId) { this.propertyId = propertyId; } | ||
|
|
||
| public String getStartDate() { return startDate; } | ||
| public void setStartDate(String startDate) { this.startDate = startDate; } | ||
|
|
||
| public String getEndDate() { return endDate; } | ||
| public void setEndDate(String endDate) { this.endDate = endDate; } | ||
|
|
||
| public List<String> getMetrics() { return metrics; } | ||
| public void setMetrics(List<String> metrics) { this.metrics = metrics; } | ||
|
|
||
| public List<String> getDimensions() { return dimensions; } | ||
| public void setDimensions(List<String> dimensions) { this.dimensions = dimensions; } | ||
|
|
||
| public FiltersDTO getFilters() { return filters; } | ||
| public void setFilters(FiltersDTO filters) { this.filters = filters; } | ||
|
|
||
| public String getSort() { return sort; } | ||
| public void setSort(String sort) { this.sort = sort; } | ||
|
|
||
| public Integer getMaxResults() { return maxResults; } | ||
| public void setMaxResults(Integer maxResults) { this.maxResults = maxResults; } | ||
| } | ||
|
|
||
| /** | ||
| * Filters container DTO. | ||
| */ | ||
| public static class FiltersDTO { | ||
| private List<FilterRequestDTO> dimension; | ||
| private List<FilterRequestDTO> metric; | ||
|
|
||
| public List<FilterRequestDTO> getDimension() { return dimension; } | ||
| public void setDimension(List<FilterRequestDTO> dimension) { this.dimension = dimension; } | ||
|
|
||
| public List<FilterRequestDTO> getMetric() { return metric; } | ||
| public void setMetric(List<FilterRequestDTO> metric) { this.metric = metric; } | ||
| } | ||
|
|
||
| /** | ||
| * Filter DTO. | ||
| */ | ||
| public static class FilterRequestDTO { | ||
| private String field; | ||
| private String value; | ||
| private String operator; | ||
|
|
||
| public String getField() { return field; } | ||
| public void setField(String field) { this.field = field; } | ||
|
|
||
| public String getValue() { return value; } | ||
| public void setValue(String value) { this.value = value; } | ||
|
|
||
| public String getOperator() { return operator; } | ||
| public void setOperator(String operator) { this.operator = operator; } | ||
| } |
There was a problem hiding this comment.
The inner DTOs (GoogleAnalyticsQueryRequest, FiltersDTO, FilterRequestDTO) lack any validation annotations or input sanitization. Fields like propertyId, field names, operator values, and filter values are accepted without validation. This could allow injection attacks or malformed data to reach the Google Analytics API. Consider adding validation annotations (e.g., @NotNull, @pattern, @SiZe) or explicit validation logic to ensure the input conforms to expected formats and constraints.
| final List<Map<String, String>> rows = gaResponse.getRowsList().stream() | ||
| .map(row -> { | ||
| final Map<String, String> rowData = new HashMap<>(); | ||
|
|
||
| // Map dimension values to names | ||
| final List<DimensionValue> dimensionValues = row.getDimensionValuesList(); | ||
| if (dimensionNames != null) { | ||
| for (int i = 0; i < dimensionNames.size() && i < dimensionValues.size(); i++) { | ||
| rowData.put(dimensionNames.get(i), dimensionValues.get(i).getValue()); | ||
| } | ||
| } | ||
|
|
||
| // Map metric values to names | ||
| final List<MetricValue> metricValues = row.getMetricValuesList(); | ||
| if (metricNames != null) { | ||
| for (int i = 0; i < metricNames.size() && i < metricValues.size(); i++) { | ||
| rowData.put(metricNames.get(i), metricValues.get(i).getValue()); | ||
| } | ||
| } | ||
|
|
||
| return rowData; | ||
| }) | ||
| .collect(Collectors.toList()); |
There was a problem hiding this comment.
The flattened response structure returns all metric and dimension values as strings, even though metrics are typically numeric values. This loses type information and could make client-side processing more difficult. Consumers will need to parse strings like "150" back to numbers. Consider preserving the numeric type for metrics in the response, or at minimum document this behavior clearly in the API response.
| @POST | ||
| @Path("/query") | ||
| @Produces(MediaType.APPLICATION_JSON) | ||
| public Response query( |
There was a problem hiding this comment.
Missing @consumes annotation on the POST endpoint. The endpoint expects JSON input (GoogleAnalyticsQueryRequest) but doesn't explicitly declare @consumes(MediaType.APPLICATION_JSON). While JAX-RS implementations may infer this, it's a best practice to explicitly declare the consumed media type to make the API contract clear and avoid potential deserialization issues.
| // Register REST resources | ||
| publishBundleServices(bundleContext); |
There was a problem hiding this comment.
The Activator calls publishBundleServices(bundleContext) to register REST resources, but the GoogleAnalyticsResource class is never explicitly passed to this method or registered with the OSGi container. The GenericBundleActivator.publishBundleServices() method typically expects resources to be registered, but there's no visible registration of the GoogleAnalyticsResource. This could mean the REST endpoint won't be accessible. Verify how REST resources should be registered in dotCMS OSGi plugins - you may need to add the resource to a collection or explicitly register it via the bundle context.
Overview
Adds a REST endpoint at
/api/v1/googleanalytics/queryfor querying Google Analytics 4 data programmatically, enabling headless/API-first use cases beyond Velocity templates.What Changed
New REST Endpoint
Endpoint:
POST /api/v1/googleanalytics/queryRequest:
{ "propertyId": "123456789", "startDate": "2026-02-09", "endDate": "2026-02-16", "metrics": ["sessions", "activeUsers"], "dimensions": ["date", "pagePath"], "filters": { "dimension": [ {"field": "pagePath", "value": "/products", "operator": "CONTAINS"} ] }, "sort": "sessions", "maxResults": 100 }Response (Flattened):
{ "rowCount": 7, "dimensions": ["date", "pagePath"], "metrics": ["sessions", "activeUsers"], "rows": [ { "date": "20260209", "pagePath": "/products", "sessions": "150", "activeUsers": "120" }, { "date": "20260210", "pagePath": "/home", "sessions": "200", "activeUsers": "180" } ], "metadata": { "currencyCode": "USD", "timeZone": "America/New_York" } }Response Format
The endpoint returns a flattened, self-documenting response where:
dimensionsarray lists dimension field namesmetricsarray lists metric field namesThis eliminates the need for consumers to track positional array indices and provides better developer experience.
Documentation Updates
Why This Matters
Current limitation: The
$analyticsviewtool only works in Velocity templates, blocking:This enables:
Testing
Build
./gradlew clean jar # Successfully builds to build/libs/google-analytics-0.5.0.jarManual Test
Expected: JSON response with flattened row structure and named fields.
Breaking Changes
Yes - The response format is a breaking change from positional arrays to named objects.
Acceptable because: This endpoint is being added in this PR and has not yet been released, so no existing consumers are affected.
Files Changed
src/main/java/com/dotcms/google/analytics/rest/GoogleAnalyticsResource.java- New REST resource with flattened response conversionREADME.md- Added REST API documentation and dimensions/metrics explainer🤖 Generated with Claude Code