From 9a04ef1e96c2624517b508ef59b88ff174591d7c Mon Sep 17 00:00:00 2001
From: serhii-vydiuk-kevychsolutions
Date: Fri, 20 Dec 2024 17:37:51 +0200
Subject: [PATCH] Java: Fetch user data based on yaml/properties configuration
in Spring Boot applications (#1147)
---
packages/java/examples/OwlTestApp/pom.xml | 5 +
.../com/readme/example/OwlController.java | 6 +-
.../src/main/resources/application.yaml | 41 ++++
.../example/ExampleApplicationTests.java | 13 --
.../README.md | 4 +-
.../pom.xml | 38 ++--
.../DataCollectionAutoConfiguration.java | 43 +++++
.../config/JakartaDataCollectionConfig.java | 70 -------
.../config/ReadmeConfigurationProperties.java | 14 ++
...roperties.java => UserDataProperties.java} | 10 +-
.../datacollection/DataCollectionFilter.java | 68 +++++--
.../HttpServletDataPayload.java | 37 ----
.../ServletDataPayloadAdapter.java | 129 +++++++++++++
.../ServletRequestDataCollector.java | 15 +-
.../userinfo/ServletUserDataCollector.java | 67 ++++---
.../userinfo/ServletUserDataExtractor.java | 68 ++++++-
...ot.autoconfigure.AutoConfiguration.imports | 1 +
.../src/main/resources/application.properties | 1 -
.../src/main/resources/application.yaml | 0
.../ServletDataPayloadAdapterTest.java | 136 ++++++++++++++
.../ServletUserDataCollectorTest.java | 101 ++++++++++
.../ServletUserDataExtractorTest.java | 176 ++++++++++++++++++
packages/java/readme-metrics/pom.xml | 2 -
.../java/com/readme/config/FieldMapping.java | 8 +-
.../com/readme/config/UserDataConfig.java | 14 --
.../readme/dataextraction/DataPayload.java | 30 ---
.../dataextraction/DataPayloadAdapter.java | 28 +++
.../dataextraction/UserDataExtractor.java | 12 +-
.../readme/dataextraction/UserDataSource.java | 8 +-
29 files changed, 878 insertions(+), 267 deletions(-)
create mode 100644 packages/java/examples/OwlTestApp/src/main/resources/application.yaml
delete mode 100644 packages/java/examples/OwlTestApp/src/test/java/com/readme/example/ExampleApplicationTests.java
create mode 100644 packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/DataCollectionAutoConfiguration.java
delete mode 100644 packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/JakartaDataCollectionConfig.java
create mode 100644 packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/ReadmeConfigurationProperties.java
rename packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/{MonitoringProperties.java => UserDataProperties.java} (83%)
delete mode 100644 packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/HttpServletDataPayload.java
create mode 100644 packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/ServletDataPayloadAdapter.java
create mode 100644 packages/java/readme-metrics-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
delete mode 100644 packages/java/readme-metrics-spring-boot-starter/src/main/resources/application.properties
delete mode 100644 packages/java/readme-metrics-spring-boot-starter/src/main/resources/application.yaml
create mode 100644 packages/java/readme-metrics-spring-boot-starter/src/test/java/com/readme/starter/datacollection/ServletDataPayloadAdapterTest.java
create mode 100644 packages/java/readme-metrics-spring-boot-starter/src/test/java/com/readme/starter/datacollection/userinfo/ServletUserDataCollectorTest.java
create mode 100644 packages/java/readme-metrics-spring-boot-starter/src/test/java/com/readme/starter/datacollection/userinfo/ServletUserDataExtractorTest.java
delete mode 100644 packages/java/readme-metrics/src/main/java/com/readme/config/UserDataConfig.java
delete mode 100644 packages/java/readme-metrics/src/main/java/com/readme/dataextraction/DataPayload.java
create mode 100644 packages/java/readme-metrics/src/main/java/com/readme/dataextraction/DataPayloadAdapter.java
diff --git a/packages/java/examples/OwlTestApp/pom.xml b/packages/java/examples/OwlTestApp/pom.xml
index d58ed1e4ca..6f00f8daa5 100644
--- a/packages/java/examples/OwlTestApp/pom.xml
+++ b/packages/java/examples/OwlTestApp/pom.xml
@@ -19,6 +19,11 @@
+
+ com.readme
+ metrics-spring-boot-starter
+ 0.0.1-SNAPSHOT
+
org.springframework.boot
spring-boot-starter-web
diff --git a/packages/java/examples/OwlTestApp/src/main/java/com/readme/example/OwlController.java b/packages/java/examples/OwlTestApp/src/main/java/com/readme/example/OwlController.java
index 3c3302c49a..c19f7913fe 100644
--- a/packages/java/examples/OwlTestApp/src/main/java/com/readme/example/OwlController.java
+++ b/packages/java/examples/OwlTestApp/src/main/java/com/readme/example/OwlController.java
@@ -1,5 +1,6 @@
package com.readme.example;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
@@ -13,6 +14,9 @@
@RestController
public class OwlController {
+ @Value("${readme.readmeApiKey}")
+ private String readmeApiKey;
+
private final Map owlStorage = new HashMap<>();
public OwlController() {
@@ -21,7 +25,7 @@ public OwlController() {
@GetMapping("/owl/{id}")
public String getOwlById(@PathVariable String id) {
- return "Owl with id " + id;
+ return "Owl with id " + id + " and key is " + readmeApiKey;
}
@GetMapping("/owls")
diff --git a/packages/java/examples/OwlTestApp/src/main/resources/application.yaml b/packages/java/examples/OwlTestApp/src/main/resources/application.yaml
new file mode 100644
index 0000000000..7f2808045f
--- /dev/null
+++ b/packages/java/examples/OwlTestApp/src/main/resources/application.yaml
@@ -0,0 +1,41 @@
+readme:
+ readmeApiKey: ${README_API_KEY}
+ userdata:
+ apiKey:
+ source: header
+ fieldName: X-User-Name
+ email:
+ source: header
+ fieldName: X-User-Email
+ label:
+ source: header
+ fieldName: X-User-Id
+
+#readme:
+# readmeApiKey: ${README_API_KEY}
+# userdata:
+# apiKey:
+# source: jsonBody
+# fieldName: /owl-creator/name
+# email:
+# source: jsonBody
+# fieldName: /owl-creator/contacts/email
+# label:
+# source: jsonBody
+# fieldName: owl-creator/label
+
+#readme:
+# readmeApiKey: ${README_API_KEY}
+# userdata:
+# apiKey:
+# source: jwt
+# fieldName: name
+# email:
+# source: jwt
+# fieldName: aud
+# label:
+# source: jwt
+# fieldName: user_id
+
+
+
diff --git a/packages/java/examples/OwlTestApp/src/test/java/com/readme/example/ExampleApplicationTests.java b/packages/java/examples/OwlTestApp/src/test/java/com/readme/example/ExampleApplicationTests.java
deleted file mode 100644
index 85514a1b4c..0000000000
--- a/packages/java/examples/OwlTestApp/src/test/java/com/readme/example/ExampleApplicationTests.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.readme.example;
-
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.test.context.SpringBootTest;
-
-@SpringBootTest
-class ExampleApplicationTests {
-
- @Test
- void contextLoads() {
- }
-
-}
diff --git a/packages/java/readme-metrics-spring-boot-starter/README.md b/packages/java/readme-metrics-spring-boot-starter/README.md
index bce5d88611..a94a718781 100644
--- a/packages/java/readme-metrics-spring-boot-starter/README.md
+++ b/packages/java/readme-metrics-spring-boot-starter/README.md
@@ -20,13 +20,13 @@ Each field (`apiKey`, `email`, `label`) requires two sub-properties:
### Example Configuration (YAML)
```yaml
readme:
- readmeApiKey: a11b33b2c44de78f7a
+ readmeApiKey: ${readmeApiKey}
userdata:
apiKey:
source: header
fieldName: X-User-Id
email:
- source: jwtClaim
+ source: jwt
fieldName: aud
label:
source: jsonBody
diff --git a/packages/java/readme-metrics-spring-boot-starter/pom.xml b/packages/java/readme-metrics-spring-boot-starter/pom.xml
index 354c9ce98b..377871affc 100644
--- a/packages/java/readme-metrics-spring-boot-starter/pom.xml
+++ b/packages/java/readme-metrics-spring-boot-starter/pom.xml
@@ -22,23 +22,28 @@
- com.readme
- readme-metrics
- ${readme-metrics.version}
+ org.springframework.boot
+ spring-boot-starter-web
-
org.springframework.boot
- spring-boot-starter-web
+ spring-boot-configuration-processor
true
- jakarta.servlet
- jakarta.servlet-api
- 6.0.0
- provided
- true
+ com.readme
+ readme-metrics
+ ${readme-metrics.version}
+
+
+
+
+ com.auth0
+ java-jwt
+ 4.4.0
+
+
org.springframework.boot
spring-boot-starter-test
@@ -46,17 +51,4 @@
-
-
-
- org.springframework.boot
- spring-boot-maven-plugin
-
- true
-
-
-
-
-
-
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/DataCollectionAutoConfiguration.java b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/DataCollectionAutoConfiguration.java
new file mode 100644
index 0000000000..b92c91e5ee
--- /dev/null
+++ b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/DataCollectionAutoConfiguration.java
@@ -0,0 +1,43 @@
+package com.readme.starter.config;
+
+import com.readme.dataextraction.RequestDataCollector;
+import com.readme.dataextraction.UserDataCollector;
+import com.readme.starter.datacollection.DataCollectionFilter;
+import com.readme.starter.datacollection.ServletDataPayloadAdapter;
+import lombok.AllArgsConstructor;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+
+/**
+ * Configuration class for registering and initializing the JakartaDataCollectionFilter
+ * along with its dependencies in a Spring Boot application.
+ *
+ * This configuration provides the following:
+ *
+ * - Instantiates the {@link DataCollectionFilter} with required collectors.
+ * - Registers the filter using {@link FilterRegistrationBean} for servlet-based applications.
+ * - Sets up default implementations for collecting request and user data.
+ *
+ */
+@Configuration
+@ConditionalOnClass({UserDataProperties.class})
+@ComponentScan(basePackages = {"com.readme.starter"})
+@AllArgsConstructor
+public class DataCollectionAutoConfiguration {
+
+ @Bean
+ public FilterRegistrationBean metricsFilter(
+ RequestDataCollector requestDataCollector,
+ UserDataCollector userDataCollector) {
+ FilterRegistrationBean registrationBean = new FilterRegistrationBean<>();
+ registrationBean.setFilter(new DataCollectionFilter(requestDataCollector, userDataCollector));
+ registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
+ registrationBean.addUrlPatterns("/*");
+ return registrationBean;
+ }
+
+}
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/JakartaDataCollectionConfig.java b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/JakartaDataCollectionConfig.java
deleted file mode 100644
index e3aa97ab4b..0000000000
--- a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/JakartaDataCollectionConfig.java
+++ /dev/null
@@ -1,70 +0,0 @@
-package com.readme.starter.config;
-
-import com.readme.config.CoreConfig;
-import com.readme.config.UserDataConfig;
-import com.readme.dataextraction.RequestDataCollector;
-import com.readme.dataextraction.UserDataExtractor;
-import com.readme.starter.datacollection.DataCollectionFilter;
-import com.readme.starter.datacollection.HttpServletDataPayload;
-import com.readme.starter.datacollection.ServletRequestDataCollector;
-import com.readme.starter.datacollection.userinfo.ServletUserDataExtractor;
-import com.readme.starter.datacollection.userinfo.ServletUserDataCollector;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
-import org.springframework.boot.web.servlet.FilterRegistrationBean;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-
-/**
- * Configuration class for registering and initializing the JakartaDataCollectionFilter
- * along with its dependencies in a Spring Boot application.
- *
- * This configuration provides the following:
- *
- * - Instantiates the {@link DataCollectionFilter} with required collectors.
- * - Registers the filter using {@link FilterRegistrationBean} for servlet-based applications.
- * - Sets up default implementations for collecting request and user data.
- *
- */
-@Configuration
-public class JakartaDataCollectionConfig {
-
- private MonitoringProperties monitoringProperties;
-
- @Bean
- public com.readme.dataextraction.UserDataCollector
- userDataCollector(UserDataExtractor userDataExtractor) {
- UserDataConfig userDataConfig = UserDataConfig.builder()
- .apiKey(monitoringProperties.getApiKey())
- .email(monitoringProperties.getEmail())
- .label(monitoringProperties.getLabel())
- .build();
-
- return new ServletUserDataCollector(userDataConfig, userDataExtractor);
- }
-
- @Bean
- public UserDataExtractor userDataExtractor() {
- return new ServletUserDataExtractor();
- }
-
-
-
- @Bean
- public DataCollectionFilter dataCollectionFilter
- (com.readme.dataextraction.UserDataCollector userDataCollector,
- RequestDataCollector requestDataCollector) {
- return new DataCollectionFilter(requestDataCollector, userDataCollector);
- }
-
- @Bean
- public RequestDataCollector requestDataCollector() {
- String readmeApiKey = monitoringProperties.getReadmeApiKey();
-
- CoreConfig coreConfig = CoreConfig.builder()
- .readmeAPIKey(readmeApiKey)
- .build();
-
- return new ServletRequestDataCollector(coreConfig);
- }
-
-}
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/ReadmeConfigurationProperties.java b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/ReadmeConfigurationProperties.java
new file mode 100644
index 0000000000..0e927f05c9
--- /dev/null
+++ b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/ReadmeConfigurationProperties.java
@@ -0,0 +1,14 @@
+package com.readme.starter.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "readme")
+public class ReadmeConfigurationProperties {
+
+ private String readmeApiKey;
+
+}
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/MonitoringProperties.java b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/UserDataProperties.java
similarity index 83%
rename from packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/MonitoringProperties.java
rename to packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/UserDataProperties.java
index 3b2c3818ec..484e50af17 100644
--- a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/MonitoringProperties.java
+++ b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/config/UserDataProperties.java
@@ -3,7 +3,7 @@
import com.readme.config.FieldMapping;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.context.annotation.Configuration;
+import org.springframework.stereotype.Component;
/**
* Configuration properties for monitoring library.
@@ -14,12 +14,12 @@
* (e.g., header, jwtClaim, or jsonBody) and its corresponding value.
*
*/
-@Configuration
-@ConfigurationProperties(prefix = "readme.userdata")
+
@Data
-public class MonitoringProperties {
+@Component
+@ConfigurationProperties(prefix = "readme.userdata")
+public class UserDataProperties {
- private String readmeApiKey;
private FieldMapping apiKey;
private FieldMapping email;
private FieldMapping label;
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/DataCollectionFilter.java b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/DataCollectionFilter.java
index 97a2a810ed..340eefab2d 100644
--- a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/DataCollectionFilter.java
+++ b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/DataCollectionFilter.java
@@ -7,37 +7,65 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.util.ContentCachingRequestWrapper;
+import org.springframework.web.util.ContentCachingResponseWrapper;
import java.io.IOException;
+import static org.springframework.http.HttpMethod.GET;
+import static org.springframework.http.HttpMethod.OPTIONS;
+
-/**
- * A filter for collecting HTTP request and response metrics in environments using the
- * jakarta.servlet API (e.g., Spring Boot 3).
- *
- * This filter intercepts HTTP requests and responses, passing them to a {@code DataCollector}
- * for processing. It enables applications to gather usage data,
- * such as response codes, headers, and payloads.
- *
- * Problem Solved: Provides a unified mechanism for metric collection while maintaining
- * compatibility with modern Servlet API versions.
- */
@AllArgsConstructor
+@Slf4j
public class DataCollectionFilter implements Filter {
- private RequestDataCollector requestDataCollector;
-
- private UserDataCollector userDataCollector;
+ private RequestDataCollector requestDataCollector;
+ private UserDataCollector userDataCollector;
+ //TODO
+ // 1. Research possibility to collect metrics in a separate thread, as it may produce
+ // race condition on reading body data stream.
+ // 2. Problem to solve: if we collect a request/response after doFilter(r,r), it means
+ // the request dataStream will be red by customer's business logic and will not be available to us.
+ // On the other hand, if we collect a request before doFilter, the response data is not available yet.
@Override
- public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
- HttpServletDataPayload payload =
- new HttpServletDataPayload((HttpServletRequest) request, (HttpServletResponse) response);
+ public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
+ HttpServletRequest request = (HttpServletRequest) req;
+ HttpServletResponse response = (HttpServletResponse) resp;
+
+ try {
+ if (request.getMethod().equalsIgnoreCase(OPTIONS.name())) {
+ chain.doFilter(req, resp);
+ } else if (request.getMethod().equalsIgnoreCase(GET.name())) {
+ ServletDataPayloadAdapter payload =
+ new ServletDataPayloadAdapter(request, response);
+
+ //TODO: Handle case if SDK user configured getting request user data from body, but GET req doesn't have it
+ UserData userData = userDataCollector.collect(payload);
+ //TODO: Validate user data. Collect request data only if user data is valid ?
+ requestDataCollector.collect(payload, userData);
+ chain.doFilter(req, resp);
+ } else {
+ ContentCachingRequestWrapper cacheableRequest =
+ new ContentCachingRequestWrapper(request);
+ ContentCachingResponseWrapper cacheableResponse =
+ new ContentCachingResponseWrapper(response);
+
+ ServletDataPayloadAdapter payload =
+ new ServletDataPayloadAdapter(cacheableRequest, cacheableResponse);
+ UserData userData = userDataCollector.collect(payload);
- UserData userData = userDataCollector.collect(payload);
- //TODO: Validate user data. Collect request data only if user data is valid ?
- requestDataCollector.collect(payload, userData);
+ requestDataCollector.collect(payload, userData);
+ chain.doFilter(cacheableRequest, cacheableResponse);
+ }
+ } catch (Exception e){
+ log.error("Error occurred while processing request by readme metrics-sdk: {}", e.getMessage());
+ } finally {
+ chain.doFilter(req, resp);
+ }
}
}
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/HttpServletDataPayload.java b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/HttpServletDataPayload.java
deleted file mode 100644
index e5761a2c13..0000000000
--- a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/HttpServletDataPayload.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.readme.starter.datacollection;
-
-import com.readme.dataextraction.DataPayload;
-import jakarta.servlet.http.HttpServletResponse;
-import lombok.AllArgsConstructor;
-
-import jakarta.servlet.http.HttpServletRequest;
-import java.util.Enumeration;
-
-@AllArgsConstructor
-public class HttpServletDataPayload implements DataPayload {
-
- private HttpServletRequest request;
- private HttpServletResponse response;
-
-
- @Override
- public String getRequestBody() {
- throw new UnsupportedOperationException("Not implemented yet");
- }
-
- @Override
- public Enumeration getRequestHeaders() {
- throw new UnsupportedOperationException("Not implemented yet");
- }
-
- @Override
- public String getResponseBody() {
- throw new UnsupportedOperationException("Not implemented yet");
- }
-
- @Override
- public Enumeration getResponseHeaders() {
- throw new UnsupportedOperationException("Not implemented yet");
- }
-
-}
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/ServletDataPayloadAdapter.java b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/ServletDataPayloadAdapter.java
new file mode 100644
index 0000000000..ff86c21290
--- /dev/null
+++ b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/ServletDataPayloadAdapter.java
@@ -0,0 +1,129 @@
+package com.readme.starter.datacollection;
+
+import com.readme.dataextraction.DataPayloadAdapter;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.AllArgsConstructor;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.IOException;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@AllArgsConstructor
+public class ServletDataPayloadAdapter implements DataPayloadAdapter {
+
+ private HttpServletRequest request;
+ private HttpServletResponse response;
+
+ //TODO Do I need a separate method to get request parameters?
+
+ @Override
+ public String getRequestMethod() {
+ return request.getMethod();
+ }
+
+ @Override
+ public String getRequestContentType() {
+ return request.getContentType();
+ }
+
+ @Override
+ public String getRequestBody() {
+ try {
+ return request.getReader()
+ .lines()
+ .collect(Collectors.joining(System.lineSeparator()));
+ } catch (IOException e) {
+ log.error("Error when trying to get request body: {}", e.getMessage());
+ }
+ return "";
+ }
+
+ /**
+ * Retrieves all request headers from the {@link HttpServletRequest} and returns them
+ * as a map where the header names are normalized to lowercase.
+ *
+ * This method ensures consistent header name formatting by converting all
+ * header names to lowercase, which is particularly useful for avoiding case-sensitivity
+ * issues when accessing HTTP headers.
+ *
+ * Example:
+ * If the request contains headers:
+ *
+ * - Authorization: Bearer token
+ * - X-User-Id: 12345
+ *
+ * The resulting map will look like:
+ *
+ * {
+ * "authorization": "Bearer token",
+ * "x-user-id": "12345"
+ * }
+ *
+ *
+ *
+ * @return a map of request header names (lowercased) and their corresponding values.
+ * If no headers are present or provided request is null, returns an empty map.
+ */
+ @Override
+ public Map getRequestHeaders() {
+ if (request != null) {
+ Map headers = new HashMap<>();
+ Enumeration headerNames = request.getHeaderNames();
+
+ while (headerNames.hasMoreElements()) {
+ String headerName = headerNames.nextElement().toLowerCase();
+ headers.put(headerName, request.getHeader(headerName));
+ }
+ return headers;
+ }
+ log.error("The provided request is null");
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public String getResponseBody() {
+ throw new UnsupportedOperationException("Not implemented yet");
+ }
+
+ /**
+ * Retrieves all response headers from the {@link HttpServletResponse} and returns them
+ * as a map where the header names are preserved in their original case.
+ *
+ * This method iterates through all header names provided by the {@link HttpServletResponse}
+ * and maps each header name to its corresponding value.
+ *
+ * Example:
+ * If the response contains headers:
+ *
+ * - Content-Type: application/json
+ * - X-Custom-Header: custom-value
+ *
+ * The resulting map will look like:
+ *
+ * {
+ * "Content-Type": "application/json",
+ * "X-Custom-Header": "custom-value"
+ * }
+ *
+ *
+ *
+ * @return a map of response header names and their corresponding values.
+ * If no headers are present or provided response is null, returns an empty map.
+ */
+ @Override
+ public Map getResponseHeaders() {
+ if (response != null) {
+ return response.getHeaderNames().stream()
+ .collect(Collectors.toMap(
+ headerName -> headerName,
+ headerName -> response.getHeader(headerName)));
+ }
+ log.error("The provided response is null");
+ return Collections.emptyMap();
+ }
+
+}
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/ServletRequestDataCollector.java b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/ServletRequestDataCollector.java
index 467d2036a6..07f9def307 100644
--- a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/ServletRequestDataCollector.java
+++ b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/ServletRequestDataCollector.java
@@ -1,21 +1,24 @@
package com.readme.starter.datacollection;
-import com.readme.config.CoreConfig;
import com.readme.dataextraction.RequestDataCollector;
import com.readme.domain.UserData;
+import com.readme.starter.config.ReadmeConfigurationProperties;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
@Slf4j
@AllArgsConstructor
-public class ServletRequestDataCollector implements RequestDataCollector {
+@Component
+public class ServletRequestDataCollector implements RequestDataCollector {
- private CoreConfig coreConfig;
+ private ReadmeConfigurationProperties readmeProperties;
@Override
- public void collect(HttpServletDataPayload dataPayload, UserData userData) {
- String readmeAPIKey = coreConfig.getReadmeAPIKey();
+ public void collect(ServletDataPayloadAdapter dataPayload, UserData userData) {
+ String readmeAPIKey = readmeProperties.getReadmeApiKey();
- log.info(">>>>>>>> Sending data to the server....");
+ log.info(">>>>>>>> Sending data to the server with key {}", readmeAPIKey);
+ log.info(">>>>>>>> and user data: {}", userData);
}
}
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/userinfo/ServletUserDataCollector.java b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/userinfo/ServletUserDataCollector.java
index 328d8a815d..409fedf80c 100644
--- a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/userinfo/ServletUserDataCollector.java
+++ b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/userinfo/ServletUserDataCollector.java
@@ -1,15 +1,16 @@
package com.readme.starter.datacollection.userinfo;
import com.readme.config.FieldMapping;
-import com.readme.config.UserDataConfig;
import com.readme.dataextraction.UserDataCollector;
import com.readme.dataextraction.UserDataExtractor;
-import com.readme.dataextraction.UserDataField;
import com.readme.dataextraction.UserDataSource;
import com.readme.domain.UserData;
-import com.readme.starter.datacollection.HttpServletDataPayload;
+import com.readme.starter.config.UserDataProperties;
+import com.readme.starter.datacollection.ServletDataPayloadAdapter;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.DependsOn;
+import org.springframework.stereotype.Component;
/**
* Responsible for selecting the appropriate {@link UserDataExtractor}
@@ -22,16 +23,17 @@
* Ensures flexibility and proper encapsulation of the strategy selection logic.
*/
+@Component
@AllArgsConstructor
@Slf4j
-public class ServletUserDataCollector implements UserDataCollector {
+public class ServletUserDataCollector implements UserDataCollector {
- private UserDataConfig userDataConfig;
+ private UserDataProperties userDataProperties;
- private final UserDataExtractor extractionService;
+ private final UserDataExtractor extractionService;
@Override
- public UserData collect(HttpServletDataPayload payload) {
+ public UserData collect(ServletDataPayloadAdapter payload) {
String apiKey = getApiKey(payload);
String email = getEmail(payload);
@@ -45,8 +47,8 @@ public UserData collect(HttpServletDataPayload payload) {
}
- private String getApiKey(HttpServletDataPayload payload) {
- FieldMapping apiKey = userDataConfig.getApiKey();
+ private String getApiKey(ServletDataPayloadAdapter payload) {
+ FieldMapping apiKey = userDataProperties.getApiKey();
if (apiKey == null) {
log.error("api-key extraction is not configured properly");
return "";
@@ -54,8 +56,8 @@ private String getApiKey(HttpServletDataPayload payload) {
return extractFieldValue(payload, apiKey);
}
- private String getEmail(HttpServletDataPayload payload) {
- FieldMapping apiKey = userDataConfig.getEmail();
+ private String getEmail(ServletDataPayloadAdapter payload) {
+ FieldMapping apiKey = userDataProperties.getEmail();
if (apiKey == null) {
log.error("email extraction is not configured properly");
return "";
@@ -63,8 +65,8 @@ private String getEmail(HttpServletDataPayload payload) {
return extractFieldValue(payload, apiKey);
}
- private String getLabel(HttpServletDataPayload payload) {
- FieldMapping apiKey = userDataConfig.getLabel();
+ private String getLabel(ServletDataPayloadAdapter payload) {
+ FieldMapping apiKey = userDataProperties.getLabel();
if (apiKey == null) {
log.error("label extraction is not configured properly");
return "";
@@ -72,25 +74,44 @@ private String getLabel(HttpServletDataPayload payload) {
return extractFieldValue(payload, apiKey);
}
- private String extractFieldValue(HttpServletDataPayload payload, FieldMapping fieldMapping) {
- if (fieldMapping.getSource().equals(UserDataSource.HEADER.name())) {
- UserDataField fieldName = UserDataField.valueOf(fieldMapping.getFieldName());
- return extractionService.extractFromHeader(payload, fieldName);
+ private String extractFieldValue(ServletDataPayloadAdapter payload, FieldMapping fieldMapping) {
+ if (fieldMapping.getSource().equals(UserDataSource.HEADER.getValue())) {
+ String fieldName = fieldMapping.getFieldName().toLowerCase();
+ String fieldValue = extractionService.extractFromHeader(payload, fieldName);
+
+ validate(payload, fieldValue);
+ return fieldValue;
}
- if (fieldMapping.getSource().equals(UserDataSource.BODY.name())) {
- UserDataField fieldName = UserDataField.valueOf(fieldMapping.getFieldName());
- return extractionService.extractFromBody(payload, fieldName);
+ if (fieldMapping.getSource().equals(UserDataSource.BODY.getValue())) {
+ String fieldName = fieldMapping.getFieldName().toLowerCase();
+ String fieldValue = extractionService.extractFromBody(payload, fieldName);
+
+ validate(payload, fieldValue);
+ return fieldValue;
}
- if (fieldMapping.getSource().equals(UserDataSource.JWT.name())) {
- UserDataField fieldName = UserDataField.valueOf(fieldMapping.getFieldName());
- return extractionService.extractFromJwt(payload, fieldName);
+ if (fieldMapping.getSource().equals(UserDataSource.JWT.getValue())) {
+ String fieldName = fieldMapping.getFieldName().toLowerCase();
+ String fieldValue = extractionService.extractFromJwt(payload, fieldName);
+
+ validate(payload, fieldValue);
+ return fieldValue;
}
log.error("unknown field source: {}", fieldMapping.getSource());
+ //TODO handle this
return "";
}
+ private void validate(ServletDataPayloadAdapter payload, String fieldName) {
+ String fieldValue = extractionService.extractFromHeader(payload, fieldName);
+ if (fieldValue == null || fieldValue.isEmpty()) {
+ log.error("The {} extraction is not configured properly. The value is empty", fieldName);
+ }
+ }
+
}
+
+
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/userinfo/ServletUserDataExtractor.java b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/userinfo/ServletUserDataExtractor.java
index e93f3c7b07..0818af747c 100644
--- a/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/userinfo/ServletUserDataExtractor.java
+++ b/packages/java/readme-metrics-spring-boot-starter/src/main/java/com/readme/starter/datacollection/userinfo/ServletUserDataExtractor.java
@@ -1,26 +1,76 @@
package com.readme.starter.datacollection.userinfo;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.readme.dataextraction.UserDataExtractor;
-import com.readme.dataextraction.UserDataField;
-import com.readme.starter.datacollection.HttpServletDataPayload;
+import com.readme.starter.datacollection.ServletDataPayloadAdapter;
import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import com.auth0.jwt.JWT;
+
+
+import java.util.Map;
+
@AllArgsConstructor
-public class ServletUserDataExtractor implements UserDataExtractor {
+@Component
+@Slf4j
+public class ServletUserDataExtractor implements UserDataExtractor {
+ private ObjectMapper objectMapper;
+
+ //TODO: Consider possibility to extract the data from the header`s multiple value
+ // Is there any practical sense?
@Override
- public String extractFromHeader(HttpServletDataPayload payload, UserDataField fieldName) {
- return "Field value from header";
+ public String extractFromHeader(ServletDataPayloadAdapter payload, String fieldName) {
+ Map requestHeaders = payload.getRequestHeaders();
+ if (requestHeaders.containsKey(fieldName)) {
+ return requestHeaders.get(fieldName);
+ }
+ log.error("The provided header name {} does not exist.", fieldName);
+ return "";
}
@Override
- public String extractFromBody(HttpServletDataPayload payload, UserDataField fieldName) {
- return "Field value from body";
+ public String extractFromBody(ServletDataPayloadAdapter payload, String fieldPath) {
+ if (payload.getRequestContentType().equalsIgnoreCase("application/json")) {
+ String requestBody = payload.getRequestBody();
+ try {
+ JsonNode currentNode = objectMapper.readTree(requestBody);
+ if (!fieldPath.startsWith("/")) {
+ fieldPath = "/" + fieldPath;
+ }
+ return currentNode.at(fieldPath).asText();
+ } catch (Exception e) {
+ log.error("Error when reading the user data from JSON body: {}", e.getMessage());
+ }
+ }
+ log.error("The provided body content type {} is not supported to get user data.", payload.getRequestContentType());
+ return "";
}
@Override
- public String extractFromJwt(HttpServletDataPayload payload, UserDataField fieldName) {
- return "Field value from jwt";
+ public String extractFromJwt(ServletDataPayloadAdapter payload, String fieldName) {
+ try {
+ Map requestHeaders = payload.getRequestHeaders();
+ String jwtToken = requestHeaders.get("authorization");
+
+ if (jwtToken == null) {
+ log.error("The JWT token is not provided as Authorization header.");
+ return "";
+ }
+ if (jwtToken.startsWith("Bearer ")) {
+ jwtToken = jwtToken.substring(7);
+ }
+
+ DecodedJWT decodedJWT = JWT.decode(jwtToken);
+ return decodedJWT.getClaim(fieldName).asString();
+ } catch (Exception e) {
+ log.error("The Authorization token is invalid. {}", e.getMessage());
+ }
+ return "";
}
}
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/packages/java/readme-metrics-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 0000000000..5f7f9ceb4b
--- /dev/null
+++ b/packages/java/readme-metrics-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+com.readme.starter.config.DataCollectionAutoConfiguration
\ No newline at end of file
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/resources/application.properties b/packages/java/readme-metrics-spring-boot-starter/src/main/resources/application.properties
deleted file mode 100644
index 586b036bfc..0000000000
--- a/packages/java/readme-metrics-spring-boot-starter/src/main/resources/application.properties
+++ /dev/null
@@ -1 +0,0 @@
-spring.application.name=readme-metrics-spring-boot-starter
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/main/resources/application.yaml b/packages/java/readme-metrics-spring-boot-starter/src/main/resources/application.yaml
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/test/java/com/readme/starter/datacollection/ServletDataPayloadAdapterTest.java b/packages/java/readme-metrics-spring-boot-starter/src/test/java/com/readme/starter/datacollection/ServletDataPayloadAdapterTest.java
new file mode 100644
index 0000000000..6b27916da0
--- /dev/null
+++ b/packages/java/readme-metrics-spring-boot-starter/src/test/java/com/readme/starter/datacollection/ServletDataPayloadAdapterTest.java
@@ -0,0 +1,136 @@
+package com.readme.starter.datacollection;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class ServletDataPayloadAdapterTest {
+
+ @Mock
+ private HttpServletRequest requestMock;
+
+ @Mock
+ private HttpServletResponse responseMock;
+
+ private ServletDataPayloadAdapter adapter;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ adapter = new ServletDataPayloadAdapter(requestMock, responseMock);
+ }
+
+
+ // --------------------------- REQUEST --------------------------------
+
+ @Test
+ void getRequestHeaders_HappyPath_ReturnsAllHeaders() {
+ String usernameHeader = "X-User-Name".toLowerCase();
+ String userIdHeader = "X-User-Id".toLowerCase();
+ Enumeration headerNames = Collections.enumeration(List.of(usernameHeader, userIdHeader));
+
+ when(requestMock.getHeaderNames()).thenReturn(headerNames);
+ when(requestMock.getHeader(usernameHeader)).thenReturn("Parrot");
+ when(requestMock.getHeader(userIdHeader)).thenReturn("parrot@birdfact0ry.abc");
+
+ Map headers = adapter.getRequestHeaders();
+
+ assertEquals(2, headers.size());
+ assertEquals("Parrot", headers.get(usernameHeader));
+ assertEquals("parrot@birdfact0ry.abc", headers.get(userIdHeader));
+ }
+
+ @Test
+ void getRequestHeaders_NoHeaders_ReturnsEmptyMap() {
+ when(requestMock.getHeaderNames()).thenReturn(Collections.emptyEnumeration());
+ Map headers = adapter.getRequestHeaders();
+
+ assertTrue(headers.isEmpty());
+ }
+
+ @Test
+ void getRequestMethod_HappyPath_ReturnsCorrectMethod() {
+ when(requestMock.getMethod()).thenReturn("POST");
+ String method = adapter.getRequestMethod();
+
+ assertEquals("POST", method);
+ }
+
+ @Test
+ void getRequestContentType_HappyPath_ReturnsContentType() {
+ when(requestMock.getContentType()).thenReturn("application/json");
+ String contentType = adapter.getRequestContentType();
+
+ assertEquals("application/json", contentType);
+ }
+
+ @Test
+ void getRequestBody_HappyPath_ReturnsRequestBody() throws IOException {
+ String requestBody = "{\"bird\": \"Owl\"}";
+ BufferedReader bufferedReader = new BufferedReader(new StringReader(requestBody));
+ when(requestMock.getReader()).thenReturn(bufferedReader);
+ String result = adapter.getRequestBody();
+
+ assertEquals(requestBody, result);
+ }
+
+ @Test
+ void getRequestBody_WhenIOExceptionOccurs_ReturnsEmptyString() throws IOException {
+ when(requestMock.getReader()).thenThrow(new IOException("Failed to read request"));
+ String result = adapter.getRequestBody();
+
+ assertEquals("", result);
+ }
+
+ // --------------------------- RESPONSE --------------------------------
+ @Test
+ void getResponseHeaders_HappyPath_ReturnsAllHeaders() {
+ String usernameHeader = "Response-X-User-Name".toLowerCase();
+ String userIdHeader = "Response-X-User-Id".toLowerCase();
+
+ when(responseMock.getHeaderNames()).thenReturn(List.of(usernameHeader, userIdHeader));
+ when(responseMock.getHeader(usernameHeader)).thenReturn("Parrot");
+ when(responseMock.getHeader(userIdHeader)).thenReturn("parrot@birdfact0ry.abc");
+
+ Map headers = adapter.getResponseHeaders();
+
+ assertEquals(2, headers.size());
+ assertEquals("Parrot", headers.get(usernameHeader));
+ assertEquals("parrot@birdfact0ry.abc", headers.get(userIdHeader));
+ }
+
+ // TODO implement this, once it fails
+ @Test
+ void getResponseBody_ThrowsUnsupportedOperationException() {
+ UnsupportedOperationException exception = assertThrows(
+ UnsupportedOperationException.class,
+ adapter::getResponseBody
+ );
+
+ assertEquals("Not implemented yet", exception.getMessage());
+ }
+
+ @Test
+ void getResponseHeaders_NoHeaders_ReturnsEmptyMap() {
+ when(responseMock.getHeaderNames()).thenReturn(Collections.emptyList());
+ Map headers = adapter.getResponseHeaders();
+
+ assertTrue(headers.isEmpty());
+ }
+
+
+}
\ No newline at end of file
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/test/java/com/readme/starter/datacollection/userinfo/ServletUserDataCollectorTest.java b/packages/java/readme-metrics-spring-boot-starter/src/test/java/com/readme/starter/datacollection/userinfo/ServletUserDataCollectorTest.java
new file mode 100644
index 0000000000..0aa639b1b5
--- /dev/null
+++ b/packages/java/readme-metrics-spring-boot-starter/src/test/java/com/readme/starter/datacollection/userinfo/ServletUserDataCollectorTest.java
@@ -0,0 +1,101 @@
+package com.readme.starter.datacollection.userinfo;
+
+import com.readme.config.FieldMapping;
+import com.readme.dataextraction.UserDataExtractor;
+import com.readme.dataextraction.UserDataSource;
+import com.readme.domain.UserData;
+import com.readme.starter.config.UserDataProperties;
+import com.readme.starter.datacollection.ServletDataPayloadAdapter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+class ServletUserDataCollectorTest {
+
+ private ServletUserDataCollector userDataCollector;
+
+ @Mock
+ private UserDataProperties userDataProperties;
+
+ @Mock
+ private UserDataExtractor extractionService;
+
+ @Mock
+ private ServletDataPayloadAdapter payload;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ userDataCollector = new ServletUserDataCollector(userDataProperties, extractionService);
+ }
+
+ @Test
+ void collect_HappyCase() {
+ FieldMapping apiKeyMapping = new FieldMapping(UserDataSource.HEADER.getValue(), "x-api-key");
+ FieldMapping emailMapping = new FieldMapping(UserDataSource.BODY.getValue(), "email");
+ FieldMapping labelMapping = new FieldMapping(UserDataSource.JWT.getValue(), "label");
+
+ when(userDataProperties.getApiKey()).thenReturn(apiKeyMapping);
+ when(userDataProperties.getEmail()).thenReturn(emailMapping);
+ when(userDataProperties.getLabel()).thenReturn(labelMapping);
+
+ when(extractionService.extractFromHeader(payload, "x-api-key")).thenReturn("test-api-key");
+ when(extractionService.extractFromBody(payload, "email")).thenReturn("test@example.com");
+ when(extractionService.extractFromJwt(payload, "label")).thenReturn("user-label");
+
+ UserData result = userDataCollector.collect(payload);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEqualTo("test-api-key");
+ assertThat(result.getEmail()).isEqualTo("test@example.com");
+ assertThat(result.getLabel()).isEqualTo("user-label");
+ }
+
+ @Test
+ void collect_MissingApiKeyConfiguration() {
+ when(userDataProperties.getApiKey()).thenReturn(null);
+ UserData result = userDataCollector.collect(payload);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEmpty();
+ verify(extractionService, never()).extractFromHeader(any(), anyString());
+ }
+
+ @Test
+ void collect_EmptyHeaderValue() {
+ FieldMapping apiKeyMapping = new FieldMapping(UserDataSource.HEADER.getValue(), "x-api-key");
+ when(userDataProperties.getApiKey()).thenReturn(apiKeyMapping);
+ when(extractionService.extractFromHeader(payload, "x-api-key")).thenReturn("");
+
+ UserData result = userDataCollector.collect(payload);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEmpty();
+ verify(extractionService).extractFromHeader(payload, "x-api-key");
+ }
+
+ @Test
+ void collect_UnknownFieldSource() {
+ FieldMapping unknownMapping = new FieldMapping("UNKNOWN", "field");
+ when(userDataProperties.getApiKey()).thenReturn(unknownMapping);
+
+ UserData result = userDataCollector.collect(payload);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEmpty();
+ }
+
+ @Test
+ void collect_NullPayload() {
+ UserData result = userDataCollector.collect(null);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getApiKey()).isEmpty();
+ assertThat(result.getEmail()).isEmpty();
+ assertThat(result.getLabel()).isEmpty();
+ }
+}
\ No newline at end of file
diff --git a/packages/java/readme-metrics-spring-boot-starter/src/test/java/com/readme/starter/datacollection/userinfo/ServletUserDataExtractorTest.java b/packages/java/readme-metrics-spring-boot-starter/src/test/java/com/readme/starter/datacollection/userinfo/ServletUserDataExtractorTest.java
new file mode 100644
index 0000000000..4ee4b7505b
--- /dev/null
+++ b/packages/java/readme-metrics-spring-boot-starter/src/test/java/com/readme/starter/datacollection/userinfo/ServletUserDataExtractorTest.java
@@ -0,0 +1,176 @@
+package com.readme.starter.datacollection.userinfo;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.readme.starter.datacollection.ServletDataPayloadAdapter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.IOException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+@ExtendWith(MockitoExtension.class)
+class ServletUserDataExtractorTest {
+
+ private ServletUserDataExtractor extractor;
+
+ @Mock
+ private ServletDataPayloadAdapter payload;
+
+
+ @BeforeEach
+ void setUp() {
+ extractor = new ServletUserDataExtractor(new ObjectMapper());
+ }
+
+ @Test
+ void extractFromHeader_happyPath() {
+ String headerName = "X-User-Name";
+ String expectedValue = "Parrot";
+ Map headers = Map.of(headerName, expectedValue);
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromHeader(payload, headerName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromHeader_headerNotFound() {
+ String headerName = "X-User-Name";
+ String expectedValue = "";
+ Map headers = Map.of();
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromHeader(payload, headerName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromHeader_multipleValuesExtractedCorrectly() {
+ String headerName = "X-User-Name";
+ String expectedValue = "Parrot,Owl,Chicken";
+ Map headers = Map.of(headerName, expectedValue);
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromHeader(payload, headerName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromJwt_happyPath_withBearerPrefix() throws NoSuchAlgorithmException {
+ String claimName = "user_name";
+ String expectedValue = "Parrot";
+
+ Algorithm signingKeyPair = createSigningKeyPair();
+ String jwt = JWT.create()
+ .withClaim(claimName, expectedValue)
+ .sign(signingKeyPair);
+ Map headers = Map.of("authorization", "Bearer " + jwt);
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromJwt(payload, claimName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromJwt_happyPath_NoBearerPrefix() throws NoSuchAlgorithmException {
+ String claimName = "user_name";
+ String expectedValue = "Parrot";
+
+ Algorithm signingKeyPair = createSigningKeyPair();
+ String jwt = JWT.create()
+ .withClaim(claimName, expectedValue)
+ .sign(signingKeyPair);
+ Map headers = Map.of("authorization", jwt);
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromJwt(payload, claimName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromJwt_missingAuthorizationHeader() {
+ String claimName = "user_name";
+ String expectedValue = "";
+
+ Map headers = Map.of();
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromJwt(payload, claimName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromJwt_invalidJwtToken() {
+ String claimName = "user_name";
+ String expectedValue = "";
+
+ Map headers = Map.of("authorization", "Bearer invalidToken");
+ Mockito.when(payload.getRequestHeaders()).thenReturn(headers);
+ String result = extractor.extractFromJwt(payload, claimName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromBody_happyPath() throws IOException {
+ String fieldName = "userName";
+ String expectedValue = "Owl";
+
+ String body = "{\"" + fieldName + "\":\"" + expectedValue + "\",\"anotherField\":\"anotherValue\"}";
+ Mockito.when(payload.getRequestBody()).thenReturn(body);
+ Mockito.when(payload.getRequestContentType()).thenReturn("application/json");
+ String result = extractor.extractFromBody(payload, fieldName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromBody_fieldNotFound() {
+ String fieldName = "userName";
+ String expectedValue = "";
+
+ String body = "{\"anotherField\":\"anotherValue\"}";
+ Mockito.when(payload.getRequestBody()).thenReturn(body);
+ Mockito.when(payload.getRequestContentType()).thenReturn("application/json");
+ String result = extractor.extractFromBody(payload, fieldName);
+
+ assertEquals(expectedValue, result);
+ }
+
+ @Test
+ void extractFromBody_invalidJson() {
+ String body = "invalid-json-body";
+ String expectedValue = "";
+
+ Mockito.when(payload.getRequestBody()).thenReturn(body);
+ Mockito.when(payload.getRequestContentType()).thenReturn("application/json");
+ String result = extractor.extractFromBody(payload, "fieldName");
+
+ assertEquals(expectedValue, result);
+ }
+
+
+ private Algorithm createSigningKeyPair() throws NoSuchAlgorithmException {
+ KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(2048);
+ KeyPair keyPair = keyPairGenerator.generateKeyPair();
+
+ RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
+ RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
+
+ return Algorithm.RSA256(publicKey, privateKey);
+ }
+}
\ No newline at end of file
diff --git a/packages/java/readme-metrics/pom.xml b/packages/java/readme-metrics/pom.xml
index e61be44e68..a5ffdb85bc 100644
--- a/packages/java/readme-metrics/pom.xml
+++ b/packages/java/readme-metrics/pom.xml
@@ -41,8 +41,6 @@
test
-
-
org.slf4j
slf4j-api
diff --git a/packages/java/readme-metrics/src/main/java/com/readme/config/FieldMapping.java b/packages/java/readme-metrics/src/main/java/com/readme/config/FieldMapping.java
index d1bd91e3c9..1fdd89d675 100644
--- a/packages/java/readme-metrics/src/main/java/com/readme/config/FieldMapping.java
+++ b/packages/java/readme-metrics/src/main/java/com/readme/config/FieldMapping.java
@@ -1,6 +1,10 @@
package com.readme.config;
-import lombok.Data; /**
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
* Represents a mapping source for extracting data from HTTP requests.
*
* A FieldMapping consists of a source type (e.g., header, jwtClaim, or jsonBody)
@@ -8,6 +12,8 @@
*
*/
@Data
+@AllArgsConstructor
+@NoArgsConstructor
public class FieldMapping {
private String source;
diff --git a/packages/java/readme-metrics/src/main/java/com/readme/config/UserDataConfig.java b/packages/java/readme-metrics/src/main/java/com/readme/config/UserDataConfig.java
deleted file mode 100644
index 6a970f1076..0000000000
--- a/packages/java/readme-metrics/src/main/java/com/readme/config/UserDataConfig.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.readme.config;
-
-import lombok.Builder;
-import lombok.Value;
-
-@Value
-@Builder
-public class UserDataConfig {
-
- FieldMapping apiKey;
- FieldMapping email;
- FieldMapping label;
-
-}
diff --git a/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/DataPayload.java b/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/DataPayload.java
deleted file mode 100644
index 17c8a9cfc4..0000000000
--- a/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/DataPayload.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.readme.dataextraction;
-
-import java.util.Enumeration;
-
-/**
- * This interface serves as a generic abstraction for working with HTTP requests in a way that is compatible
- * with both `javax.servlet.http.HttpServletRequest` (for example, used in Spring Boot 2) and
- * `jakarta.servlet.http.HttpServletRequest` (for example, used in Spring Boot 3).
- *
- * The migration from Java EE to Jakarta EE introduced a package change from `javax` to `jakarta`,
- * leading to incompatibilities in libraries or frameworks that aim to support both versions simultaneously.
- *
- *
By defining a common interface, `HttpServletDataPayload` allows library developers to write code
- * that handles HTTP requests without directly depending on either `javax.servlet` or `jakarta.servlet`.
- * Concrete adapters for each implementation can wrap the respective request types, enabling a unified API
- * for collecting data from HTTP requests, such as parameters and headers, across different versions of
- * the servlet API.
- *
- *
This approach eliminates the need for duplicate logic and makes it easier to maintain compatibility
- * with both `javax.servlet` and `jakarta.servlet` in the same library.
- */
-public interface DataPayload {
-
- String getRequestBody();
- Enumeration getRequestHeaders();
-
- String getResponseBody();
- Enumeration getResponseHeaders();
-
-}
diff --git a/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/DataPayloadAdapter.java b/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/DataPayloadAdapter.java
new file mode 100644
index 0000000000..789ae3df4f
--- /dev/null
+++ b/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/DataPayloadAdapter.java
@@ -0,0 +1,28 @@
+package com.readme.dataextraction;
+
+import java.util.Map;
+
+/**
+ * Represents a generic payload abstraction that provides methods to interact
+ * with request and response data regardless of the underlying framework or implementation.
+ * This interface allows seamless handling of HTTP-related data, enabling the
+ * extraction of request and response headers, and bodies without tying the logic
+ * to a specific framework or API (e.g., Servlet API, Spring WebFlux, Ktor, etc.).
+ *
+ * Implementations of this interface should adapt their behavior based on the
+ * specific HTTP processing framework they represent, but the consumer of this
+ * interface does not need to be aware of these details.
+ *
+ */
+public interface DataPayloadAdapter {
+
+ String getRequestMethod();
+ String getRequestContentType();
+
+ String getRequestBody();
+ Map getRequestHeaders();
+
+ String getResponseBody();
+ Map getResponseHeaders();
+
+}
diff --git a/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/UserDataExtractor.java b/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/UserDataExtractor.java
index 3345843b44..ccf6746d89 100644
--- a/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/UserDataExtractor.java
+++ b/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/UserDataExtractor.java
@@ -19,27 +19,27 @@ public interface UserDataExtractor {
* Extracts requested data from request header
*
* @param payload the type of request object from which user data will be extracted.
- * @param fieldName is the enumeration to identify which field to extract
+ * @param fieldName is the source field name to extract the data
* @return extracted value as a String
*/
- String extractFromHeader(T payload, UserDataField fieldName);
+ String extractFromHeader(T payload, String fieldName);
/**
* Extracts requested data from JSON body
*
* @param payload the type of request object from which user data will be extracted.
- * @param fieldName is the enumeration to identify which field to extract
+ * @param fieldName is the source field name to extract the data
* @return extracted value as a String
*/
- String extractFromBody(T payload, UserDataField fieldName);
+ String extractFromBody(T payload, String fieldPath);
/**
* Extracts requested data from JWT token
*
* @param payload the type of request object from which user data will be extracted.
- * @param fieldName is the enumeration to identify which field to extract
+ * @param fieldName is the source field name to extract the data
* @return extracted value as a String
*/
- String extractFromJwt(T payload, UserDataField fieldName);
+ String extractFromJwt(T payload, String fieldName);
}
diff --git a/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/UserDataSource.java b/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/UserDataSource.java
index 30cd38abb0..153f466ab9 100644
--- a/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/UserDataSource.java
+++ b/packages/java/readme-metrics/src/main/java/com/readme/dataextraction/UserDataSource.java
@@ -7,12 +7,12 @@ public enum UserDataSource {
HEADER("header"),
BODY("jsonBody"),
- JWT("jwtClaim");
+ JWT("jwt");
- private final String source;
+ private final String value;
- UserDataSource(String source) {
- this.source = source;
+ UserDataSource(String value) {
+ this.value = value;
}
}