Skip to content

Commit

Permalink
Support multiple spec files (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
pboos authored May 23, 2023
1 parent 739f901 commit 2b0a876
Show file tree
Hide file tree
Showing 10 changed files with 399 additions and 18 deletions.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, LogLevel> levelResolverLevels;

private final List<PathPatternSpec> specificationPaths;

public record PathPatternSpec(Pattern pathPattern, String specificationFilePath) {
}
}
Original file line number Diff line number Diff line change
@@ -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<String, LogLevel> levelResolverLevels;
private List<ValidatorConfiguration.PathPatternSpec> 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
+ ")";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, LogLevel> 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;
Expand Down Expand Up @@ -95,17 +135,22 @@ private Optional<String> loadSpecFromResources(String resourceFileLocation) {
}
}

private LevelResolver buildLevelResolver(ValidatorConfiguration configuration) {
private LevelResolver buildLevelResolver(
Map<String, LogLevel> 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();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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;
import com.getyourguide.openapi.validation.api.model.Direction;
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;
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Pair<Pattern, OpenApiInteractionValidatorWrapper>> validators;

public MultipleSpecOpenApiInteractionValidatorWrapper(
List<Pair<Pattern, OpenApiInteractionValidatorWrapper>> 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<OpenApiInteractionValidatorWrapper> 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<Message> messages;

@Nonnull
@Override
public List<Message> 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<String> getAdditionalInfo() {
return List.of();
}

@Override
public Optional<ValidationReport.MessageContext> 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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Loading

0 comments on commit 2b0a876

Please sign in to comment.