From 928569d542f0f2515a86c3c490ad7581b9a1afa2 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 10:11:19 -0600 Subject: [PATCH 01/15] feat: add TurnstileMetrics interface for optional Micrometer support --- .../cf/turnstile/metrics/TurnstileMetrics.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java new file mode 100644 index 0000000..aab8fb6 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java @@ -0,0 +1,14 @@ +package com.digitalsanctuary.cf.turnstile.metrics; + +import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType; + +/** + * Abstraction for recording Turnstile validation metrics. + * Implementations may be no-op (when Micrometer is absent) or Micrometer-backed. + */ +public interface TurnstileMetrics { + void recordValidation(); + void recordSuccess(); + void recordError(ValidationResultType type); + void recordResponseTime(long milliseconds); +} From f934a36325387ec698aeb652e50e3c1e841a62d6 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 10:13:02 -0600 Subject: [PATCH 02/15] docs: add JavaDoc param for TurnstileMetrics.recordError --- .../cf/turnstile/metrics/TurnstileMetrics.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java index aab8fb6..d47e14d 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java @@ -9,6 +9,18 @@ public interface TurnstileMetrics { void recordValidation(); void recordSuccess(); + + /** + * Records an error metric for a failed validation result. + * + * @param type the type of validation error that occurred. Expected values are: + * {@link ValidationResultType#NETWORK_ERROR}, + * {@link ValidationResultType#CONFIGURATION_ERROR}, + * {@link ValidationResultType#INVALID_TOKEN}, + * {@link ValidationResultType#INPUT_ERROR}. + * {@link ValidationResultType#SUCCESS} is never passed to this method. + */ void recordError(ValidationResultType type); + void recordResponseTime(long milliseconds); } From 23e6a3bb70c53927ed9e36b837f77f72981b5543 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 10:13:34 -0600 Subject: [PATCH 03/15] feat: add NoOpTurnstileMetrics for when Micrometer is absent --- .../metrics/NoOpTurnstileMetrics.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java new file mode 100644 index 0000000..31152c2 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java @@ -0,0 +1,21 @@ +package com.digitalsanctuary.cf.turnstile.metrics; + +import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType; + +/** + * No-op implementation of TurnstileMetrics used when Micrometer is not on the classpath. + */ +public class NoOpTurnstileMetrics implements TurnstileMetrics { + + @Override + public void recordValidation() {} + + @Override + public void recordSuccess() {} + + @Override + public void recordError(ValidationResultType type) {} + + @Override + public void recordResponseTime(long milliseconds) {} +} From 63d1c1022892dffe891855cd6672d210a222f375 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 10:15:41 -0600 Subject: [PATCH 04/15] feat: add MicrometerTurnstileMetrics implementation --- .../metrics/MicrometerTurnstileMetrics.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java new file mode 100644 index 0000000..5de0234 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java @@ -0,0 +1,72 @@ +package com.digitalsanctuary.cf.turnstile.metrics; + +import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; + +/** + * Micrometer-backed implementation of TurnstileMetrics. + * This class is only instantiated when Micrometer is present on the classpath. + */ +@Slf4j +public class MicrometerTurnstileMetrics implements TurnstileMetrics { + + private final Counter validationCounter; + private final Counter successCounter; + private final Counter errorCounter; + private final Counter networkErrorCounter; + private final Counter configErrorCounter; + private final Counter validationErrorCounter; + private final Counter inputErrorCounter; + private final Timer responseTimer; + + public MicrometerTurnstileMetrics(MeterRegistry registry) { + log.info("Initializing Turnstile metrics with MeterRegistry"); + validationCounter = Counter.builder("turnstile.validation.requests") + .description("Total number of Turnstile validation requests").register(registry); + successCounter = Counter.builder("turnstile.validation.success") + .description("Number of successful Turnstile validations").register(registry); + errorCounter = Counter.builder("turnstile.validation.errors") + .description("Number of failed Turnstile validations").register(registry); + networkErrorCounter = Counter.builder("turnstile.validation.errors.network") + .description("Number of Turnstile validation network errors").register(registry); + configErrorCounter = Counter.builder("turnstile.validation.errors.config") + .description("Number of Turnstile validation configuration errors").register(registry); + validationErrorCounter = Counter.builder("turnstile.validation.errors.token") + .description("Number of Turnstile validation token errors").register(registry); + inputErrorCounter = Counter.builder("turnstile.validation.errors.input") + .description("Number of Turnstile validation input errors").register(registry); + responseTimer = Timer.builder("turnstile.validation.response.time") + .description("Response time for Turnstile validation requests").register(registry); + } + + @Override + public void recordValidation() { + validationCounter.increment(); + } + + @Override + public void recordSuccess() { + successCounter.increment(); + } + + @Override + public void recordError(ValidationResultType type) { + errorCounter.increment(); + switch (type) { + case NETWORK_ERROR -> networkErrorCounter.increment(); + case CONFIGURATION_ERROR -> configErrorCounter.increment(); + case INVALID_TOKEN -> validationErrorCounter.increment(); + case INPUT_ERROR -> inputErrorCounter.increment(); + default -> {} + } + } + + @Override + public void recordResponseTime(long milliseconds) { + responseTimer.record(milliseconds, TimeUnit.MILLISECONDS); + } +} From 6a7a40d5935ea1b5722e1d24540919ec8e09da77 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 10:21:48 -0600 Subject: [PATCH 05/15] refactor: remove Micrometer imports from TurnstileValidationService, use TurnstileMetrics interface Also removes the now-orphaned manual TurnstileValidationService @Bean factory from TurnstileServiceConfig (the @Service annotation handles Spring registration; TurnstileMetrics will be autowired once Task 5 publishes the bean). --- .../config/TurnstileServiceConfig.java | 18 -- .../service/TurnstileValidationService.java | 295 +++--------------- 2 files changed, 51 insertions(+), 262 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java index 45ec2b2..ac42048 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java @@ -2,17 +2,12 @@ import java.net.http.HttpClient; import java.time.Duration; -import java.util.Optional; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; -import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService; -import io.micrometer.core.instrument.MeterRegistry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,19 +21,6 @@ public class TurnstileServiceConfig { private final TurnstileConfigProperties properties; - private final ObjectProvider meterRegistryProvider; - - /** - * Creates a TurnstileValidationService bean. - * - * @param restClient the preconfigured REST client for Turnstile calls - * @return a configured TurnstileValidationService instance - */ - @Bean - public TurnstileValidationService turnstileValidationService(@Qualifier("turnstileRestClient") RestClient restClient) { - Optional optionalRegistry = Optional.ofNullable(meterRegistryProvider.getIfAvailable()); - return new TurnstileValidationService(restClient, properties, optionalRegistry); - } /** * Creates a RestClient bean for Turnstile API interactions. diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java index 2d20a62..8489e7f 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java @@ -20,9 +20,7 @@ import com.digitalsanctuary.cf.turnstile.exception.TurnstileConfigurationException; import com.digitalsanctuary.cf.turnstile.exception.TurnstileNetworkException; import com.digitalsanctuary.cf.turnstile.exception.TurnstileValidationException; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Timer; +import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics; import jakarta.annotation.PostConstruct; import jakarta.servlet.ServletRequest; import jakarta.servlet.http.HttpServletRequest; @@ -44,9 +42,9 @@ public class TurnstileValidationService { private final RestClient turnstileRestClient; private final TurnstileConfigProperties properties; - private final Optional meterRegistry; + private final TurnstileMetrics metrics; - // Metrics (used regardless of whether Micrometer is available) + // Internal counters (always active, independent of Micrometer) private final LongAdder validationCount = new LongAdder(); private final LongAdder successCount = new LongAdder(); private final LongAdder errorCount = new LongAdder(); @@ -58,75 +56,18 @@ public class TurnstileValidationService { private final AtomicLong totalResponseTime = new AtomicLong(); private final AtomicLong responseCount = new AtomicLong(); - // Micrometer metrics - private Counter validationCounter; - private Counter successCounter; - private Counter errorCounter; - private Counter networkErrorCounter; - private Counter configErrorCounter; - private Counter validationErrorCounter; - private Counter inputErrorCounter; - private Timer responseTimer; - /** - * Constructor for TurnstileValidationService with metrics support. + * Constructor for TurnstileValidationService. * * @param turnstileRestClient the RestClient to use for making requests to the Turnstile API * @param properties the TurnstileConfigProperties to use for configuration - * @param meterRegistry the optional MeterRegistry for recording metrics + * @param metrics the TurnstileMetrics implementation for recording metrics */ - public TurnstileValidationService(@Qualifier("turnstileRestClient") RestClient turnstileRestClient, TurnstileConfigProperties properties, - Optional meterRegistry) { + public TurnstileValidationService(@Qualifier("turnstileRestClient") RestClient turnstileRestClient, + TurnstileConfigProperties properties, TurnstileMetrics metrics) { this.turnstileRestClient = turnstileRestClient; this.properties = properties; - this.meterRegistry = meterRegistry; - - // Initialize metrics if Micrometer is available - initMetrics(); - } - - /** - * Constructor for TurnstileValidationService without metrics support. - * - * @param turnstileRestClient the RestClient to use for making requests to the Turnstile API - * @param properties the TurnstileConfigProperties to use for configuration - */ - public TurnstileValidationService(@Qualifier("turnstileRestClient") RestClient turnstileRestClient, TurnstileConfigProperties properties) { - this(turnstileRestClient, properties, Optional.empty()); - } - - /** - * Initializes metrics if Micrometer is available. - */ - private void initMetrics() { - meterRegistry.ifPresent(registry -> { - log.info("Initializing Turnstile metrics with MeterRegistry"); - - // Initialize counters - validationCounter = - Counter.builder("turnstile.validation.requests").description("Total number of Turnstile validation requests").register(registry); - - successCounter = - Counter.builder("turnstile.validation.success").description("Number of successful Turnstile validations").register(registry); - - errorCounter = Counter.builder("turnstile.validation.errors").description("Number of failed Turnstile validations").register(registry); - - networkErrorCounter = Counter.builder("turnstile.validation.errors.network").description("Number of Turnstile validation network errors") - .register(registry); - - configErrorCounter = Counter.builder("turnstile.validation.errors.config") - .description("Number of Turnstile validation configuration errors").register(registry); - - validationErrorCounter = Counter.builder("turnstile.validation.errors.token").description("Number of Turnstile validation token errors") - .register(registry); - - inputErrorCounter = Counter.builder("turnstile.validation.errors.input").description("Number of Turnstile validation input errors") - .register(registry); - - // Initialize timer - responseTimer = Timer.builder("turnstile.validation.response.time").description("Response time for Turnstile validation requests") - .register(registry); - }); + this.metrics = metrics; } /** @@ -143,19 +84,16 @@ public void onStartup() { log.info("Turnstile Metrics enabled: {}", properties.getMetrics().isEnabled()); log.info("Turnstile Health Check enabled: {}", properties.getMetrics().isHealthCheckEnabled()); - // Validate required configuration if (properties.getSecret() == null || properties.getSecret().isBlank()) { log.error("Turnstile secret key is not configured. Validation will fail."); } - if (properties.getUrl() == null || properties.getUrl().isBlank()) { log.error("Turnstile URL is not configured. Validation will fail."); } } /** - * Validates the Turnstile response token by making a request to Cloudflare's Turnstile API. This is a convenience method that doesn't require a - * remote IP. + * Validates the Turnstile response token. Convenience method without remote IP. * * @param token the response token to be validated. * @return true if the response is valid and successful, false otherwise. @@ -165,8 +103,7 @@ public boolean validateTurnstileResponse(String token) { } /** - * Validates the Turnstile response token by making a request to Cloudflare's Turnstile API. This method returns a boolean result and handles all - * exceptions internally. + * Validates the Turnstile response token. Returns boolean and handles exceptions internally. * * @param token the response token to be validated. * @param remoteIp the remote IP address of the client (optional). @@ -183,8 +120,7 @@ public boolean validateTurnstileResponse(String token, String remoteIp) { } /** - * Validates the Turnstile response token by making a request to Cloudflare's Turnstile API. This is a convenience method that doesn't require a - * remote IP. + * Validates the Turnstile response token. Convenience method without remote IP. * * @param token the response token to be validated. * @return a ValidationResult object with detailed information about the validation outcome. @@ -197,8 +133,7 @@ public ValidationResult validateTurnstileResponseDetailed(String token) { } /** - * Validates the Turnstile response token by making a request to Cloudflare's Turnstile API. This method provides detailed validation results and - * throws specific exceptions for different error scenarios. + * Validates the Turnstile response token with detailed results and typed exceptions. * * @param token the response token to be validated. * @param remoteIp the remote IP address of the client (optional). @@ -208,16 +143,12 @@ public ValidationResult validateTurnstileResponseDetailed(String token) { * @throws TurnstileValidationException if the token is rejected by Cloudflare */ public ValidationResult validateTurnstileResponseDetailed(String token, String remoteIp) { - // Start tracking metrics for this validation attempt long startTime = System.currentTimeMillis(); validationCount.increment(); - if (validationCounter != null) { - validationCounter.increment(); - } + metrics.recordValidation(); log.trace("Starting validation for token: {} with remoteIp: {}", token, remoteIp); - // Validate input parameters if (token == null) { log.warn("Turnstile validation failed: token cannot be null"); recordError(ValidationResultType.INPUT_ERROR); @@ -230,22 +161,18 @@ public ValidationResult validateTurnstileResponseDetailed(String token, String r return ValidationResult.inputError("Token cannot be empty or blank"); } - // Basic format validation - Cloudflare tokens typically start with '0.' or '1.' followed by alphanumeric chars - // and should be reasonably sized (typically 100+ chars) if (token.length() < MIN_TOKEN_LENGTH) { log.warn("Turnstile validation failed: token appears to be too short to be valid (length: {})", token.length()); recordError(ValidationResultType.INPUT_ERROR); return ValidationResult.inputError("Token is too short to be valid (length: " + token.length() + ")"); } - // Validate remoteIp if provided String cleanRemoteIp = remoteIp; if (cleanRemoteIp != null && (cleanRemoteIp.isEmpty() || cleanRemoteIp.isBlank())) { log.warn("Turnstile validation: ignoring empty or blank remoteIp"); cleanRemoteIp = null; } - // Validate that we have the required configuration if (properties.getSecret() == null || properties.getSecret().isBlank()) { String msg = "Turnstile secret key is not configured"; log.error(msg); @@ -260,7 +187,6 @@ public ValidationResult validateTurnstileResponseDetailed(String token, String r throw new TurnstileConfigurationException(msg); } - // Create a JSON request body Map requestBody = new HashMap<>(); requestBody.put("secret", properties.getSecret()); requestBody.put("response", token); @@ -268,61 +194,41 @@ public ValidationResult validateTurnstileResponseDetailed(String token, String r log.trace("Making request to Cloudflare Turnstile API at: {}", properties.getUrl()); - // Make the request to Cloudflare's Turnstile API try { - // Use timer if available - if (responseTimer != null) { - return responseTimer.record(() -> { - return executeValidationRequest(requestBody, startTime); - }); - } else { - return executeValidationRequest(requestBody, startTime); - } + ValidationResult result = executeValidationRequest(requestBody); + long elapsed = System.currentTimeMillis() - startTime; + lastResponseTime.set(elapsed); + totalResponseTime.addAndGet(elapsed); + responseCount.incrementAndGet(); + metrics.recordResponseTime(elapsed); + return result; } catch (HttpClientErrorException e) { - // 4xx response status codes (client errors) log.error("Client error during Turnstile validation: {}", e.getMessage(), e); recordError(ValidationResultType.NETWORK_ERROR); throw new TurnstileNetworkException("Client error: " + e.getMessage(), e); } catch (HttpServerErrorException e) { - // 5xx response status codes (server errors) log.error("Server error during Turnstile validation: {}", e.getMessage(), e); recordError(ValidationResultType.NETWORK_ERROR); throw new TurnstileNetworkException("Server error: " + e.getMessage(), e); } catch (ResourceAccessException e) { - // Network-related exceptions (timeouts, connection errors, etc.) log.error("Network error during Turnstile validation: {}", e.getMessage(), e); recordError(ValidationResultType.NETWORK_ERROR); throw new TurnstileNetworkException("Network error: " + e.getMessage(), e); } catch (TurnstileValidationException e) { - // Re-throw the TurnstileValidationException recordError(ValidationResultType.INVALID_TOKEN); throw e; } catch (Exception e) { - // Catch-all for any other unexpected exceptions log.error("Unexpected error during Turnstile validation: {}", e.getMessage(), e); recordError(ValidationResultType.NETWORK_ERROR); throw new TurnstileNetworkException("Unexpected error: " + e.getMessage(), e); } } - /** - * Executes the actual validation request to Cloudflare Turnstile API. - * - * @param requestBody the request body to send - * @param startTime the start time of the validation for metrics tracking - * @return the validation result - */ - private ValidationResult executeValidationRequest(Map requestBody, long startTime) { + private ValidationResult executeValidationRequest(Map requestBody) { TurnstileResponse response = turnstileRestClient.post().uri(properties.getUrl()) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).body(requestBody).retrieve().body(TurnstileResponse.class); - // Record response time - long responseTime = System.currentTimeMillis() - startTime; - lastResponseTime.set(responseTime); - totalResponseTime.addAndGet(responseTime); - responseCount.incrementAndGet(); - - log.debug("Turnstile response: {} (took {}ms)", response, responseTime); + log.debug("Turnstile response: {}", response); if (response == null) { log.warn("Turnstile API returned null response"); @@ -333,9 +239,7 @@ private ValidationResult executeValidationRequest(Map requestBod if (response.isSuccess()) { log.debug("Turnstile validation successful"); successCount.increment(); - if (successCounter != null) { - successCounter.increment(); - } + metrics.recordSuccess(); return ValidationResult.success(); } else { log.warn("Turnstile validation failed with error codes: {}", response.getErrorCodes()); @@ -344,159 +248,63 @@ private ValidationResult executeValidationRequest(Map requestBod } } - /** - * Records an error in the metrics based on the validation result type. - * - * @param resultType the type of validation result - */ private void recordError(ValidationResultType resultType) { errorCount.increment(); - if (errorCounter != null) { - errorCounter.increment(); - } + metrics.recordError(resultType); switch (resultType) { - case NETWORK_ERROR: - networkErrorCount.increment(); - if (networkErrorCounter != null) { - networkErrorCounter.increment(); - } - break; - case CONFIGURATION_ERROR: - configErrorCount.increment(); - if (configErrorCounter != null) { - configErrorCounter.increment(); - } - break; - case INVALID_TOKEN: - validationErrorCount.increment(); - if (validationErrorCounter != null) { - validationErrorCounter.increment(); - } - break; - case INPUT_ERROR: - inputErrorCount.increment(); - if (inputErrorCounter != null) { - inputErrorCounter.increment(); - } - break; - default: - // No specific counter for SUCCESS or other types - break; + case NETWORK_ERROR -> networkErrorCount.increment(); + case CONFIGURATION_ERROR -> configErrorCount.increment(); + case INVALID_TOKEN -> validationErrorCount.increment(); + case INPUT_ERROR -> inputErrorCount.increment(); + default -> {} } } - /** - * Gets the total number of validation attempts. - * - * @return the total number of validation attempts - */ - public long getValidationCount() { - return validationCount.sum(); - } + /** @return total number of validation attempts */ + public long getValidationCount() { return validationCount.sum(); } - /** - * Gets the number of successful validations. - * - * @return the number of successful validations - */ - public long getSuccessCount() { - return successCount.sum(); - } + /** @return number of successful validations */ + public long getSuccessCount() { return successCount.sum(); } - /** - * Gets the number of failed validations. - * - * @return the number of failed validations - */ - public long getErrorCount() { - return errorCount.sum(); - } + /** @return number of failed validations */ + public long getErrorCount() { return errorCount.sum(); } - /** - * Gets the number of network errors. - * - * @return the number of network errors - */ - public long getNetworkErrorCount() { - return networkErrorCount.sum(); - } + /** @return number of network errors */ + public long getNetworkErrorCount() { return networkErrorCount.sum(); } - /** - * Gets the number of configuration errors. - * - * @return the number of configuration errors - */ - public long getConfigErrorCount() { - return configErrorCount.sum(); - } + /** @return number of configuration errors */ + public long getConfigErrorCount() { return configErrorCount.sum(); } - /** - * Gets the number of validation errors (invalid tokens). - * - * @return the number of validation errors - */ - public long getValidationErrorCount() { - return validationErrorCount.sum(); - } + /** @return number of validation errors (invalid tokens) */ + public long getValidationErrorCount() { return validationErrorCount.sum(); } - /** - * Gets the number of input validation errors. - * - * @return the number of input validation errors - */ - public long getInputErrorCount() { - return inputErrorCount.sum(); - } + /** @return number of input validation errors */ + public long getInputErrorCount() { return inputErrorCount.sum(); } - /** - * Gets the time of the last response in milliseconds. - * - * @return the time of the last response in milliseconds - */ - public long getLastResponseTime() { - return lastResponseTime.get(); - } + /** @return time of last response in milliseconds */ + public long getLastResponseTime() { return lastResponseTime.get(); } - /** - * Gets the average response time in milliseconds. - * - * @return the average response time in milliseconds, or 0 if no responses have been received - */ + /** @return average response time in milliseconds, or 0 if no responses yet */ public double getAverageResponseTime() { long count = responseCount.get(); return count > 0 ? (double) totalResponseTime.get() / count : 0; } - /** - * Gets the error rate as a percentage of total validation attempts. - * - * @return the error rate as a percentage (0-100), or 0 if no validation attempts have been made - */ + /** @return error rate as a percentage (0-100), or 0 if no attempts yet */ public double getErrorRate() { long total = validationCount.sum(); return total > 0 ? (double) errorCount.sum() * 100 / total : 0; } /** - * Gets the Turnstile Sitekey. - * - * @return the Turnstile Sitekey. - * @deprecated Use {@link #getTurnstileSitekey()} instead. Will be removed in a future version. + * @deprecated Use {@link #getTurnstileSitekey()} instead. */ @Deprecated - public String getTurnsiteSitekey() { - return getTurnstileSitekey(); - } + public String getTurnsiteSitekey() { return getTurnstileSitekey(); } - /** - * Gets the Turnstile Sitekey. - * - * @return the Turnstile Sitekey. - */ - public String getTurnstileSitekey() { - return properties.getSitekey(); - } + /** @return the Turnstile Sitekey */ + public String getTurnstileSitekey() { return properties.getSitekey(); } /** * Gets the client IP address from the ServletRequest. @@ -512,7 +320,6 @@ public String getClientIpAddress(ServletRequest request) { if (ipHeaderValue == null || ipHeaderValue.isBlank()) { continue; } - String candidate = ipHeaderValue.split(",", 2)[0].trim(); if (!candidate.isEmpty() && !UNKNOWN.equalsIgnoreCase(candidate)) { return candidate; From a8d89bf7efa902727d4045c182cf025c774ffa4d Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 10:29:00 -0600 Subject: [PATCH 06/15] fix: record response time for all HTTP-level outcomes, not just success --- .../service/TurnstileValidationService.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java index 8489e7f..c9c72e3 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java @@ -195,13 +195,7 @@ public ValidationResult validateTurnstileResponseDetailed(String token, String r log.trace("Making request to Cloudflare Turnstile API at: {}", properties.getUrl()); try { - ValidationResult result = executeValidationRequest(requestBody); - long elapsed = System.currentTimeMillis() - startTime; - lastResponseTime.set(elapsed); - totalResponseTime.addAndGet(elapsed); - responseCount.incrementAndGet(); - metrics.recordResponseTime(elapsed); - return result; + return executeValidationRequest(requestBody); } catch (HttpClientErrorException e) { log.error("Client error during Turnstile validation: {}", e.getMessage(), e); recordError(ValidationResultType.NETWORK_ERROR); @@ -221,6 +215,12 @@ public ValidationResult validateTurnstileResponseDetailed(String token, String r log.error("Unexpected error during Turnstile validation: {}", e.getMessage(), e); recordError(ValidationResultType.NETWORK_ERROR); throw new TurnstileNetworkException("Unexpected error: " + e.getMessage(), e); + } finally { + long elapsed = System.currentTimeMillis() - startTime; + lastResponseTime.set(elapsed); + totalResponseTime.addAndGet(elapsed); + responseCount.incrementAndGet(); + metrics.recordResponseTime(elapsed); } } From 445374ffd1e74efafe2c1a09893cfd801e0f583d Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 10:38:05 -0600 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20wire=20TurnstileMetrics=20beans?= =?UTF-8?q?=20=E2=80=94=20Micrometer-backed=20or=20no-op=20based=20on=20cl?= =?UTF-8?q?asspath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TurnstileServiceConfig: add noOpTurnstileMetrics() (@ConditionalOnMissingBean) and turnstileValidationService() factory method injecting TurnstileMetrics - TurnstileMetricsConfig: add micrometerTurnstileMetrics() bean (@ConditionalOnClass(MeterRegistry.class)) - TurnstileValidationService: remove @Service — bean is now registered exclusively via factory method --- .../config/TurnstileMetricsConfig.java | 34 +++++++++-------- .../config/TurnstileServiceConfig.java | 38 ++++++++++++++++--- .../service/TurnstileValidationService.java | 2 - 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java index bdb3910..df5237e 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java @@ -5,7 +5,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; - +import com.digitalsanctuary.cf.turnstile.metrics.MicrometerTurnstileMetrics; +import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.config.MeterFilter; @@ -14,10 +15,7 @@ /** * Configuration for Turnstile metrics and monitoring. - *

- * This class configures the metrics for Cloudflare Turnstile service. It sets up common tags and filters for all metrics related to the Turnstile - * service. The metrics are only configured if micrometer-core is on the classpath and metrics are enabled in the configuration. - *

+ * Only loaded when Micrometer is on the classpath and metrics are enabled. */ @Slf4j @Configuration @@ -26,20 +24,26 @@ public class TurnstileMetricsConfig { /** - * Customizes the meter registry for Turnstile metrics. - *

- * Adds a common tag 'component:turnstile' to all Turnstile-related metrics and configures a prefix for all Turnstile metrics. - *

+ * Registers the Micrometer-backed TurnstileMetrics bean. + * + * @param registry the MeterRegistry to use for metrics + * @return a MicrometerTurnstileMetrics instance + */ + @Bean + public TurnstileMetrics micrometerTurnstileMetrics(MeterRegistry registry) { + return new MicrometerTurnstileMetrics(registry); + } + + /** + * Customizes the meter registry with Turnstile-specific tags and filters. * - * @return a MeterRegistryCustomizer to customize the MeterRegistry + * @return a MeterRegistryCustomizer for the MeterRegistry */ @Bean public MeterRegistryCustomizer turnstileMeterRegistryCustomizer() { log.info("Configuring Turnstile metrics"); - return registry -> { - // Add a common tag to all turnstile metrics - registry.config().meterFilter(MeterFilter.acceptNameStartsWith("turnstile")) - .meterFilter(MeterFilter.commonTags(Collections.singletonList(Tag.of("component", "turnstile")))); - }; + return registry -> registry.config() + .meterFilter(MeterFilter.acceptNameStartsWith("turnstile")) + .meterFilter(MeterFilter.commonTags(Collections.singletonList(Tag.of("component", "turnstile")))); } } diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java index ac42048..23756e0 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java @@ -2,18 +2,22 @@ import java.net.http.HttpClient; import java.time.Duration; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; +import com.digitalsanctuary.cf.turnstile.metrics.NoOpTurnstileMetrics; +import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics; +import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** - * Configuration class for setting up Turnstile related beans. This class configures the RestTemplate and RestClient used for Turnstile API - * interactions, and initializes the TurnstileValidationService. + * Configuration class for setting up Turnstile related beans. */ @Slf4j @Configuration @@ -22,6 +26,33 @@ public class TurnstileServiceConfig { private final TurnstileConfigProperties properties; + /** + * Provides a no-op TurnstileMetrics bean when no other implementation is registered. + * This is the fallback when Micrometer is not on the classpath. + * + * @return a no-op TurnstileMetrics instance + */ + @Bean + @ConditionalOnMissingBean(TurnstileMetrics.class) + public TurnstileMetrics noOpTurnstileMetrics() { + log.info("Micrometer not available — using no-op Turnstile metrics"); + return new NoOpTurnstileMetrics(); + } + + /** + * Creates a TurnstileValidationService bean. + * + * @param restClient the preconfigured REST client for Turnstile calls + * @param metrics the TurnstileMetrics implementation to use + * @return a configured TurnstileValidationService instance + */ + @Bean + public TurnstileValidationService turnstileValidationService( + @Qualifier("turnstileRestClient") RestClient restClient, + TurnstileMetrics metrics) { + return new TurnstileValidationService(restClient, properties, metrics); + } + /** * Creates a RestClient bean for Turnstile API interactions. * @@ -33,12 +64,10 @@ public RestClient turnstileRestClient() { log.info("Turnstile REST client timeouts - connect: {}s, read: {}s", properties.getConnectTimeout(), properties.getReadTimeout()); - // Configure HttpClient with timeouts HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(properties.getConnectTimeout())) .build(); - // Create request factory with the configured HttpClient JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); requestFactory.setReadTimeout(Duration.ofSeconds(properties.getReadTimeout())); @@ -49,5 +78,4 @@ public RestClient turnstileRestClient() { .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .build(); } - } diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java index c9c72e3..ac12379 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java @@ -8,7 +8,6 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.ResourceAccessException; @@ -35,7 +34,6 @@ *

*/ @Slf4j -@Service public class TurnstileValidationService { private static final String UNKNOWN = "unknown"; private static final int MIN_TOKEN_LENGTH = 20; From 0b8f2136f691198161d04f2a94408fda14073407 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 10:40:38 -0600 Subject: [PATCH 08/15] fix: add @ConditionalOnMissingBean to allow custom TurnstileMetrics override --- .../cf/turnstile/config/TurnstileMetricsConfig.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java index df5237e..9d5dc39 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java @@ -2,6 +2,7 @@ import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -30,6 +31,7 @@ public class TurnstileMetricsConfig { * @return a MicrometerTurnstileMetrics instance */ @Bean + @ConditionalOnMissingBean(TurnstileMetrics.class) public TurnstileMetrics micrometerTurnstileMetrics(MeterRegistry registry) { return new MicrometerTurnstileMetrics(registry); } From b0020298ce91dd2d4c26d3b06464577603271e2f Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 10:41:35 -0600 Subject: [PATCH 09/15] fix: use string-form @ConditionalOnClass in TurnstileConfiguration to prevent NoClassDefFoundError when Micrometer is absent --- .../cf/turnstile/TurnstileConfiguration.java | 44 +++++-------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java b/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java index c36b92c..b49ffec 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java @@ -5,43 +5,19 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; - import com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties; import com.digitalsanctuary.cf.turnstile.config.TurnstileHealthIndicator; import com.digitalsanctuary.cf.turnstile.config.TurnstileMetricsConfig; import com.digitalsanctuary.cf.turnstile.config.TurnstileServiceConfig; import com.digitalsanctuary.cf.turnstile.filter.TurnstileCaptchaFilter; -import io.micrometer.core.instrument.MeterRegistry; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; /** * Main auto-configuration class for the Spring Cloudflare Turnstile integration. *

- * This class serves as the entry point for Spring Boot's auto-configuration mechanism to automatically set up Cloudflare Turnstile integration when - * the library is included in a project. It imports the necessary configuration components such as property management, service configuration, and - * metrics/monitoring configuration. - *

- *

- * To use this auto-configuration, include this library in your Spring Boot project and configure the required properties in your application.yml or - * application.properties file: - *

- * - *
- * ds:
- *   cf:
- *     turnstile:
- *       sitekey: your-turnstile-site-key
- *       secret: your-turnstile-secret-key
- *       url: https://challenges.cloudflare.com/turnstile/v0/siteverify
- *       metrics:
- *         enabled: true
- *         health-check-enabled: true
- *         error-threshold: 10
- * 
- *

- * The {@link #onStartup()} method is annotated with {@link jakarta.annotation.PostConstruct} and is executed after the bean initialization to log a - * confirmation message that the Cloudflare Turnstile Service has been loaded. + * Imports core configuration unconditionally; metrics and health configurations are + * conditional on the presence of their respective classes on the classpath. *

* * @see com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties @@ -57,16 +33,21 @@ public class TurnstileConfiguration { /** - * Metrics configuration for Turnstile. Only imported if MeterRegistry is available on the classpath. + * Metrics configuration for Turnstile. + * Only imported if Micrometer's MeterRegistry is available on the classpath. + * Uses string form of @ConditionalOnClass to avoid encoding a bytecode reference + * to MeterRegistry in this class, which would cause NoClassDefFoundError when + * Micrometer is absent. */ @Configuration - @ConditionalOnClass(MeterRegistry.class) + @ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry") @Import(TurnstileMetricsConfig.class) static class TurnstileMetricsConfiguration { } /** - * Health indicator configuration for Turnstile. Only imported if Spring Actuator health is enabled. + * Health indicator configuration for Turnstile. + * Only imported if Spring Actuator's HealthIndicator is available on the classpath. */ @Configuration @ConditionalOnEnabledHealthIndicator("turnstile") @@ -76,10 +57,7 @@ static class TurnstileHealthConfiguration { } /** - * Method executed after the bean initialization. - *

- * This method logs a message indicating that the DigitalSanctuary Cloudflare Turnstile Service has been loaded. - *

+ * Logs confirmation that the Turnstile service has been loaded. */ @PostConstruct public void onStartup() { From b22f75c353df22e9248386eda4c72e855f593001 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 10:50:42 -0600 Subject: [PATCH 10/15] =?UTF-8?q?test:=20add=20regression=20tests=20for=20?= =?UTF-8?q?issue=20#94=20=E2=80=94=20library=20loads=20without=20Actuator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies the library starts correctly when Micrometer auto-configurations are excluded via spring.autoconfigure.exclude, covering context load, metrics bean availability, validation functionality, and internal counter tracking without throwing NoClassDefFoundError. --- .../TurnstileWithoutActuatorTest.java | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java diff --git a/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java b/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java new file mode 100644 index 0000000..87f0081 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java @@ -0,0 +1,79 @@ +package com.digitalsanctuary.cf.test.turnstile; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; +import com.digitalsanctuary.cf.test.TestApplication; +import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics; +import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService; + +/** + * Regression tests for GitHub issue #94. + * Verifies the library starts without NoClassDefFoundError when Micrometer or Actuator is absent. + */ +@SpringBootTest( + classes = TestApplication.class, + properties = { + "spring.autoconfigure.exclude=" + + "org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration," + + "org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration" + } +) +@ActiveProfiles("test") +class TurnstileWithoutActuatorTest { + + @Autowired + private TurnstileValidationService turnstileValidationService; + + @Autowired + private TurnstileMetrics turnstileMetrics; + + @Autowired + private ApplicationContext applicationContext; + + /** + * Verifies TurnstileValidationService is available when Micrometer auto-configs are excluded. + */ + @Test + void contextLoadsWithoutMicrometer() { + assertNotNull(turnstileValidationService, "TurnstileValidationService should be available"); + } + + /** + * Verifies a TurnstileMetrics bean is available when Micrometer auto-configs are excluded. + * Regression test for issue #94: unconditionally-loaded classes with direct Micrometer type + * references caused NoClassDefFoundError when Micrometer was absent. The core fix ensures + * that a metrics bean (of any implementation) is always available without throwing errors. + */ + @Test + void metricsAvailableWhenMicrometerAutoConfigExcluded() { + assertNotNull(turnstileMetrics, + "A TurnstileMetrics bean should be available even with Micrometer auto-config excluded"); + } + + /** + * Verifies validation still works (exercises the metrics recording code path via NoOp). + */ + @Test + void validationWorksWithoutMicrometer() { + boolean result = turnstileValidationService.validateTurnstileResponse("short"); + assertFalse(result, "Short token should fail validation with false return (not throw)"); + } + + /** + * Verifies internal counters still work without Micrometer (they use LongAdder/AtomicLong). + */ + @Test + void internalCountersWorkWithoutMicrometer() { + long before = turnstileValidationService.getValidationCount(); + turnstileValidationService.validateTurnstileResponse("short"); + long after = turnstileValidationService.getValidationCount(); + assertNotNull(after, "Validation count should be trackable"); + assertTrue(after > before, "Validation count should have incremented"); + } +} From db09a03153ef8bb8f3645a208ac61f10d10f5b70 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 10:56:21 -0600 Subject: [PATCH 11/15] test: rename and correct metrics wiring sanity checks (not #94 classpath regression) --- ...t.java => TurnstileMetricsWiringTest.java} | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) rename src/test/java/com/digitalsanctuary/cf/test/turnstile/{TurnstileWithoutActuatorTest.java => TurnstileMetricsWiringTest.java} (68%) diff --git a/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java b/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java similarity index 68% rename from src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java rename to src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java index 87f0081..e83bd4a 100644 --- a/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java +++ b/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java @@ -1,20 +1,24 @@ package com.digitalsanctuary.cf.test.turnstile; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.ApplicationContext; import org.springframework.test.context.ActiveProfiles; import com.digitalsanctuary.cf.test.TestApplication; +import com.digitalsanctuary.cf.turnstile.metrics.NoOpTurnstileMetrics; import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics; import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService; /** - * Regression tests for GitHub issue #94. - * Verifies the library starts without NoClassDefFoundError when Micrometer or Actuator is absent. + * Metrics wiring sanity checks that verify auto-configuration exclusions work correctly. + * + *

Note: Micrometer is still on the test classpath (via spring-boot-starter-actuator), so these + * tests cannot detect a bytecode-level {@code NoClassDefFoundError} regression. They verify that + * Spring auto-configuration exclusions are respected and that the correct metrics implementation + * is active given the classpath conditions. */ @SpringBootTest( classes = TestApplication.class, @@ -25,7 +29,7 @@ } ) @ActiveProfiles("test") -class TurnstileWithoutActuatorTest { +class TurnstileMetricsWiringTest { @Autowired private TurnstileValidationService turnstileValidationService; @@ -33,27 +37,16 @@ class TurnstileWithoutActuatorTest { @Autowired private TurnstileMetrics turnstileMetrics; - @Autowired - private ApplicationContext applicationContext; - - /** - * Verifies TurnstileValidationService is available when Micrometer auto-configs are excluded. - */ - @Test - void contextLoadsWithoutMicrometer() { - assertNotNull(turnstileValidationService, "TurnstileValidationService should be available"); - } - /** - * Verifies a TurnstileMetrics bean is available when Micrometer auto-configs are excluded. - * Regression test for issue #94: unconditionally-loaded classes with direct Micrometer type - * references caused NoClassDefFoundError when Micrometer was absent. The core fix ensures - * that a metrics bean (of any implementation) is always available without throwing errors. + * Verifies that a TurnstileMetrics bean is available when metrics auto-configs are excluded. + * Because Micrometer is still on the test classpath, MicrometerTurnstileMetrics remains active. */ @Test void metricsAvailableWhenMicrometerAutoConfigExcluded() { assertNotNull(turnstileMetrics, "A TurnstileMetrics bean should be available even with Micrometer auto-config excluded"); + assertFalse(turnstileMetrics instanceof NoOpTurnstileMetrics, + "MicrometerTurnstileMetrics should be active when Micrometer is on the classpath"); } /** @@ -73,7 +66,6 @@ void internalCountersWorkWithoutMicrometer() { long before = turnstileValidationService.getValidationCount(); turnstileValidationService.validateTurnstileResponse("short"); long after = turnstileValidationService.getValidationCount(); - assertNotNull(after, "Validation count should be trackable"); assertTrue(after > before, "Validation count should have incremented"); } } From 8974419f0963d9d0cc72fd49f3f7156dac886bf5 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 10:59:37 -0600 Subject: [PATCH 12/15] fix: resolve PMD and Checkstyle violations in new metrics classes --- .../metrics/MicrometerTurnstileMetrics.java | 2 +- .../metrics/NoOpTurnstileMetrics.java | 12 +- .../service/TurnstileValidationService.java | 113 ++++++++++++++---- 3 files changed, 99 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java index 5de0234..279dc9e 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java @@ -61,7 +61,7 @@ public void recordError(ValidationResultType type) { case CONFIGURATION_ERROR -> configErrorCounter.increment(); case INVALID_TOKEN -> validationErrorCounter.increment(); case INPUT_ERROR -> inputErrorCounter.increment(); - default -> {} + default -> { } } } diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java index 31152c2..3cf446a 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java @@ -8,14 +8,18 @@ public class NoOpTurnstileMetrics implements TurnstileMetrics { @Override - public void recordValidation() {} + public void recordValidation() { // no-op + } @Override - public void recordSuccess() {} + public void recordSuccess() { // no-op + } @Override - public void recordError(ValidationResultType type) {} + public void recordError(ValidationResultType type) { // no-op + } @Override - public void recordResponseTime(long milliseconds) {} + public void recordResponseTime(long milliseconds) { // no-op + } } diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java index ac12379..e59c937 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java @@ -255,54 +255,121 @@ private void recordError(ValidationResultType resultType) { case CONFIGURATION_ERROR -> configErrorCount.increment(); case INVALID_TOKEN -> validationErrorCount.increment(); case INPUT_ERROR -> inputErrorCount.increment(); - default -> {} + default -> { } } } - /** @return total number of validation attempts */ - public long getValidationCount() { return validationCount.sum(); } + /** + * Gets the total number of validation attempts. + * + * @return total number of validation attempts + */ + public long getValidationCount() { + return validationCount.sum(); + } - /** @return number of successful validations */ - public long getSuccessCount() { return successCount.sum(); } + /** + * Gets the number of successful validations. + * + * @return number of successful validations + */ + public long getSuccessCount() { + return successCount.sum(); + } - /** @return number of failed validations */ - public long getErrorCount() { return errorCount.sum(); } + /** + * Gets the number of failed validations. + * + * @return number of failed validations + */ + public long getErrorCount() { + return errorCount.sum(); + } - /** @return number of network errors */ - public long getNetworkErrorCount() { return networkErrorCount.sum(); } + /** + * Gets the number of network errors. + * + * @return number of network errors + */ + public long getNetworkErrorCount() { + return networkErrorCount.sum(); + } - /** @return number of configuration errors */ - public long getConfigErrorCount() { return configErrorCount.sum(); } + /** + * Gets the number of configuration errors. + * + * @return number of configuration errors + */ + public long getConfigErrorCount() { + return configErrorCount.sum(); + } - /** @return number of validation errors (invalid tokens) */ - public long getValidationErrorCount() { return validationErrorCount.sum(); } + /** + * Gets the number of validation errors (invalid tokens). + * + * @return number of validation errors + */ + public long getValidationErrorCount() { + return validationErrorCount.sum(); + } - /** @return number of input validation errors */ - public long getInputErrorCount() { return inputErrorCount.sum(); } + /** + * Gets the number of input validation errors. + * + * @return number of input validation errors + */ + public long getInputErrorCount() { + return inputErrorCount.sum(); + } - /** @return time of last response in milliseconds */ - public long getLastResponseTime() { return lastResponseTime.get(); } + /** + * Gets the time of the last response in milliseconds. + * + * @return time of last response in milliseconds + */ + public long getLastResponseTime() { + return lastResponseTime.get(); + } - /** @return average response time in milliseconds, or 0 if no responses yet */ + /** + * Gets the average response time in milliseconds. + * + * @return average response time in milliseconds, or 0 if no responses yet + */ public double getAverageResponseTime() { long count = responseCount.get(); return count > 0 ? (double) totalResponseTime.get() / count : 0; } - /** @return error rate as a percentage (0-100), or 0 if no attempts yet */ + /** + * Gets the error rate as a percentage of total validation attempts. + * + * @return error rate as a percentage (0-100), or 0 if no attempts yet + */ public double getErrorRate() { long total = validationCount.sum(); return total > 0 ? (double) errorCount.sum() * 100 / total : 0; } /** - * @deprecated Use {@link #getTurnstileSitekey()} instead. + * Gets the Turnstile Sitekey. + * + * @return the Turnstile Sitekey. + * @deprecated Use {@link #getTurnstileSitekey()} instead. Will be removed in a future version. */ @Deprecated - public String getTurnsiteSitekey() { return getTurnstileSitekey(); } + public String getTurnsiteSitekey() { + return getTurnstileSitekey(); + } - /** @return the Turnstile Sitekey */ - public String getTurnstileSitekey() { return properties.getSitekey(); } + /** + * Gets the Turnstile Sitekey. + * + * @return the Turnstile Sitekey + */ + public String getTurnstileSitekey() { + return properties.getSitekey(); + } /** * Gets the client IP address from the ServletRequest. From 62a885530760e1204b182abb0829de00282eb236 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 11:32:02 -0600 Subject: [PATCH 13/15] docs: add implementation plan for issue #94 fix --- .../2026-05-01-optional-micrometer-fix.md | 1003 +++++++++++++++++ 1 file changed, 1003 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-optional-micrometer-fix.md diff --git a/docs/superpowers/plans/2026-05-01-optional-micrometer-fix.md b/docs/superpowers/plans/2026-05-01-optional-micrometer-fix.md new file mode 100644 index 0000000..d5e6bbb --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-optional-micrometer-fix.md @@ -0,0 +1,1003 @@ +# Optional Micrometer Dependency Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix `NoClassDefFoundError: io/micrometer/core/instrument/MeterRegistry` when Micrometer is absent by isolating all Micrometer references behind classes that are only loaded when Micrometer is on the classpath. + +**Architecture:** Introduce a `TurnstileMetrics` interface with a no-op default and a Micrometer-backed implementation. Classes that are always loaded (`TurnstileValidationService`, `TurnstileServiceConfig`, `TurnstileConfiguration`) are purged of all direct Micrometer type references. The Micrometer implementation lives in a class only loaded when `@ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry")` is true. + +**Tech Stack:** Java 17, Spring Boot 4.x auto-configuration, Micrometer (optional), Lombok, JUnit 5, Gradle + +--- + +## File Map + +**New files:** +- `src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java` — interface; no Micrometer imports +- `src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java` — active when Micrometer absent; no Micrometer imports +- `src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java` — active when Micrometer present; contains all Micrometer imports/references +- `src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java` — Spring context test that excludes MeterRegistry to verify no-actuator loading + +**Modified files:** +- `src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java` — remove `Optional`, `Counter`, `Timer` fields; take `TurnstileMetrics` instead +- `src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java` — remove `ObjectProvider`; add `@ConditionalOnMissingBean` no-op provider; wire `TurnstileMetrics` into service +- `src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java` — add bean that registers `MicrometerTurnstileMetrics` into Spring context +- `src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java` — switch `@ConditionalOnClass(MeterRegistry.class)` to string form; remove Micrometer import + +--- + +## Task 1: Create `TurnstileMetrics` interface + +**Files:** +- Create: `src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java` + +- [ ] **Step 1: Create the interface** + +```java +package com.digitalsanctuary.cf.turnstile.metrics; + +import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType; + +/** + * Abstraction for recording Turnstile validation metrics. + * Implementations may be no-op (when Micrometer is absent) or Micrometer-backed. + */ +public interface TurnstileMetrics { + void recordValidation(); + void recordSuccess(); + void recordError(ValidationResultType type); + void recordResponseTime(long milliseconds); +} +``` + +- [ ] **Step 2: Compile to verify no issues** + +```bash +./gradlew compileJava +``` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java +git commit -m "feat: add TurnstileMetrics interface for optional Micrometer support" +``` + +--- + +## Task 2: Create `NoOpTurnstileMetrics` + +**Files:** +- Create: `src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java` + +- [ ] **Step 1: Create the no-op implementation** + +```java +package com.digitalsanctuary.cf.turnstile.metrics; + +import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType; + +/** + * No-op implementation of TurnstileMetrics used when Micrometer is not on the classpath. + */ +public class NoOpTurnstileMetrics implements TurnstileMetrics { + + @Override + public void recordValidation() {} + + @Override + public void recordSuccess() {} + + @Override + public void recordError(ValidationResultType type) {} + + @Override + public void recordResponseTime(long milliseconds) {} +} +``` + +- [ ] **Step 2: Compile to verify** + +```bash +./gradlew compileJava +``` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java +git commit -m "feat: add NoOpTurnstileMetrics for when Micrometer is absent" +``` + +--- + +## Task 3: Create `MicrometerTurnstileMetrics` + +This class contains ALL Micrometer imports for the library. It must never be referenced as a type in any class that loads unconditionally. + +**Files:** +- Create: `src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java` + +- [ ] **Step 1: Create the Micrometer implementation** + +```java +package com.digitalsanctuary.cf.turnstile.metrics; + +import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; + +/** + * Micrometer-backed implementation of TurnstileMetrics. + * This class is only instantiated when Micrometer is present on the classpath. + */ +@Slf4j +public class MicrometerTurnstileMetrics implements TurnstileMetrics { + + private final Counter validationCounter; + private final Counter successCounter; + private final Counter errorCounter; + private final Counter networkErrorCounter; + private final Counter configErrorCounter; + private final Counter validationErrorCounter; + private final Counter inputErrorCounter; + private final Timer responseTimer; + + public MicrometerTurnstileMetrics(MeterRegistry registry) { + log.info("Initializing Turnstile metrics with MeterRegistry"); + validationCounter = Counter.builder("turnstile.validation.requests") + .description("Total number of Turnstile validation requests").register(registry); + successCounter = Counter.builder("turnstile.validation.success") + .description("Number of successful Turnstile validations").register(registry); + errorCounter = Counter.builder("turnstile.validation.errors") + .description("Number of failed Turnstile validations").register(registry); + networkErrorCounter = Counter.builder("turnstile.validation.errors.network") + .description("Number of Turnstile validation network errors").register(registry); + configErrorCounter = Counter.builder("turnstile.validation.errors.config") + .description("Number of Turnstile validation configuration errors").register(registry); + validationErrorCounter = Counter.builder("turnstile.validation.errors.token") + .description("Number of Turnstile validation token errors").register(registry); + inputErrorCounter = Counter.builder("turnstile.validation.errors.input") + .description("Number of Turnstile validation input errors").register(registry); + responseTimer = Timer.builder("turnstile.validation.response.time") + .description("Response time for Turnstile validation requests").register(registry); + } + + @Override + public void recordValidation() { + validationCounter.increment(); + } + + @Override + public void recordSuccess() { + successCounter.increment(); + } + + @Override + public void recordError(ValidationResultType type) { + errorCounter.increment(); + switch (type) { + case NETWORK_ERROR -> networkErrorCounter.increment(); + case CONFIGURATION_ERROR -> configErrorCounter.increment(); + case INVALID_TOKEN -> validationErrorCounter.increment(); + case INPUT_ERROR -> inputErrorCounter.increment(); + default -> {} + } + } + + @Override + public void recordResponseTime(long milliseconds) { + responseTimer.record(milliseconds, TimeUnit.MILLISECONDS); + } +} +``` + +- [ ] **Step 2: Compile to verify** + +```bash +./gradlew compileJava +``` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java +git commit -m "feat: add MicrometerTurnstileMetrics implementation" +``` + +--- + +## Task 4: Refactor `TurnstileValidationService` to use `TurnstileMetrics` + +Remove all Micrometer imports and type references. Replace `Optional` + individual counter/timer fields with a single `TurnstileMetrics metrics` field. + +**Files:** +- Modify: `src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java` + +- [ ] **Step 1: Replace the class with the refactored version** + +Replace the entire file content with: + +```java +package com.digitalsanctuary.cf.turnstile.service; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties; +import com.digitalsanctuary.cf.turnstile.dto.TurnstileResponse; +import com.digitalsanctuary.cf.turnstile.dto.ValidationResult; +import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType; +import com.digitalsanctuary.cf.turnstile.exception.TurnstileConfigurationException; +import com.digitalsanctuary.cf.turnstile.exception.TurnstileNetworkException; +import com.digitalsanctuary.cf.turnstile.exception.TurnstileValidationException; +import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for validating responses from Cloudflare's Turnstile API. + *

+ * This service provides methods to validate Turnstile tokens with the Cloudflare API, handling various error scenarios with appropriate exceptions + * and detailed validation results. It also collects metrics on validation attempts, success/failure rates, and response times when metrics are + * enabled. + *

+ */ +@Slf4j +@Service +public class TurnstileValidationService { + private static final String UNKNOWN = "unknown"; + private static final int MIN_TOKEN_LENGTH = 20; + + private final RestClient turnstileRestClient; + private final TurnstileConfigProperties properties; + private final TurnstileMetrics metrics; + + // Internal counters (always active, independent of Micrometer) + private final LongAdder validationCount = new LongAdder(); + private final LongAdder successCount = new LongAdder(); + private final LongAdder errorCount = new LongAdder(); + private final LongAdder networkErrorCount = new LongAdder(); + private final LongAdder configErrorCount = new LongAdder(); + private final LongAdder validationErrorCount = new LongAdder(); + private final LongAdder inputErrorCount = new LongAdder(); + private final AtomicLong lastResponseTime = new AtomicLong(); + private final AtomicLong totalResponseTime = new AtomicLong(); + private final AtomicLong responseCount = new AtomicLong(); + + /** + * Constructor for TurnstileValidationService. + * + * @param turnstileRestClient the RestClient to use for making requests to the Turnstile API + * @param properties the TurnstileConfigProperties to use for configuration + * @param metrics the TurnstileMetrics implementation for recording metrics + */ + public TurnstileValidationService(@Qualifier("turnstileRestClient") RestClient turnstileRestClient, + TurnstileConfigProperties properties, TurnstileMetrics metrics) { + this.turnstileRestClient = turnstileRestClient; + this.properties = properties; + this.metrics = metrics; + } + + /** + * Method called after the bean is initialized. Logs the startup information and validates the required configuration. + * + * @throws TurnstileConfigurationException if required configuration properties are missing + */ + @PostConstruct + public void onStartup() { + log.info("TurnstileValidationService started"); + log.info("Turnstile URL: {}", properties.getUrl()); + log.info("Turnstile Sitekey: {}", properties.getSitekey()); + log.info("Turnstile Secret: {}", properties.getSecret() != null && !properties.getSecret().isBlank() ? "[CONFIGURED]" : "[NOT CONFIGURED]"); + log.info("Turnstile Metrics enabled: {}", properties.getMetrics().isEnabled()); + log.info("Turnstile Health Check enabled: {}", properties.getMetrics().isHealthCheckEnabled()); + + if (properties.getSecret() == null || properties.getSecret().isBlank()) { + log.error("Turnstile secret key is not configured. Validation will fail."); + } + if (properties.getUrl() == null || properties.getUrl().isBlank()) { + log.error("Turnstile URL is not configured. Validation will fail."); + } + } + + /** + * Validates the Turnstile response token. Convenience method without remote IP. + * + * @param token the response token to be validated. + * @return true if the response is valid and successful, false otherwise. + */ + public boolean validateTurnstileResponse(String token) { + return validateTurnstileResponse(token, null); + } + + /** + * Validates the Turnstile response token. Returns boolean and handles exceptions internally. + * + * @param token the response token to be validated. + * @param remoteIp the remote IP address of the client (optional). + * @return true if the response is valid and successful, false otherwise. + */ + public boolean validateTurnstileResponse(String token, String remoteIp) { + try { + ValidationResult result = validateTurnstileResponseDetailed(token, remoteIp); + return result.isSuccess(); + } catch (Exception e) { + log.error("Unexpected error during Turnstile validation: {}", e.getMessage(), e); + return false; + } + } + + /** + * Validates the Turnstile response token. Convenience method without remote IP. + * + * @param token the response token to be validated. + * @return a ValidationResult object with detailed information about the validation outcome. + * @throws TurnstileConfigurationException if the service is not properly configured + * @throws TurnstileNetworkException if a network error occurs during validation + * @throws TurnstileValidationException if the token is rejected by Cloudflare + */ + public ValidationResult validateTurnstileResponseDetailed(String token) { + return validateTurnstileResponseDetailed(token, null); + } + + /** + * Validates the Turnstile response token with detailed results and typed exceptions. + * + * @param token the response token to be validated. + * @param remoteIp the remote IP address of the client (optional). + * @return a ValidationResult object with detailed information about the validation outcome. + * @throws TurnstileConfigurationException if the service is not properly configured + * @throws TurnstileNetworkException if a network error occurs during validation + * @throws TurnstileValidationException if the token is rejected by Cloudflare + */ + public ValidationResult validateTurnstileResponseDetailed(String token, String remoteIp) { + long startTime = System.currentTimeMillis(); + validationCount.increment(); + metrics.recordValidation(); + + log.trace("Starting validation for token: {} with remoteIp: {}", token, remoteIp); + + if (token == null) { + log.warn("Turnstile validation failed: token cannot be null"); + recordError(ValidationResultType.INPUT_ERROR); + return ValidationResult.inputError("Token cannot be null"); + } + + if (token.isEmpty() || token.isBlank()) { + log.warn("Turnstile validation failed: token cannot be empty or blank"); + recordError(ValidationResultType.INPUT_ERROR); + return ValidationResult.inputError("Token cannot be empty or blank"); + } + + if (token.length() < MIN_TOKEN_LENGTH) { + log.warn("Turnstile validation failed: token appears to be too short to be valid (length: {})", token.length()); + recordError(ValidationResultType.INPUT_ERROR); + return ValidationResult.inputError("Token is too short to be valid (length: " + token.length() + ")"); + } + + String cleanRemoteIp = remoteIp; + if (cleanRemoteIp != null && (cleanRemoteIp.isEmpty() || cleanRemoteIp.isBlank())) { + log.warn("Turnstile validation: ignoring empty or blank remoteIp"); + cleanRemoteIp = null; + } + + if (properties.getSecret() == null || properties.getSecret().isBlank()) { + String msg = "Turnstile secret key is not configured"; + log.error(msg); + recordError(ValidationResultType.CONFIGURATION_ERROR); + throw new TurnstileConfigurationException(msg); + } + + if (properties.getUrl() == null || properties.getUrl().isBlank()) { + String msg = "Turnstile URL is not configured"; + log.error(msg); + recordError(ValidationResultType.CONFIGURATION_ERROR); + throw new TurnstileConfigurationException(msg); + } + + Map requestBody = new HashMap<>(); + requestBody.put("secret", properties.getSecret()); + requestBody.put("response", token); + Optional.ofNullable(cleanRemoteIp).ifPresent(ip -> requestBody.put("remoteip", ip)); + + log.trace("Making request to Cloudflare Turnstile API at: {}", properties.getUrl()); + + try { + ValidationResult result = executeValidationRequest(requestBody); + long elapsed = System.currentTimeMillis() - startTime; + lastResponseTime.set(elapsed); + totalResponseTime.addAndGet(elapsed); + responseCount.incrementAndGet(); + metrics.recordResponseTime(elapsed); + return result; + } catch (HttpClientErrorException e) { + log.error("Client error during Turnstile validation: {}", e.getMessage(), e); + recordError(ValidationResultType.NETWORK_ERROR); + throw new TurnstileNetworkException("Client error: " + e.getMessage(), e); + } catch (HttpServerErrorException e) { + log.error("Server error during Turnstile validation: {}", e.getMessage(), e); + recordError(ValidationResultType.NETWORK_ERROR); + throw new TurnstileNetworkException("Server error: " + e.getMessage(), e); + } catch (ResourceAccessException e) { + log.error("Network error during Turnstile validation: {}", e.getMessage(), e); + recordError(ValidationResultType.NETWORK_ERROR); + throw new TurnstileNetworkException("Network error: " + e.getMessage(), e); + } catch (TurnstileValidationException e) { + recordError(ValidationResultType.INVALID_TOKEN); + throw e; + } catch (Exception e) { + log.error("Unexpected error during Turnstile validation: {}", e.getMessage(), e); + recordError(ValidationResultType.NETWORK_ERROR); + throw new TurnstileNetworkException("Unexpected error: " + e.getMessage(), e); + } + } + + private ValidationResult executeValidationRequest(Map requestBody) { + TurnstileResponse response = turnstileRestClient.post().uri(properties.getUrl()) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).body(requestBody).retrieve().body(TurnstileResponse.class); + + log.debug("Turnstile response: {}", response); + + if (response == null) { + log.warn("Turnstile API returned null response"); + recordError(ValidationResultType.NETWORK_ERROR); + return ValidationResult.networkError("Cloudflare returned an empty response"); + } + + if (response.isSuccess()) { + log.debug("Turnstile validation successful"); + successCount.increment(); + metrics.recordSuccess(); + return ValidationResult.success(); + } else { + log.warn("Turnstile validation failed with error codes: {}", response.getErrorCodes()); + recordError(ValidationResultType.INVALID_TOKEN); + throw new TurnstileValidationException("Token validation failed", response.getErrorCodes()); + } + } + + private void recordError(ValidationResultType resultType) { + errorCount.increment(); + metrics.recordError(resultType); + + switch (resultType) { + case NETWORK_ERROR -> networkErrorCount.increment(); + case CONFIGURATION_ERROR -> configErrorCount.increment(); + case INVALID_TOKEN -> validationErrorCount.increment(); + case INPUT_ERROR -> inputErrorCount.increment(); + default -> {} + } + } + + /** @return total number of validation attempts */ + public long getValidationCount() { return validationCount.sum(); } + + /** @return number of successful validations */ + public long getSuccessCount() { return successCount.sum(); } + + /** @return number of failed validations */ + public long getErrorCount() { return errorCount.sum(); } + + /** @return number of network errors */ + public long getNetworkErrorCount() { return networkErrorCount.sum(); } + + /** @return number of configuration errors */ + public long getConfigErrorCount() { return configErrorCount.sum(); } + + /** @return number of validation errors (invalid tokens) */ + public long getValidationErrorCount() { return validationErrorCount.sum(); } + + /** @return number of input validation errors */ + public long getInputErrorCount() { return inputErrorCount.sum(); } + + /** @return time of last response in milliseconds */ + public long getLastResponseTime() { return lastResponseTime.get(); } + + /** @return average response time in milliseconds, or 0 if no responses yet */ + public double getAverageResponseTime() { + long count = responseCount.get(); + return count > 0 ? (double) totalResponseTime.get() / count : 0; + } + + /** @return error rate as a percentage (0-100), or 0 if no attempts yet */ + public double getErrorRate() { + long total = validationCount.sum(); + return total > 0 ? (double) errorCount.sum() * 100 / total : 0; + } + + /** + * @deprecated Use {@link #getTurnstileSitekey()} instead. + */ + @Deprecated + public String getTurnsiteSitekey() { return getTurnstileSitekey(); } + + /** @return the Turnstile Sitekey */ + public String getTurnstileSitekey() { return properties.getSitekey(); } + + /** + * Gets the client IP address from the ServletRequest. + * + * @param request the ServletRequest. + * @return the client IP address. + */ + public String getClientIpAddress(ServletRequest request) { + if (request instanceof HttpServletRequest httpRequest) { + String[] headers = {"X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP", "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR"}; + for (String header : headers) { + String ipHeaderValue = httpRequest.getHeader(header); + if (ipHeaderValue == null || ipHeaderValue.isBlank()) { + continue; + } + String candidate = ipHeaderValue.split(",", 2)[0].trim(); + if (!candidate.isEmpty() && !UNKNOWN.equalsIgnoreCase(candidate)) { + return candidate; + } + } + } + return request.getRemoteAddr(); + } +} +``` + +- [ ] **Step 2: Compile to verify** + +```bash +./gradlew compileJava +``` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 3: Run existing tests to verify no regressions** + +```bash +./gradlew test +``` +Expected: All tests pass (they will fail until Task 5 wires the `TurnstileMetrics` bean — the constructor signature changed, so Spring context won't load yet). If they fail with `NoSuchBeanDefinitionException: TurnstileMetrics`, that's expected — continue to Task 5. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java +git commit -m "refactor: remove Micrometer imports from TurnstileValidationService, use TurnstileMetrics interface" +``` + +--- + +## Task 5: Register `TurnstileMetrics` beans in configuration + +Wire the correct `TurnstileMetrics` bean into Spring context: `NoOpTurnstileMetrics` when Micrometer is absent (via `@ConditionalOnMissingBean`), `MicrometerTurnstileMetrics` when Micrometer is present (registered in `TurnstileMetricsConfig`). + +**Files:** +- Modify: `src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java` +- Modify: `src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java` + +- [ ] **Step 1: Replace `TurnstileServiceConfig` — remove `ObjectProvider`, add no-op bean, fix service constructor call** + +Replace the entire file content with: + +```java +package com.digitalsanctuary.cf.turnstile.config; + +import java.net.http.HttpClient; +import java.time.Duration; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestClient; +import com.digitalsanctuary.cf.turnstile.metrics.NoOpTurnstileMetrics; +import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics; +import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Configuration class for setting up Turnstile related beans. + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class TurnstileServiceConfig { + + private final TurnstileConfigProperties properties; + + /** + * Provides a no-op TurnstileMetrics bean when no other implementation is registered. + * This is the fallback when Micrometer is not on the classpath. + * + * @return a no-op TurnstileMetrics instance + */ + @Bean + @ConditionalOnMissingBean(TurnstileMetrics.class) + public TurnstileMetrics noOpTurnstileMetrics() { + log.info("Micrometer not available — using no-op Turnstile metrics"); + return new NoOpTurnstileMetrics(); + } + + /** + * Creates a TurnstileValidationService bean. + * + * @param restClient the preconfigured REST client for Turnstile calls + * @param metrics the TurnstileMetrics implementation to use + * @return a configured TurnstileValidationService instance + */ + @Bean + public TurnstileValidationService turnstileValidationService( + @Qualifier("turnstileRestClient") RestClient restClient, + TurnstileMetrics metrics) { + return new TurnstileValidationService(restClient, properties, metrics); + } + + /** + * Creates a RestClient bean for Turnstile API interactions. + * + * @return a configured RestClient instance + */ + @Bean(name = "turnstileRestClient") + public RestClient turnstileRestClient() { + log.info("Creating Turnstile REST client with endpoint: {}", properties.getUrl()); + log.info("Turnstile REST client timeouts - connect: {}s, read: {}s", + properties.getConnectTimeout(), properties.getReadTimeout()); + + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(properties.getConnectTimeout())) + .build(); + + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(Duration.ofSeconds(properties.getReadTimeout())); + + return RestClient.builder() + .baseUrl(properties.getUrl()) + .requestFactory(requestFactory) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} +``` + +- [ ] **Step 2: Add `MicrometerTurnstileMetrics` bean to `TurnstileMetricsConfig`** + +Replace the entire file content with: + +```java +package com.digitalsanctuary.cf.turnstile.config; + +import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import com.digitalsanctuary.cf.turnstile.metrics.MicrometerTurnstileMetrics; +import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.config.MeterFilter; +import java.util.Collections; +import lombok.extern.slf4j.Slf4j; + +/** + * Configuration for Turnstile metrics and monitoring. + * Only loaded when Micrometer is on the classpath and metrics are enabled. + */ +@Slf4j +@Configuration +@ConditionalOnClass(MeterRegistry.class) +@ConditionalOnProperty(prefix = "ds.cf.turnstile.metrics", name = "enabled", havingValue = "true", matchIfMissing = true) +public class TurnstileMetricsConfig { + + /** + * Registers the Micrometer-backed TurnstileMetrics bean. + * + * @param registry the MeterRegistry to use for metrics + * @return a MicrometerTurnstileMetrics instance + */ + @Bean + public TurnstileMetrics micrometerTurnstileMetrics(MeterRegistry registry) { + return new MicrometerTurnstileMetrics(registry); + } + + /** + * Customizes the meter registry with Turnstile-specific tags and filters. + * + * @return a MeterRegistryCustomizer for the MeterRegistry + */ + @Bean + public MeterRegistryCustomizer turnstileMeterRegistryCustomizer() { + log.info("Configuring Turnstile metrics"); + return registry -> registry.config() + .meterFilter(MeterFilter.acceptNameStartsWith("turnstile")) + .meterFilter(MeterFilter.commonTags(Collections.singletonList(Tag.of("component", "turnstile")))); + } +} +``` + +- [ ] **Step 3: Compile to verify** + +```bash +./gradlew compileJava +``` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 4: Run existing tests** + +```bash +./gradlew test +``` +Expected: All existing tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java \ + src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java +git commit -m "feat: wire TurnstileMetrics beans — Micrometer-backed or no-op based on classpath" +``` + +--- + +## Task 6: Fix `TurnstileConfiguration` — remove direct Micrometer import, use string-form `@ConditionalOnClass` + +The outer auto-configuration class references `MeterRegistry` as a class literal in an annotation, which encodes it into the bytecode. Switch to the safe string form. + +**Files:** +- Modify: `src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java` + +- [ ] **Step 1: Update `TurnstileConfiguration`** + +Replace the entire file content with: + +```java +package com.digitalsanctuary.cf.turnstile; + +import org.springframework.boot.health.autoconfigure.contributor.ConditionalOnEnabledHealthIndicator; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties; +import com.digitalsanctuary.cf.turnstile.config.TurnstileHealthIndicator; +import com.digitalsanctuary.cf.turnstile.config.TurnstileMetricsConfig; +import com.digitalsanctuary.cf.turnstile.config.TurnstileServiceConfig; +import com.digitalsanctuary.cf.turnstile.filter.TurnstileCaptchaFilter; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +/** + * Main auto-configuration class for the Spring Cloudflare Turnstile integration. + *

+ * Imports core configuration unconditionally; metrics and health configurations are + * conditional on the presence of their respective classes on the classpath. + *

+ * + * @see com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties + * @see com.digitalsanctuary.cf.turnstile.config.TurnstileServiceConfig + * @see com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService + * @see com.digitalsanctuary.cf.turnstile.config.TurnstileMetricsConfig + * @see com.digitalsanctuary.cf.turnstile.config.TurnstileHealthIndicator + */ +@Slf4j +@Configuration +@AutoConfiguration +@Import({TurnstileServiceConfig.class, TurnstileConfigProperties.class, TurnstileCaptchaFilter.class}) +public class TurnstileConfiguration { + + /** + * Metrics configuration for Turnstile. + * Only imported if Micrometer's MeterRegistry is available on the classpath. + * Uses string form of @ConditionalOnClass to avoid encoding a bytecode reference + * to MeterRegistry in this class, which would cause NoClassDefFoundError when + * Micrometer is absent. + */ + @Configuration + @ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry") + @Import(TurnstileMetricsConfig.class) + static class TurnstileMetricsConfiguration { + } + + /** + * Health indicator configuration for Turnstile. + * Only imported if Spring Actuator's HealthIndicator is available on the classpath. + */ + @Configuration + @ConditionalOnEnabledHealthIndicator("turnstile") + @ConditionalOnClass(name = "org.springframework.boot.health.contributor.HealthIndicator") + @Import(TurnstileHealthIndicator.class) + static class TurnstileHealthConfiguration { + } + + /** + * Logs confirmation that the Turnstile service has been loaded. + */ + @PostConstruct + public void onStartup() { + log.info("DigitalSanctuary Spring Cloudflare Turnstile Service loaded"); + } +} +``` + +- [ ] **Step 2: Compile to verify** + +```bash +./gradlew compileJava +``` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 3: Run all tests** + +```bash +./gradlew test +``` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java +git commit -m "fix: use string-form @ConditionalOnClass in TurnstileConfiguration to prevent NoClassDefFoundError when Micrometer is absent" +``` + +--- + +## Task 7: Add test verifying the library loads without Actuator + +Write a Spring context test that excludes `MeterRegistry` from the context, simulating a consumer app without Actuator. This is the regression test for issue #94. + +**Files:** +- Create: `src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java` + +- [ ] **Step 1: Create the test** + +```java +package com.digitalsanctuary.cf.test.turnstile; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import com.digitalsanctuary.cf.test.TestApplication; +import com.digitalsanctuary.cf.turnstile.metrics.NoOpTurnstileMetrics; +import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics; +import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService; + +/** + * Verifies that the library starts successfully without Micrometer/Actuator on the classpath. + * Simulates this by excluding all Micrometer-related auto-configurations. + * Regression test for GitHub issue #94. + */ +@SpringBootTest( + classes = TestApplication.class, + properties = { + "spring.autoconfigure.exclude=" + + "org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration," + + "org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration" + } +) +@ActiveProfiles("test") +class TurnstileWithoutActuatorTest { + + @Autowired + private TurnstileValidationService turnstileValidationService; + + @Autowired + private TurnstileMetrics turnstileMetrics; + + @Test + void contextLoadsWithoutMicrometer() { + assertNotNull(turnstileValidationService, "TurnstileValidationService should be available"); + } + + @Test + void noOpMetricsUsedWhenMicrometerExcluded() { + assertInstanceOf(NoOpTurnstileMetrics.class, turnstileMetrics, + "Should use NoOpTurnstileMetrics when Micrometer auto-config is excluded"); + } + + @Test + void validationWorksWithoutMicrometer() { + // Short token triggers INPUT_ERROR path — exercises metrics recording via NoOp + boolean result = turnstileValidationService.validateTurnstileResponse("short"); + assertNotNull(result); // just verifying no exception from metrics recording + } +} +``` + +- [ ] **Step 2: Run the new test** + +```bash +./gradlew test --tests "com.digitalsanctuary.cf.test.turnstile.TurnstileWithoutActuatorTest" +``` +Expected: All 3 tests pass. + +- [ ] **Step 3: Run the full test suite** + +```bash +./gradlew test +``` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileWithoutActuatorTest.java +git commit -m "test: add regression test for issue #94 — library loads without Actuator" +``` + +--- + +## Task 8: Final verification + +- [ ] **Step 1: Clean build** + +```bash +./gradlew clean build +``` +Expected: `BUILD SUCCESSFUL`, all tests pass. + +- [ ] **Step 2: Verify no stray Micrometer imports in always-loaded classes** + +```bash +grep -r "import io.micrometer" \ + src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java \ + src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java \ + src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java +``` +Expected: No output (zero matches). + +- [ ] **Step 3: Verify Micrometer imports are only in conditional classes** + +```bash +grep -r "import io.micrometer" src/main/java/ --include="*.java" -l +``` +Expected: Only `TurnstileMetricsConfig.java` and `MicrometerTurnstileMetrics.java` appear. + +- [ ] **Step 4: Publish to local Maven and verify POM** + +```bash +./gradlew publishToMavenLocal +``` +Expected: `BUILD SUCCESSFUL`. Verify `spring-boot-starter-actuator` is listed as `optional` or absent from the published POM's `` (it was `compileOnly` so it should not appear in the POM). + +- [ ] **Step 5: Commit final verification results** + +No code changes; if all checks pass, proceed to create a PR. + +--- + +## Self-Review + +**Spec coverage:** +- ✅ `NoClassDefFoundError` when Micrometer absent — fixed by removing type references from always-loaded classes +- ✅ Micrometer metrics still work when Actuator is present — `MicrometerTurnstileMetrics` registered via `TurnstileMetricsConfig` +- ✅ Health indicator still works (not touched — already uses string-form `@ConditionalOnClass`) +- ✅ `@ConditionalOnClass` uses safe string form in `TurnstileConfiguration` +- ✅ Regression test added + +**Type consistency check:** +- `TurnstileMetrics` interface methods: `recordValidation()`, `recordSuccess()`, `recordError(ValidationResultType)`, `recordResponseTime(long)` — consistent across `NoOpTurnstileMetrics`, `MicrometerTurnstileMetrics`, and all call sites in `TurnstileValidationService` +- `TurnstileServiceConfig.turnstileValidationService(RestClient, TurnstileMetrics)` — matches `TurnstileValidationService` constructor signature + +**Placeholder scan:** None found — all steps contain exact code. From 077f3006d8d0de77ddc6308c085c8f2a7eda4bb5 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 11:42:31 -0600 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20address=20PR=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20double-counting,=20finally=20safety,=20JavaDoc,?= =?UTF-8?q?=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate recordError(INVALID_TOKEN) call from executeValidationRequest; outer catch (TurnstileValidationException) is the single correct recording site - Wrap metrics.recordResponseTime() in try/catch inside finally block so a metrics failure cannot suppress the original validation exception - Add log.debug to TurnstileValidationException catch for symmetry with other handlers - Improve broad catch to log exception class name for better diagnostics - Add complete JavaDoc to TurnstileMetrics interface (recordValidation, recordSuccess, recordResponseTime) including call-sequence contract and timing semantics - Add class and constructor JavaDoc to MicrometerTurnstileMetrics; add Objects.requireNonNull guard on registry parameter - Fix TurnstileValidationService class-level doc (no longer says "when metrics enabled") - Fix TurnstileHealthConfiguration comment to mention @ConditionalOnEnabledHealthIndicator - Fix noOpTurnstileMetrics comment to say "no TurnstileMetrics bean registered" rather than "Micrometer not on classpath" - Add MicrometerTurnstileMetricsTest (9 tests) covering all counter dispatch paths, timer recording, null-registry rejection, and aggregate/sub-counter consistency --- .../cf/turnstile/TurnstileConfiguration.java | 4 +- .../config/TurnstileServiceConfig.java | 6 +- .../metrics/MicrometerTurnstileMetrics.java | 33 ++++++- .../turnstile/metrics/TurnstileMetrics.java | 29 +++++- .../service/TurnstileValidationService.java | 14 ++- .../MicrometerTurnstileMetricsTest.java | 98 +++++++++++++++++++ 6 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/cf/test/turnstile/MicrometerTurnstileMetricsTest.java diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java b/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java index b49ffec..a6bd9ae 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java @@ -47,7 +47,9 @@ static class TurnstileMetricsConfiguration { /** * Health indicator configuration for Turnstile. - * Only imported if Spring Actuator's HealthIndicator is available on the classpath. + * Only imported if Spring Actuator's {@code HealthIndicator} class is on the classpath + * and the turnstile health indicator has not been disabled via + * {@code management.health.turnstile.enabled=false}. */ @Configuration @ConditionalOnEnabledHealthIndicator("turnstile") diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java index 23756e0..85e58ad 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java @@ -27,8 +27,10 @@ public class TurnstileServiceConfig { private final TurnstileConfigProperties properties; /** - * Provides a no-op TurnstileMetrics bean when no other implementation is registered. - * This is the fallback when Micrometer is not on the classpath. + * Provides a no-op {@link TurnstileMetrics} bean when no other implementation is registered. + * This fallback is active when Micrometer is absent from the classpath, when metrics are + * disabled via {@code ds.cf.turnstile.metrics.enabled=false}, or when no custom + * {@code TurnstileMetrics} bean has been supplied by the consuming application. * * @return a no-op TurnstileMetrics instance */ diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java index 279dc9e..7d6d9dc 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java @@ -4,12 +4,24 @@ import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; +import java.util.Objects; import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; /** - * Micrometer-backed implementation of TurnstileMetrics. - * This class is only instantiated when Micrometer is present on the classpath. + * Micrometer-backed implementation of {@link TurnstileMetrics}. + *

+ * This class is only instantiated when Micrometer is present on the classpath, via + * {@code TurnstileMetricsConfig} which is guarded by {@code @ConditionalOnClass(MeterRegistry.class)}. + *

+ *

+ * All eight meters ({@code errorCounter} is the aggregate; the four type-specific counters are + * sub-categories whose sum equals the aggregate) are eagerly registered at construction time so + * they appear in monitoring dashboards before the first validation event occurs. A single instance + * should be registered per application context to avoid duplicate meter registration errors. + *

+ * + * @see NoOpTurnstileMetrics */ @Slf4j public class MicrometerTurnstileMetrics implements TurnstileMetrics { @@ -23,7 +35,24 @@ public class MicrometerTurnstileMetrics implements TurnstileMetrics { private final Counter inputErrorCounter; private final Timer responseTimer; + /** + * Creates a {@code MicrometerTurnstileMetrics} instance and eagerly registers all Turnstile + * meters with the provided registry. The registered meters are: + *
    + *
  • {@code turnstile.validation.requests} — total attempts
  • + *
  • {@code turnstile.validation.success} — successful validations
  • + *
  • {@code turnstile.validation.errors} — all errors (aggregate)
  • + *
  • {@code turnstile.validation.errors.network} — network errors
  • + *
  • {@code turnstile.validation.errors.config} — configuration errors
  • + *
  • {@code turnstile.validation.errors.token} — invalid token errors
  • + *
  • {@code turnstile.validation.errors.input} — input validation errors
  • + *
  • {@code turnstile.validation.response.time} — response time timer
  • + *
+ * + * @param registry the Micrometer {@link MeterRegistry} to register meters with; must not be null + */ public MicrometerTurnstileMetrics(MeterRegistry registry) { + Objects.requireNonNull(registry, "registry must not be null"); log.info("Initializing Turnstile metrics with MeterRegistry"); validationCounter = Counter.builder("turnstile.validation.requests") .description("Total number of Turnstile validation requests").register(registry); diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java index d47e14d..f35c564 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java @@ -4,10 +4,30 @@ /** * Abstraction for recording Turnstile validation metrics. - * Implementations may be no-op (when Micrometer is absent) or Micrometer-backed. + *

+ * Implementations may be no-op (when Micrometer is absent from the classpath or metrics are + * disabled) or Micrometer-backed. Consumers may also supply a custom implementation as a Spring + * bean to integrate with their own metrics infrastructure. + *

+ *

+ * Expected call sequence per validation attempt: + * {@link #recordValidation()} is always called first, followed by exactly one of + * {@link #recordSuccess()} or {@link #recordError(ValidationResultType)}, and then + * {@link #recordResponseTime(long)} for any attempt that reached the network (input and + * configuration errors do not record a response time). + *

*/ public interface TurnstileMetrics { + + /** + * Records a validation attempt. Called once per invocation of + * {@code validateTurnstileResponseDetailed}, regardless of outcome. + */ void recordValidation(); + + /** + * Records a successful validation. Called only when Cloudflare returns a success response. + */ void recordSuccess(); /** @@ -22,5 +42,12 @@ public interface TurnstileMetrics { */ void recordError(ValidationResultType type); + /** + * Records the elapsed wall-clock time for a validation attempt that reached the network. + * Not called for input or configuration errors that short-circuit before the HTTP request. + * + * @param milliseconds elapsed time in milliseconds from the start of the validation call + * to its completion (success or failure) + */ void recordResponseTime(long milliseconds); } diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java index e59c937..b1abccd 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java @@ -29,8 +29,8 @@ * Service for validating responses from Cloudflare's Turnstile API. *

* This service provides methods to validate Turnstile tokens with the Cloudflare API, handling various error scenarios with appropriate exceptions - * and detailed validation results. It also collects metrics on validation attempts, success/failure rates, and response times when metrics are - * enabled. + * and detailed validation results. It maintains internal counters for validation attempts, success/failure rates, and response times, and delegates + * to a {@link com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics} implementation (Micrometer-backed or no-op) for external metric recording. *

*/ @Slf4j @@ -207,10 +207,11 @@ public ValidationResult validateTurnstileResponseDetailed(String token, String r recordError(ValidationResultType.NETWORK_ERROR); throw new TurnstileNetworkException("Network error: " + e.getMessage(), e); } catch (TurnstileValidationException e) { + log.debug("Turnstile token rejected by Cloudflare: {}", e.getMessage()); recordError(ValidationResultType.INVALID_TOKEN); throw e; } catch (Exception e) { - log.error("Unexpected error during Turnstile validation: {}", e.getMessage(), e); + log.error("Unexpected {} during Turnstile validation: {}", e.getClass().getSimpleName(), e.getMessage(), e); recordError(ValidationResultType.NETWORK_ERROR); throw new TurnstileNetworkException("Unexpected error: " + e.getMessage(), e); } finally { @@ -218,7 +219,11 @@ public ValidationResult validateTurnstileResponseDetailed(String token, String r lastResponseTime.set(elapsed); totalResponseTime.addAndGet(elapsed); responseCount.incrementAndGet(); - metrics.recordResponseTime(elapsed); + try { + metrics.recordResponseTime(elapsed); + } catch (Exception metricsEx) { + log.warn("Failed to record response time metric; validation result is unaffected: {}", metricsEx.getMessage(), metricsEx); + } } } @@ -241,7 +246,6 @@ private ValidationResult executeValidationRequest(Map requestBod return ValidationResult.success(); } else { log.warn("Turnstile validation failed with error codes: {}", response.getErrorCodes()); - recordError(ValidationResultType.INVALID_TOKEN); throw new TurnstileValidationException("Token validation failed", response.getErrorCodes()); } } diff --git a/src/test/java/com/digitalsanctuary/cf/test/turnstile/MicrometerTurnstileMetricsTest.java b/src/test/java/com/digitalsanctuary/cf/test/turnstile/MicrometerTurnstileMetricsTest.java new file mode 100644 index 0000000..75fbef6 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/cf/test/turnstile/MicrometerTurnstileMetricsTest.java @@ -0,0 +1,98 @@ +package com.digitalsanctuary.cf.test.turnstile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType; +import com.digitalsanctuary.cf.turnstile.metrics.MicrometerTurnstileMetrics; + +/** + * Unit tests for MicrometerTurnstileMetrics using an in-memory SimpleMeterRegistry. + * Verifies that each method routes to the correct named Micrometer meter. + */ +class MicrometerTurnstileMetricsTest { + + private SimpleMeterRegistry registry; + private MicrometerTurnstileMetrics metrics; + + @BeforeEach + void setUp() { + registry = new SimpleMeterRegistry(); + metrics = new MicrometerTurnstileMetrics(registry); + } + + @Test + void recordValidation_incrementsRequestsCounter() { + metrics.recordValidation(); + metrics.recordValidation(); + assertEquals(2.0, registry.counter("turnstile.validation.requests").count()); + } + + @Test + void recordSuccess_incrementsSuccessCounter() { + metrics.recordSuccess(); + assertEquals(1.0, registry.counter("turnstile.validation.success").count()); + } + + @Test + void recordError_networkError_incrementsAggregateAndSubcounter() { + metrics.recordError(ValidationResultType.NETWORK_ERROR); + assertEquals(1.0, registry.counter("turnstile.validation.errors").count()); + assertEquals(1.0, registry.counter("turnstile.validation.errors.network").count()); + assertEquals(0.0, registry.counter("turnstile.validation.errors.config").count()); + assertEquals(0.0, registry.counter("turnstile.validation.errors.token").count()); + assertEquals(0.0, registry.counter("turnstile.validation.errors.input").count()); + } + + @Test + void recordError_configurationError_incrementsAggregateAndSubcounter() { + metrics.recordError(ValidationResultType.CONFIGURATION_ERROR); + assertEquals(1.0, registry.counter("turnstile.validation.errors").count()); + assertEquals(0.0, registry.counter("turnstile.validation.errors.network").count()); + assertEquals(1.0, registry.counter("turnstile.validation.errors.config").count()); + } + + @Test + void recordError_invalidToken_incrementsAggregateAndSubcounter() { + metrics.recordError(ValidationResultType.INVALID_TOKEN); + assertEquals(1.0, registry.counter("turnstile.validation.errors").count()); + assertEquals(1.0, registry.counter("turnstile.validation.errors.token").count()); + } + + @Test + void recordError_inputError_incrementsAggregateAndSubcounter() { + metrics.recordError(ValidationResultType.INPUT_ERROR); + assertEquals(1.0, registry.counter("turnstile.validation.errors").count()); + assertEquals(1.0, registry.counter("turnstile.validation.errors.input").count()); + } + + @Test + void recordResponseTime_recordsToTimer() { + metrics.recordResponseTime(150L); + metrics.recordResponseTime(250L); + assertEquals(2L, registry.timer("turnstile.validation.response.time").count()); + } + + @Test + void constructor_rejectsNullRegistry() { + assertThrows(NullPointerException.class, () -> new MicrometerTurnstileMetrics(null)); + } + + @Test + void multipleErrors_aggregateCountEqualsSum() { + metrics.recordError(ValidationResultType.NETWORK_ERROR); + metrics.recordError(ValidationResultType.INVALID_TOKEN); + metrics.recordError(ValidationResultType.INPUT_ERROR); + + Counter aggregate = registry.counter("turnstile.validation.errors"); + double subTotal = registry.counter("turnstile.validation.errors.network").count() + + registry.counter("turnstile.validation.errors.config").count() + + registry.counter("turnstile.validation.errors.token").count() + + registry.counter("turnstile.validation.errors.input").count(); + + assertEquals(aggregate.count(), subTotal, "Aggregate error count must equal sum of sub-counters"); + } +} From dfafe6bec1e6652ae2cc24c4fb29e4198eb1dfb3 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Fri, 1 May 2026 12:04:50 -0600 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20@ConditionalOnBean=20guard=20and=20accurate=20log=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @ConditionalOnBean(MeterRegistry.class) to micrometerTurnstileMetrics so the Micrometer-backed bean only registers when a MeterRegistry bean actually exists, gracefully falling back to NoOpTurnstileMetrics otherwise. Update noOpTurnstileMetrics log message to accurately describe all fallback scenarios (no Micrometer, metrics disabled, no registry available). Update TurnstileMetricsWiringTest to assert the correct post-fix behavior: NoOpTurnstileMetrics is active when MetricsAutoConfiguration is excluded and no MeterRegistry bean is present. --- .../cf/turnstile/config/TurnstileMetricsConfig.java | 2 ++ .../cf/turnstile/config/TurnstileServiceConfig.java | 2 +- .../test/turnstile/TurnstileMetricsWiringTest.java | 12 +++++++----- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java index 9d5dc39..48244a5 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java @@ -1,6 +1,7 @@ package com.digitalsanctuary.cf.turnstile.config; import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -31,6 +32,7 @@ public class TurnstileMetricsConfig { * @return a MicrometerTurnstileMetrics instance */ @Bean + @ConditionalOnBean(MeterRegistry.class) @ConditionalOnMissingBean(TurnstileMetrics.class) public TurnstileMetrics micrometerTurnstileMetrics(MeterRegistry registry) { return new MicrometerTurnstileMetrics(registry); diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java index 85e58ad..e928bb0 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java @@ -37,7 +37,7 @@ public class TurnstileServiceConfig { @Bean @ConditionalOnMissingBean(TurnstileMetrics.class) public TurnstileMetrics noOpTurnstileMetrics() { - log.info("Micrometer not available — using no-op Turnstile metrics"); + log.info("No TurnstileMetrics bean available — using no-op Turnstile metrics"); return new NoOpTurnstileMetrics(); } diff --git a/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java b/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java index e83bd4a..ab2da8c 100644 --- a/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java +++ b/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java @@ -38,15 +38,17 @@ class TurnstileMetricsWiringTest { private TurnstileMetrics turnstileMetrics; /** - * Verifies that a TurnstileMetrics bean is available when metrics auto-configs are excluded. - * Because Micrometer is still on the test classpath, MicrometerTurnstileMetrics remains active. + * Verifies that the NoOp fallback is active when MetricsAutoConfiguration is excluded. + * Even though Micrometer is on the classpath, no MeterRegistry bean is registered when + * MetricsAutoConfiguration is excluded, so @ConditionalOnBean(MeterRegistry.class) fails + * and the NoOpTurnstileMetrics fallback is used instead. */ @Test - void metricsAvailableWhenMicrometerAutoConfigExcluded() { + void noOpMetricsActiveWhenMicrometerAutoConfigExcluded() { assertNotNull(turnstileMetrics, "A TurnstileMetrics bean should be available even with Micrometer auto-config excluded"); - assertFalse(turnstileMetrics instanceof NoOpTurnstileMetrics, - "MicrometerTurnstileMetrics should be active when Micrometer is on the classpath"); + assertTrue(turnstileMetrics instanceof NoOpTurnstileMetrics, + "NoOpTurnstileMetrics should be active when no MeterRegistry bean is available"); } /**