diff --git a/README.md b/README.md index b455eb1d..200d465b 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,44 @@ public class SampleRateTrafficSelector implements TrafficSelector { } ``` +### Custom log levels +One can customize log levels as per the following example. The default log level is `info`. + +The key to be used here is also printed in the violation log message. + +```java +@Configuration +public class ValidatorConfiguration { + @Bean + public ValidatorConfiguration buildValidatorConfiguration() { + return new ValidatorConfigurationBuilder() + .levelResolverLevel("validation.request.body.schema.additionalProperties", LogLevel.ERROR) + .levelResolverDefaultLevel(LogLevel.INFO) + .build(); + } +} +``` + +### Multiple spec files +It is possible to use multiple spec files for different paths. This can be achieved as demonstrated in the following +code snipped. + +It is best practice to use a catch-all spec file. If a request is not matching any of the paths defined here it will +result in a violation error with log level `warn`. + +```java +@Configuration +public class ValidatorConfiguration { + @Bean + public ValidatorConfiguration buildValidatorConfiguration() { + return new ValidatorConfigurationBuilder() + .specificationPath(Pattern.compile("/v1/.*"), "openapi-v1.yaml") + .specificationPath(Pattern.compile("/.*"), "openapi.yaml") + .build(); + } +} +``` + ## Examples Run examples with `./gradlew :examples:example-spring-boot-starter-web:bootRun` or `./gradlew :examples:example-spring-boot-starter-webflux:bootRun`. diff --git a/openapi-validation-api/src/main/java/com/getyourguide/openapi/validation/api/model/ValidatorConfiguration.java b/openapi-validation-api/src/main/java/com/getyourguide/openapi/validation/api/model/ValidatorConfiguration.java index 60fa52d7..1b0d0ea9 100644 --- a/openapi-validation-api/src/main/java/com/getyourguide/openapi/validation/api/model/ValidatorConfiguration.java +++ b/openapi-validation-api/src/main/java/com/getyourguide/openapi/validation/api/model/ValidatorConfiguration.java @@ -1,13 +1,20 @@ package com.getyourguide.openapi.validation.api.model; import com.getyourguide.openapi.validation.api.log.LogLevel; +import java.util.List; import java.util.Map; -import lombok.Builder; +import java.util.regex.Pattern; +import lombok.AllArgsConstructor; import lombok.Getter; -@Builder +@AllArgsConstructor @Getter public class ValidatorConfiguration { private final LogLevel levelResolverDefaultLevel; private final Map levelResolverLevels; + + private final List specificationPaths; + + public record PathPatternSpec(Pattern pathPattern, String specificationFilePath) { + } } diff --git a/openapi-validation-api/src/main/java/com/getyourguide/openapi/validation/api/model/ValidatorConfigurationBuilder.java b/openapi-validation-api/src/main/java/com/getyourguide/openapi/validation/api/model/ValidatorConfigurationBuilder.java new file mode 100644 index 00000000..ba5ef91d --- /dev/null +++ b/openapi-validation-api/src/main/java/com/getyourguide/openapi/validation/api/model/ValidatorConfigurationBuilder.java @@ -0,0 +1,50 @@ +package com.getyourguide.openapi.validation.api.model; + +import com.getyourguide.openapi.validation.api.log.LogLevel; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +public class ValidatorConfigurationBuilder { + private LogLevel levelResolverDefaultLevel; + private Map levelResolverLevels; + private List specificationPaths; + + public ValidatorConfigurationBuilder levelResolverDefaultLevel(LogLevel levelResolverDefaultLevel) { + this.levelResolverDefaultLevel = levelResolverDefaultLevel; + return this; + } + + public ValidatorConfigurationBuilder levelResolverLevel(String messageKey, LogLevel level) { + if (this.levelResolverLevels == null) { + this.levelResolverLevels = new HashMap<>(); + } + this.levelResolverLevels.put(messageKey, level); + return this; + } + + public ValidatorConfigurationBuilder specificationPath(Pattern pathPattern, String specPath) { + if (this.specificationPaths == null) { + this.specificationPaths = new ArrayList<>(); + } + this.specificationPaths.add(new ValidatorConfiguration.PathPatternSpec(pathPattern, specPath)); + return this; + } + + public ValidatorConfiguration build() { + return new ValidatorConfiguration( + levelResolverDefaultLevel, + levelResolverLevels, + specificationPaths + ); + } + + public String toString() { + return "ValidatorConfigurationBuilder(" + + "levelResolverDefaultLevel=" + this.levelResolverDefaultLevel + ", " + + "levelResolverLevels=" + this.levelResolverLevels + + ")"; + } +} diff --git a/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiInteractionValidatorFactory.java b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiInteractionValidatorFactory.java index 282051f3..7da3cea5 100644 --- a/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiInteractionValidatorFactory.java +++ b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiInteractionValidatorFactory.java @@ -5,37 +5,77 @@ import com.atlassian.oai.validator.report.ValidationReport; import com.getyourguide.openapi.validation.api.log.LogLevel; import com.getyourguide.openapi.validation.api.model.ValidatorConfiguration; +import com.getyourguide.openapi.validation.core.validator.MultipleSpecOpenApiInteractionValidatorWrapper; +import com.getyourguide.openapi.validation.core.validator.OpenApiInteractionValidatorWrapper; +import com.getyourguide.openapi.validation.core.validator.SingleSpecOpenApiInteractionValidatorWrapper; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; import javax.annotation.Nullable; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.tuple.Pair; @Slf4j public class OpenApiInteractionValidatorFactory { @Nullable - public OpenApiInteractionValidator build(String specificationFilePath, ValidatorConfiguration configuration) { + public OpenApiInteractionValidatorWrapper build( + String specificationFilePath, + ValidatorConfiguration configuration + ) { + if (configuration.getSpecificationPaths() != null && !configuration.getSpecificationPaths().isEmpty()) { + return buildMultipleSpecOpenApiInteractionValidatorWrapper(configuration); + } + var specOptional = loadOpenAPISpec(specificationFilePath); if (specOptional.isEmpty()) { log.info("OpenAPI spec file could not be found [validation disabled]"); return null; } - var spec = specOptional.get(); + return buildSingleSpecOpenApiInteractionValidatorWrapper(specOptional.get(), + configuration.getLevelResolverLevels(), configuration.getLevelResolverDefaultLevel()); + } + + private MultipleSpecOpenApiInteractionValidatorWrapper buildMultipleSpecOpenApiInteractionValidatorWrapper( + ValidatorConfiguration configuration) { + var validators = configuration.getSpecificationPaths().stream() + .map(entry -> { + var path = entry.specificationFilePath(); + var specOptional = loadSpecFromPath(path).or(() -> loadSpecFromResources(path)); + if (specOptional.isEmpty()) { + log.error("OpenAPI spec file {} could not be found", path); + return null; + } + var validator = buildSingleSpecOpenApiInteractionValidatorWrapper(specOptional.get(), + configuration.getLevelResolverLevels(), configuration.getLevelResolverDefaultLevel()); + return Pair.of(entry.pathPattern(), (OpenApiInteractionValidatorWrapper) validator); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + return new MultipleSpecOpenApiInteractionValidatorWrapper(validators); + } + + private SingleSpecOpenApiInteractionValidatorWrapper buildSingleSpecOpenApiInteractionValidatorWrapper( + String spec, + Map levelResolverLevels, + LogLevel levelResolverDefaultLevel + ) { try { - return OpenApiInteractionValidator + var validator = OpenApiInteractionValidator .createForInlineApiSpecification(spec) .withResolveRefs(true) .withResolveCombinators(true) // Inline to avoid problems with allOf - .withLevelResolver(buildLevelResolver(configuration)) + .withLevelResolver(buildLevelResolver(levelResolverLevels, levelResolverDefaultLevel)) .build(); + return new SingleSpecOpenApiInteractionValidatorWrapper(validator); } catch (Throwable e) { log.error("Could not initialize OpenApiInteractionValidator [validation disabled]", e); return null; @@ -95,17 +135,22 @@ private Optional loadSpecFromResources(String resourceFileLocation) { } } - private LevelResolver buildLevelResolver(ValidatorConfiguration configuration) { + private LevelResolver buildLevelResolver( + Map levelResolverLevels, + LogLevel levelResolverDefaultLevel + ) { var builder = LevelResolver.create(); - if (configuration.getLevelResolverLevels() != null && !configuration.getLevelResolverLevels().isEmpty()) { + if (levelResolverLevels != null && !levelResolverLevels.isEmpty()) { builder.withLevels( - configuration.getLevelResolverLevels().entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, entry -> mapLevel(entry.getValue()).orElse(ValidationReport.Level.INFO))) + levelResolverLevels.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> + mapLevel(entry.getValue()).orElse(ValidationReport.Level.INFO)) + ) ); } return builder // this will cause all messages to be warn by default - .withDefaultLevel(mapLevel(configuration.getLevelResolverDefaultLevel()).orElse(ValidationReport.Level.INFO)) + .withDefaultLevel(mapLevel(levelResolverDefaultLevel).orElse(ValidationReport.Level.INFO)) .build(); } 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 42e87e1c..4ec73fa7 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 @@ -1,6 +1,5 @@ package com.getyourguide.openapi.validation.core; -import com.atlassian.oai.validator.OpenApiInteractionValidator; import com.atlassian.oai.validator.model.Request; import com.atlassian.oai.validator.model.SimpleRequest; import com.atlassian.oai.validator.model.SimpleResponse; @@ -8,6 +7,7 @@ import com.getyourguide.openapi.validation.api.model.RequestMetaData; import com.getyourguide.openapi.validation.api.model.ResponseMetaData; import com.getyourguide.openapi.validation.api.model.ValidatorConfiguration; +import com.getyourguide.openapi.validation.core.validator.OpenApiInteractionValidatorWrapper; import java.nio.charset.StandardCharsets; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; @@ -19,7 +19,7 @@ public class OpenApiRequestValidator { private final ThreadPoolExecutor threadPool = new ThreadPoolExecutor(2, 2, 1000L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(10)); - private final OpenApiInteractionValidator validator; + private final OpenApiInteractionValidatorWrapper validator; private final ValidationReportHandler validationReportHandler; public OpenApiRequestValidator(ValidationReportHandler validationReportHandler, String specificationFilePath, ValidatorConfiguration configuration) { diff --git a/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/validator/MultipleSpecOpenApiInteractionValidatorWrapper.java b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/validator/MultipleSpecOpenApiInteractionValidatorWrapper.java new file mode 100644 index 00000000..d5ef5049 --- /dev/null +++ b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/validator/MultipleSpecOpenApiInteractionValidatorWrapper.java @@ -0,0 +1,125 @@ +package com.getyourguide.openapi.validation.core.validator; + +import com.atlassian.oai.validator.model.Request; +import com.atlassian.oai.validator.model.SimpleRequest; +import com.atlassian.oai.validator.model.SimpleResponse; +import com.atlassian.oai.validator.report.ValidationReport; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; +import javax.annotation.Nonnull; +import lombok.AllArgsConstructor; +import org.apache.commons.lang3.tuple.Pair; + +public class MultipleSpecOpenApiInteractionValidatorWrapper implements OpenApiInteractionValidatorWrapper { + public static final String MESSAGE_KEY_VALIDATOR_FOUND = "zopenapi-validator-java.noValidatorFound"; + private final List> validators; + + public MultipleSpecOpenApiInteractionValidatorWrapper( + List> validators + ) { + assert validators != null && validators.size() > 0; + + this.validators = validators; + } + + @Override + public ValidationReport validateRequest(SimpleRequest request) { + return getValidatorForPath(request.getPath()) + .map(validator -> validator.validateRequest(request)) + .orElse(new SimpleValidationReport(List.of(buildNoValidatorFoundMessage(request.getPath())))); + } + + @Override + public ValidationReport validateResponse(String path, Request.Method method, SimpleResponse response) { + return getValidatorForPath(path) + .map(validator -> validator.validateResponse(path, method, response)) + .orElse(new SimpleValidationReport(List.of(buildNoValidatorFoundMessage(path)))); + } + + private Optional getValidatorForPath(String path) { + for (var validator : validators) { + if (validator.getLeft().matcher(path).matches()) { + return Optional.of(validator.getRight()); + } + } + + return Optional.empty(); + } + + private static SimpleMessage buildNoValidatorFoundMessage(String path) { + return new SimpleMessage( + MESSAGE_KEY_VALIDATOR_FOUND, + "No validator found for path: " + path, + ValidationReport.Level.WARN + ); + } + + @AllArgsConstructor + private static class SimpleValidationReport implements ValidationReport { + private final List messages; + + @Nonnull + @Override + public List getMessages() { + return messages; + } + + @Override + public ValidationReport withAdditionalContext(MessageContext context) { + return this; + } + } + + @AllArgsConstructor + private static class SimpleMessage implements ValidationReport.Message { + private final String key; + private final String message; + private final ValidationReport.Level level; + + @Override + public String getKey() { + return key; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public ValidationReport.Level getLevel() { + return level; + } + + @Override + public List getAdditionalInfo() { + return List.of(); + } + + @Override + public Optional getContext() { + return Optional.empty(); + } + + @Override + public ValidationReport.Message withLevel(ValidationReport.Level level) { + return this; + } + + @Override + public ValidationReport.Message withAdditionalInfo(String info) { + return this; + } + + @Override + public ValidationReport.Message withAdditionalContext(ValidationReport.MessageContext context) { + return this; + } + + @Override + public String toString() { + return message; + } + } +} diff --git a/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/validator/OpenApiInteractionValidatorWrapper.java b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/validator/OpenApiInteractionValidatorWrapper.java new file mode 100644 index 00000000..ea273b68 --- /dev/null +++ b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/validator/OpenApiInteractionValidatorWrapper.java @@ -0,0 +1,12 @@ +package com.getyourguide.openapi.validation.core.validator; + +import com.atlassian.oai.validator.model.Request; +import com.atlassian.oai.validator.model.SimpleRequest; +import com.atlassian.oai.validator.model.SimpleResponse; +import com.atlassian.oai.validator.report.ValidationReport; + +public interface OpenApiInteractionValidatorWrapper { + ValidationReport validateRequest(SimpleRequest request); + + ValidationReport validateResponse(String path, Request.Method method, SimpleResponse response); +} diff --git a/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/validator/SingleSpecOpenApiInteractionValidatorWrapper.java b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/validator/SingleSpecOpenApiInteractionValidatorWrapper.java new file mode 100644 index 00000000..4ba34aa5 --- /dev/null +++ b/openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/validator/SingleSpecOpenApiInteractionValidatorWrapper.java @@ -0,0 +1,23 @@ +package com.getyourguide.openapi.validation.core.validator; + +import com.atlassian.oai.validator.OpenApiInteractionValidator; +import com.atlassian.oai.validator.model.Request; +import com.atlassian.oai.validator.model.SimpleRequest; +import com.atlassian.oai.validator.model.SimpleResponse; +import com.atlassian.oai.validator.report.ValidationReport; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class SingleSpecOpenApiInteractionValidatorWrapper implements OpenApiInteractionValidatorWrapper { + private final OpenApiInteractionValidator validator; + + @Override + public ValidationReport validateRequest(SimpleRequest request) { + return validator.validateRequest(request); + } + + @Override + public ValidationReport validateResponse(String path, Request.Method method, SimpleResponse response) { + return validator.validateResponse(path, method, response); + } +} diff --git a/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/validator/MultipleSpecOpenApiInteractionValidatorWrapperTest.java b/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/validator/MultipleSpecOpenApiInteractionValidatorWrapperTest.java new file mode 100644 index 00000000..53eb5b49 --- /dev/null +++ b/openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/validator/MultipleSpecOpenApiInteractionValidatorWrapperTest.java @@ -0,0 +1,83 @@ +package com.getyourguide.openapi.validation.core.validator; + +import static com.getyourguide.openapi.validation.core.validator.MultipleSpecOpenApiInteractionValidatorWrapper.MESSAGE_KEY_VALIDATOR_FOUND; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.atlassian.oai.validator.model.Request; +import com.atlassian.oai.validator.model.SimpleRequest; +import com.atlassian.oai.validator.report.ValidationReport; +import java.util.List; +import java.util.regex.Pattern; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.Test; + +public class MultipleSpecOpenApiInteractionValidatorWrapperTest { + private MultipleSpecOpenApiInteractionValidatorWrapper validator; + + @Test + public void testCallsCorrectValidator() { + var specificValidator = mockValidator(); + var catchAllValidator = mockValidator(); + + validator = new MultipleSpecOpenApiInteractionValidatorWrapper( + List.of( + Pair.of(Pattern.compile("/test/.*"), specificValidator.validator), + Pair.of(Pattern.compile(".*"), catchAllValidator.validator) + ) + ); + + assertRequestPathHitsCorrectValidator(specificValidator.validationReport, "/test/123"); + assertRequestPathHitsCorrectValidator(catchAllValidator.validationReport, "/123"); + + assertResponsePathHitsCorrectValidator(specificValidator.validationReport, "/test/123"); + assertResponsePathHitsCorrectValidator(catchAllValidator.validationReport, "/123"); + } + + @Test + public void testReturnsViolationWhenNoMatchingValidatorFound() { + var specificValidator = mockValidator(); + + validator = new MultipleSpecOpenApiInteractionValidatorWrapper( + List.of( + Pair.of(Pattern.compile("/test/.*"), specificValidator.validator()) + ) + ); + + var path = "/123"; + var report = validator.validateRequest(new SimpleRequest.Builder("GET", path).build()); + + var messages = report.getMessages(); + assertEquals(1, messages.size()); + var message = messages.get(0); + assertEquals(MESSAGE_KEY_VALIDATOR_FOUND, message.getKey()); + assertEquals("No validator found for path: /123", message.getMessage()); + } + + private static MockValidatorResult mockValidator() { + var catchAllValidator = mock(OpenApiInteractionValidatorWrapper.class); + var catchAllValidationReport = mock(ValidationReport.class); + when(catchAllValidator.validateRequest(any())).thenReturn(catchAllValidationReport); + when(catchAllValidator.validateResponse(any(), any(), any())).thenReturn(catchAllValidationReport); + MockValidatorResult result = new MockValidatorResult(catchAllValidator, catchAllValidationReport); + return result; + } + + private record MockValidatorResult( + OpenApiInteractionValidatorWrapper validator, + ValidationReport validationReport + ) { + } + + private void assertRequestPathHitsCorrectValidator(ValidationReport validationReport, String path) { + var report = validator.validateRequest(new SimpleRequest.Builder("GET", path).build()); + assertEquals(validationReport, report); + } + + private void assertResponsePathHitsCorrectValidator(ValidationReport validationReport, String path) { + var report = validator.validateResponse(path, Request.Method.GET, mock()); + assertEquals(validationReport, report); + } +} 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 334c0dab..dbe0e724 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 @@ -8,6 +8,7 @@ import com.getyourguide.openapi.validation.api.metrics.MetricsReporter; import com.getyourguide.openapi.validation.api.metrics.NoOpMetricsReporter; import com.getyourguide.openapi.validation.api.model.ValidatorConfiguration; +import com.getyourguide.openapi.validation.api.model.ValidatorConfigurationBuilder; import com.getyourguide.openapi.validation.core.DefaultViolationLogger; import com.getyourguide.openapi.validation.core.OpenApiRequestValidator; import com.getyourguide.openapi.validation.core.ValidationReportHandler; @@ -69,11 +70,8 @@ public ValidationReportHandler validationReportHandler( @Bean @ConditionalOnMissingBean public ValidatorConfiguration validatorConfiguration() { - return ValidatorConfiguration.builder() - // Example: .levelResolverLevels(levels) - // levels = mapOf( - // "validation.request.body.schema.additionalProperties" => LogLevel.IGNORE - // ) + return new ValidatorConfigurationBuilder() + // .levelResolverLevel("validation.request.body.schema.additionalProperties", LogLevel.IGNORE) .levelResolverDefaultLevel(LogLevel.INFO) .build(); }