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; } }