diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index fb00a7b..b730264 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -6,6 +6,9 @@ on: - '**' workflow_call: +permissions: + contents: write + concurrency: group: ${{ github.workflow }}${{ github.ref_name != github.event.repository.default_branch && github.ref || github.run_id }} cancel-in-progress: ${{ github.ref_name != github.event.repository.default_branch }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2e01b77..1d5da54 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,6 +5,9 @@ on: tags: - 'v*' +permissions: + contents: write + jobs: check: uses: ./.github/workflows/check.yml diff --git a/README.md b/README.md index 60cb14e..170c60e 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,9 @@ openapi.validation.validation-report-metric-additional-tags=service=example,team # Fail requests on request/response violations. Defaults to false. openapi.validation.should-fail-on-request-violation=true openapi.validation.should-fail-on-response-violation=true + +# Enable virtual threads for async validation. Defaults to false. +openapi.validation.enable-virtual-threads=true ``` ### DataDog metrics diff --git a/build.gradle b/build.gradle index 81c7f89..e4288e5 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ apply from: "${rootDir}/gradle/publish-root.gradle" allprojects { group = 'com.getyourguide.openapi.validation' description = 'OpenAPI Validation library' - version = '3.2.1' + version = '3.2.2' java { toolchain { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f90b8ce..5858d56 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] java = "21" -spring-boot = "3.5.0" +spring-boot = "3.5.3" spring-dependency-management = "1.1.7" openapi-generator = "7.13.0" openapi-tools = "0.2.6" -swagger = "2.2.32" -swagger-request-validator = "2.44.8" +swagger = "2.2.34" +swagger-request-validator = "2.44.9" jakarta-validation = "3.1.1" lombok = "1.18.38" commons-codec = "1.18.0" @@ -18,8 +18,8 @@ pmd = "7.14.0" jacoco = "0.8.13" # Testing mockito = "5.18.0" -junit-jupiter = "5.13.0" -junit-platform = "1.13.0" +junit-jupiter = "5.13.1" +junit-platform = "1.13.1" [libraries] openapi-tools-jacksonDatabindNullable = { group = "org.openapitools", name = "jackson-databind-nullable", version.ref = "openapi-tools" } diff --git a/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidator.java b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidator.java index 1991fda..1d3e922 100644 --- a/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidator.java +++ b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidator.java @@ -14,26 +14,26 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ThreadPoolExecutor; import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.apache.http.client.utils.URLEncodedUtils; @Slf4j public class OpenApiRequestValidator { - private final ThreadPoolExecutor threadPoolExecutor; + private final Executor executor; private final OpenApiInteractionValidatorWrapper validator; private final ValidationReportToOpenApiViolationsMapper mapper; public OpenApiRequestValidator( - ThreadPoolExecutor threadPoolExecutor, + Executor executor, MetricsReporter metricsReporter, OpenApiInteractionValidatorWrapper validator, ValidationReportToOpenApiViolationsMapper mapper, OpenApiRequestValidationConfiguration configuration ) { - this.threadPoolExecutor = threadPoolExecutor; + this.executor = executor; this.validator = validator; this.mapper = mapper; @@ -74,7 +74,7 @@ public void validateResponseObjectAsync( private void executeAsync(Runnable command) { try { - threadPoolExecutor.execute(command); + executor.execute(command); } catch (RejectedExecutionException ignored) { // ignored } diff --git a/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/executor/VirtualThreadLimitedExecutor.java b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/executor/VirtualThreadLimitedExecutor.java new file mode 100644 index 0000000..9aab3a9 --- /dev/null +++ b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/executor/VirtualThreadLimitedExecutor.java @@ -0,0 +1,54 @@ +package com.getyourguide.openapi.validation.core.executor; + +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicInteger; + +public class VirtualThreadLimitedExecutor implements Executor { + private static final int DEFAULT_MAX_CONCURRENT = 2; + private final int maxConcurrent; + private final AtomicInteger runningCount = new AtomicInteger(0); + + public VirtualThreadLimitedExecutor() { + this(DEFAULT_MAX_CONCURRENT); + } + + public VirtualThreadLimitedExecutor(int maxConcurrent) { + checkVirtualThreadSupport(); + this.maxConcurrent = maxConcurrent; + } + + public static boolean isSupported() { + try { + checkVirtualThreadSupport(); + return true; + } catch (UnsupportedOperationException | NoSuchMethodError e) { + return false; + } + } + + private static void checkVirtualThreadSupport() { + // This will throw NoSuchMethodError on Java < 21 + //noinspection ResultOfMethodCallIgnored + Thread.ofVirtual(); + } + + @Override + public void execute(Runnable command) { + if (runningCount.get() >= maxConcurrent) { + return; + } + + if (runningCount.incrementAndGet() > maxConcurrent) { + runningCount.decrementAndGet(); + return; + } + + Thread.ofVirtual().start(() -> { + try { + command.run(); + } finally { + runningCount.decrementAndGet(); + } + }); + } +} diff --git a/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidatorTest.java b/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidatorTest.java index 3ba4fbf..9cb2c38 100644 --- a/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidatorTest.java +++ b/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidatorTest.java @@ -14,8 +14,8 @@ import java.net.URI; import java.util.HashMap; import java.util.List; +import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ThreadPoolExecutor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -23,21 +23,21 @@ public class OpenApiRequestValidatorTest { - private ThreadPoolExecutor threadPoolExecutor; + private Executor executor; private OpenApiInteractionValidatorWrapper validator; private OpenApiRequestValidator openApiRequestValidator; @BeforeEach public void setup() { - threadPoolExecutor = mock(); + executor = mock(); validator = mock(); MetricsReporter metricsReporter = mock(); var mapper = mock(ValidationReportToOpenApiViolationsMapper.class); when(mapper.map(any(), any(), any(), any(), any())).thenReturn(List.of()); openApiRequestValidator = new OpenApiRequestValidator( - threadPoolExecutor, + executor, metricsReporter, validator, mapper, @@ -47,7 +47,7 @@ public void setup() { @Test public void testWhenThreadPoolExecutorRejectsExecutionThenItShouldNotThrow() { - Mockito.doThrow(new RejectedExecutionException()).when(threadPoolExecutor).execute(any()); + Mockito.doThrow(new RejectedExecutionException()).when(executor).execute(any()); openApiRequestValidator.validateRequestObjectAsync(mock(), null, null, mock()); } diff --git a/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationProperties.java b/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationProperties.java index 6e0f891..6448612 100644 --- a/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationProperties.java +++ b/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationProperties.java @@ -38,6 +38,7 @@ public class OpenApiValidationApplicationProperties { private List excludedHeaders; private Boolean shouldFailOnRequestViolation; private Boolean shouldFailOnResponseViolation; + private Boolean enableVirtualThreads; public double getSampleRate() { return sampleRate != null ? sampleRate : SAMPLE_RATE_DEFAULT; @@ -84,6 +85,10 @@ public List getExcludedHeaders() { .toList(); } + public boolean isEnableVirtualThreads() { + return enableVirtualThreads != null ? enableVirtualThreads : false; + } + public OpenApiRequestValidationConfiguration toOpenApiRequestValidationConfiguration() { return OpenApiRequestValidationConfiguration.builder() .sampleRate(getSampleRate()) diff --git a/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java b/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java index 29d8aa1..130d8b9 100644 --- a/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java +++ b/spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java @@ -19,12 +19,14 @@ import com.getyourguide.openapi.validation.core.OpenApiInteractionValidatorFactory; import com.getyourguide.openapi.validation.core.OpenApiRequestValidator; import com.getyourguide.openapi.validation.core.exclusions.InternalViolationExclusions; +import com.getyourguide.openapi.validation.core.executor.VirtualThreadLimitedExecutor; import com.getyourguide.openapi.validation.core.log.DefaultOpenApiViolationHandler; import com.getyourguide.openapi.validation.core.log.ExclusionsOpenApiViolationHandler; import com.getyourguide.openapi.validation.core.log.ThrottlingOpenApiViolationHandler; import com.getyourguide.openapi.validation.core.mapper.ValidationReportToOpenApiViolationsMapper; import com.getyourguide.openapi.validation.core.metrics.DefaultMetricsReporter; import java.util.Optional; +import java.util.concurrent.Executor; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -104,14 +106,7 @@ public OpenApiRequestValidator openApiRequestValidator( MetricsReporter metricsReporter, ValidatorConfiguration validatorConfiguration ) { - var threadPoolExecutor = new ThreadPoolExecutor( - 2, - 2, - 1000L, - TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>(10), - new ThreadPoolExecutor.DiscardPolicy() - ); + var threadPoolExecutor = createThreadPoolExecutor(); return new OpenApiRequestValidator( threadPoolExecutor, @@ -122,4 +117,20 @@ public OpenApiRequestValidator openApiRequestValidator( properties.toOpenApiRequestValidationConfiguration() ); } + + private Executor createThreadPoolExecutor() { + if (properties.isEnableVirtualThreads() && VirtualThreadLimitedExecutor.isSupported()) { + return new VirtualThreadLimitedExecutor(); + } + + // Fallback to ThreadPoolExecutor with regular threads + return new ThreadPoolExecutor( + 2, + 2, + 1000L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(10), + new ThreadPoolExecutor.DiscardPolicy() + ); + } } diff --git a/spring-boot-starter/spring-boot-starter-core/src/test/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationPropertiesTest.java b/spring-boot-starter/spring-boot-starter-core/src/test/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationPropertiesTest.java index f5f95c1..0f0a00f 100644 --- a/spring-boot-starter/spring-boot-starter-core/src/test/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationPropertiesTest.java +++ b/spring-boot-starter/spring-boot-starter-core/src/test/java/com/getyourguide/openapi/validation/OpenApiValidationApplicationPropertiesTest.java @@ -34,7 +34,8 @@ void getters() { EXCLUDED_PATHS, EXCLUDED_HEADERS, true, - false + false, + true ); assertEquals(SAMPLE_RATE, loggingConfiguration.getSampleRate());