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. diff --git a/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java b/src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java index c36b92c..a6bd9ae 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,23 @@ 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 {@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") @@ -76,10 +59,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() { 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..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,11 +1,14 @@ 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; 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 +17,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 +26,28 @@ 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 + @ConditionalOnBean(MeterRegistry.class) + @ConditionalOnMissingBean(TurnstileMetrics.class) + 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 45ec2b2..e928bb0 100644 --- a/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java @@ -2,23 +2,22 @@ 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.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 io.micrometer.core.instrument.MeterRegistry; 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 @@ -26,18 +25,34 @@ public class TurnstileServiceConfig { private final TurnstileConfigProperties properties; - private final ObjectProvider meterRegistryProvider; + + /** + * 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 + */ + @Bean + @ConditionalOnMissingBean(TurnstileMetrics.class) + public TurnstileMetrics noOpTurnstileMetrics() { + log.info("No TurnstileMetrics bean 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) { - Optional optionalRegistry = Optional.ofNullable(meterRegistryProvider.getIfAvailable()); - return new TurnstileValidationService(restClient, properties, optionalRegistry); + public TurnstileValidationService turnstileValidationService( + @Qualifier("turnstileRestClient") RestClient restClient, + TurnstileMetrics metrics) { + return new TurnstileValidationService(restClient, properties, metrics); } /** @@ -51,12 +66,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())); @@ -67,5 +80,4 @@ public RestClient turnstileRestClient() { .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .build(); } - } 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..7d6d9dc --- /dev/null +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java @@ -0,0 +1,101 @@ +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.Objects; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; + +/** + * 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 { + + 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; + + /** + * 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); + 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); + } +} 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..3cf446a --- /dev/null +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java @@ -0,0 +1,25 @@ +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() { // no-op + } + + @Override + public void recordSuccess() { // no-op + } + + @Override + public void recordError(ValidationResultType type) { // no-op + } + + @Override + public void recordResponseTime(long milliseconds) { // no-op + } +} 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..f35c564 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java @@ -0,0 +1,53 @@ +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 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(); + + /** + * 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); + + /** + * 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 2d20a62..b1abccd 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; @@ -20,9 +19,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; @@ -32,21 +29,20 @@ * 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 -@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 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 +54,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 +82,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 +101,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 +118,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 +131,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 +141,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 +159,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 +185,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 +192,46 @@ 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); - } + return executeValidationRequest(requestBody); } 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 + log.debug("Turnstile token rejected by Cloudflare: {}", e.getMessage()); 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); + 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 { + long elapsed = System.currentTimeMillis() - startTime; + lastResponseTime.set(elapsed); + totalResponseTime.addAndGet(elapsed); + responseCount.incrementAndGet(); + try { + metrics.recordResponseTime(elapsed); + } catch (Exception metricsEx) { + log.warn("Failed to record response time metric; validation result is unaffected: {}", metricsEx.getMessage(), metricsEx); + } } } - /** - * 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,63 +242,31 @@ 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()); - recordError(ValidationResultType.INVALID_TOKEN); throw new TurnstileValidationException("Token validation failed", response.getErrorCodes()); } } - /** - * 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 + * @return total number of validation attempts */ public long getValidationCount() { return validationCount.sum(); @@ -398,7 +275,7 @@ public long getValidationCount() { /** * Gets the number of successful validations. * - * @return the number of successful validations + * @return number of successful validations */ public long getSuccessCount() { return successCount.sum(); @@ -407,7 +284,7 @@ public long getSuccessCount() { /** * Gets the number of failed validations. * - * @return the number of failed validations + * @return number of failed validations */ public long getErrorCount() { return errorCount.sum(); @@ -416,7 +293,7 @@ public long getErrorCount() { /** * Gets the number of network errors. * - * @return the number of network errors + * @return number of network errors */ public long getNetworkErrorCount() { return networkErrorCount.sum(); @@ -425,7 +302,7 @@ public long getNetworkErrorCount() { /** * Gets the number of configuration errors. * - * @return the number of configuration errors + * @return number of configuration errors */ public long getConfigErrorCount() { return configErrorCount.sum(); @@ -434,7 +311,7 @@ public long getConfigErrorCount() { /** * Gets the number of validation errors (invalid tokens). * - * @return the number of validation errors + * @return number of validation errors */ public long getValidationErrorCount() { return validationErrorCount.sum(); @@ -443,7 +320,7 @@ public long getValidationErrorCount() { /** * Gets the number of input validation errors. * - * @return the number of input validation errors + * @return number of input validation errors */ public long getInputErrorCount() { return inputErrorCount.sum(); @@ -452,7 +329,7 @@ public long getInputErrorCount() { /** * Gets the time of the last response in milliseconds. * - * @return the time of the last response in milliseconds + * @return time of last response in milliseconds */ public long getLastResponseTime() { return lastResponseTime.get(); @@ -461,7 +338,7 @@ public long getLastResponseTime() { /** * 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(); @@ -471,7 +348,7 @@ public double getAverageResponseTime() { /** * 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(); @@ -492,7 +369,7 @@ public String getTurnsiteSitekey() { /** * Gets the Turnstile Sitekey. * - * @return the Turnstile Sitekey. + * @return the Turnstile Sitekey */ public String getTurnstileSitekey() { return properties.getSitekey(); @@ -512,7 +389,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; 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"); + } +} diff --git a/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java b/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java new file mode 100644 index 0000000..ab2da8c --- /dev/null +++ b/src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java @@ -0,0 +1,73 @@ +package com.digitalsanctuary.cf.test.turnstile; + +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.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; + +/** + * 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, + properties = { + "spring.autoconfigure.exclude=" + + "org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration," + + "org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration" + } +) +@ActiveProfiles("test") +class TurnstileMetricsWiringTest { + + @Autowired + private TurnstileValidationService turnstileValidationService; + + @Autowired + private TurnstileMetrics turnstileMetrics; + + /** + * 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 noOpMetricsActiveWhenMicrometerAutoConfigExcluded() { + assertNotNull(turnstileMetrics, + "A TurnstileMetrics bean should be available even with Micrometer auto-config excluded"); + assertTrue(turnstileMetrics instanceof NoOpTurnstileMetrics, + "NoOpTurnstileMetrics should be active when no MeterRegistry bean is available"); + } + + /** + * 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(); + assertTrue(after > before, "Validation count should have incremented"); + } +}