diff --git a/README.md b/README.md index fa19c3d..3d4824f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,26 @@ Finally, you can run the images with: The regular docker-compose.yml is reserved for production deployments, and will always use the latest images +## Configuration +Configuration for this app is done via the `resources/application.properties` file. + +### BRAVA +Configuring the BRAVA tool is important because you can define supported BrAPI versions and where to get the schema files +for each BrAPI module via: + +``` +# Additional supported versions can be added here using commas like: 2.1,2.0,1.3, etc +brapi.schema.versions=2.1 + +# Here use '_' for all .'s in versions of BrAPI to load properly +brapi.schema.urls.v2_1.core=https://api.swaggerhub.com/apis/PlantBreedingAPI/BrAPI-Core/2.1/swagger.yaml?resolved=false&flatten=false +brapi.schema.urls.v2_1.germplasm=https://api.swaggerhub.com/apis/PlantBreedingAPI/BrAPI-Germplasm/2.1/swagger.yaml?resolved=false&flatten=false +brapi.schema.urls.v2_1.genotyping=https://api.swaggerhub.com/apis/PlantBreedingAPI/BrAPI-Genotyping/2.1/swagger.yaml?resolved=false&flatten=false +brapi.schema.urls.v2_1.phenotyping=https://api.swaggerhub.com/apis/PlantBreedingAPI/BrAPI-Phenotyping/2.1/swagger.yaml?resolved=false&flatten=false +``` + +On startup the system will load all the supported versions and URLs. + ## Releases Releases will trigger an action to build and upload an image to Docker Hub diff --git a/build.gradle b/build.gradle index 69bd9b2..d76399b 100644 --- a/build.gradle +++ b/build.gradle @@ -19,12 +19,14 @@ repositories { } ext { - usecaseinatorVersion = '1.0.3' + usecaseinatorVersion = '1.0.4' } dependencies { implementation "org.brapi:use-caseinator:${usecaseinatorVersion}" + implementation "org.brapi.brapi-schema-tools:brapi-schema-tools-analyse:0.68.0" + implementation 'com.atlassian.oai:swagger-request-validator-core:2.44.1' implementation "org.springframework.boot:spring-boot-starter-web" compileOnly "org.projectlombok:lombok:1.18.34" diff --git a/src/main/java/org/bravatools/BravaToolsBackend.java b/src/main/java/org/bravatools/BravaToolsBackend.java index 5f0e390..a65a217 100644 --- a/src/main/java/org/bravatools/BravaToolsBackend.java +++ b/src/main/java/org/bravatools/BravaToolsBackend.java @@ -1,11 +1,9 @@ package org.bravatools; import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -@EnableAutoConfiguration public class BravaToolsBackend { public static void main(String[] args) { SpringApplication.run(BravaToolsBackend.class, args); diff --git a/src/main/java/org/bravatools/brava/controller/ValidationController.java b/src/main/java/org/bravatools/brava/controller/ValidationController.java new file mode 100644 index 0000000..bbcadc0 --- /dev/null +++ b/src/main/java/org/bravatools/brava/controller/ValidationController.java @@ -0,0 +1,32 @@ +package org.bravatools.brava.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import lombok.extern.slf4j.Slf4j; +import org.bravatools.brava.model.ValidationRequest; +import org.bravatools.brava.model.ValidationResponse; +import org.bravatools.brava.service.ValidationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/validator") +@Slf4j +public class ValidationController { + + private ValidationService validationService; + + @Autowired + public ValidationController(ValidationService validationService) { + this.validationService = validationService; + } + + @PostMapping("/validateall") + public ResponseEntity availableBrapps( + @RequestBody ValidationRequest validationRequest + ) throws JsonProcessingException { + ValidationResponse result = validationService.validateAll(validationRequest); + + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/org/bravatools/brava/model/BrAPIEntity.java b/src/main/java/org/bravatools/brava/model/BrAPIEntity.java new file mode 100644 index 0000000..63e70ed --- /dev/null +++ b/src/main/java/org/bravatools/brava/model/BrAPIEntity.java @@ -0,0 +1,14 @@ +package org.bravatools.brava.model; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class BrAPIEntity { + private String name; + private List testedEndpoints; + private ValidationSuccessLevel validationSuccessLevel = ValidationSuccessLevel.SUCCESS; +} diff --git a/src/main/java/org/bravatools/brava/model/TestedEndpoint.java b/src/main/java/org/bravatools/brava/model/TestedEndpoint.java new file mode 100644 index 0000000..97da879 --- /dev/null +++ b/src/main/java/org/bravatools/brava/model/TestedEndpoint.java @@ -0,0 +1,20 @@ +package org.bravatools.brava.model; + + +import com.atlassian.oai.validator.model.Request; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class TestedEndpoint { + private String path; + private Request.Method method; + private String summaryMessage; + private long responseTime; + private int statusCode; + private List validations; + private ValidationSuccessLevel validationSuccessLevel = ValidationSuccessLevel.SUCCESS; +} diff --git a/src/main/java/org/bravatools/brava/model/Validation.java b/src/main/java/org/bravatools/brava/model/Validation.java new file mode 100644 index 0000000..e175467 --- /dev/null +++ b/src/main/java/org/bravatools/brava/model/Validation.java @@ -0,0 +1,12 @@ +package org.bravatools.brava.model; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Validation { + private String message; + private ValidationSuccessLevel validationSuccessLevel = ValidationSuccessLevel.WARNING; + +} diff --git a/src/main/java/org/bravatools/brava/model/ValidationRequest.java b/src/main/java/org/bravatools/brava/model/ValidationRequest.java new file mode 100644 index 0000000..89b63ba --- /dev/null +++ b/src/main/java/org/bravatools/brava/model/ValidationRequest.java @@ -0,0 +1,12 @@ +package org.bravatools.brava.model; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ValidationRequest { + private String serverUrl; + private String version; + private String token; +} diff --git a/src/main/java/org/bravatools/brava/model/ValidationResponse.java b/src/main/java/org/bravatools/brava/model/ValidationResponse.java new file mode 100644 index 0000000..3c09613 --- /dev/null +++ b/src/main/java/org/bravatools/brava/model/ValidationResponse.java @@ -0,0 +1,12 @@ +package org.bravatools.brava.model; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class ValidationResponse { + List entities; +} diff --git a/src/main/java/org/bravatools/brava/model/ValidationSuccessLevel.java b/src/main/java/org/bravatools/brava/model/ValidationSuccessLevel.java new file mode 100644 index 0000000..d8f4735 --- /dev/null +++ b/src/main/java/org/bravatools/brava/model/ValidationSuccessLevel.java @@ -0,0 +1,7 @@ +package org.bravatools.brava.model; + +public enum ValidationSuccessLevel { + SUCCESS, + WARNING, + ERROR +} diff --git a/src/main/java/org/bravatools/brava/model/spec/SupportedSpecs.java b/src/main/java/org/bravatools/brava/model/spec/SupportedSpecs.java new file mode 100644 index 0000000..ee6ddac --- /dev/null +++ b/src/main/java/org/bravatools/brava/model/spec/SupportedSpecs.java @@ -0,0 +1,12 @@ +package org.bravatools.brava.model.spec; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class SupportedSpecs { + private List versions; +} diff --git a/src/main/java/org/bravatools/brava/model/spec/Version.java b/src/main/java/org/bravatools/brava/model/spec/Version.java new file mode 100644 index 0000000..9071812 --- /dev/null +++ b/src/main/java/org/bravatools/brava/model/spec/Version.java @@ -0,0 +1,13 @@ +package org.bravatools.brava.model.spec; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class Version { + private String name; + private List moduleUrls; +} diff --git a/src/main/java/org/bravatools/brava/service/ValidationService.java b/src/main/java/org/bravatools/brava/service/ValidationService.java new file mode 100644 index 0000000..c6ca4e1 --- /dev/null +++ b/src/main/java/org/bravatools/brava/service/ValidationService.java @@ -0,0 +1,222 @@ +package org.bravatools.brava.service; + +import com.atlassian.oai.validator.report.ValidationReport; +import io.swagger.v3.oas.models.OpenAPI; +import org.brapi.schematools.analyse.AnalysisOptions; +import org.brapi.schematools.analyse.AnalysisReport; +import org.brapi.schematools.analyse.BrAPISpecificationAnalyserFactory; +import org.brapi.schematools.core.authorization.NoAuthorizationProvider; +import org.brapi.schematools.core.response.Response; +import org.bravatools.brava.model.*; +import org.bravatools.brava.model.spec.SupportedSpecs; +import org.bravatools.brava.model.spec.Version; +import org.bravatools.config.BrAPISchemaProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.*; + +@Service +public class ValidationService { + + private final SupportedSpecs supportedSpecs; + private final HttpClient httpClient; + + + @Autowired + public ValidationService(BrAPISchemaProperties brAPISchemaProperties) { + this.supportedSpecs = brAPISchemaProperties.getSupportedSpecs(); + this.httpClient = HttpClient.newHttpClient(); + } + + public ValidationResponse validateAll(ValidationRequest validationRequest) { + List moduleSchemaUrls = schemaUrlsForVersion(validationRequest.getVersion()); + + List analysers + = produceBrAPIAnalysersForEachSchemaModule(moduleSchemaUrls, validationRequest.getServerUrl(), validationRequest.getToken()); + + // TODO: Optimize checks by only running against server-info. For now, just check all endpoints in the schemas. + + Map> analysisReportsByEntityTag = getAnalysisReportsByEntityTag(analysers); + + ValidationResponse validationResponse = new ValidationResponse(); + + List brapiEntities = new ArrayList<>(); + + for (Map.Entry> entry : analysisReportsByEntityTag.entrySet()) { + BrAPIEntity brAPIEntity = new BrAPIEntity(); + + brAPIEntity.setName(entry.getKey()); + + List testedEndpoints = buildTestedEndpoints(entry.getValue()); + + brAPIEntity.setTestedEndpoints(testedEndpoints); + + if (brAPIEntity.getTestedEndpoints().stream().anyMatch(te -> te.getValidationSuccessLevel().equals(ValidationSuccessLevel.ERROR))) { + brAPIEntity.setValidationSuccessLevel(ValidationSuccessLevel.ERROR); + } else if (brAPIEntity.getTestedEndpoints().stream().anyMatch(te -> te.getValidationSuccessLevel().equals(ValidationSuccessLevel.WARNING))) { + brAPIEntity.setValidationSuccessLevel(ValidationSuccessLevel.WARNING); + } + + brapiEntities.add(brAPIEntity); + } + + validationResponse.setEntities(brapiEntities); + + return validationResponse; + } + + private List schemaUrlsForVersion(String version) { + List moduleSchemaUrls = supportedSpecs.getVersions() + .stream() + .filter(v -> v.getName().equals(version)) + .findFirst() + .map(Version::getModuleUrls) + .orElse(List.of()); + + if (moduleSchemaUrls.isEmpty()) { + throw new IllegalStateException("No module urls found for submitted version " + version); + } + + return moduleSchemaUrls; + } + + private List produceBrAPIAnalysersForEachSchemaModule(List moduleSchemaUrls, + String serverBaseUrl, + String token) { + List analysers = new ArrayList<>(); + + String spec = null; + + for (String moduleSchemaUrl : moduleSchemaUrls) { + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(moduleSchemaUrl)).build(); + + spec = httpClient + .send(request, HttpResponse.BodyHandlers.ofString()) + .body(); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + + var analyser = new BrAPISpecificationAnalyserFactory(serverBaseUrl, + httpClient, + // TODO: Handle token use case + new NoAuthorizationProvider(), + // TODO: Once more documentation on configuration, load in custom analysis-options files depending on user input. Implement programmatic configuration as well + AnalysisOptions.load() + ).analyser(spec); + + analysers.add(analyser); + } + return analysers; + } + + private Map getPathToTagMap(OpenAPI openAPI) { + Map pathToTagMap = new HashMap<>(); + + openAPI.getPaths() + .forEach((path, pathItem) -> { + + var tag = pathItem.readOperations() + .getFirst() + .getTags() + .getFirst(); + + pathToTagMap.put(path, tag); + }); + return pathToTagMap; + } + + private Map> getAnalysisReportsByEntityTag(List analysers) { + Map> analysisReportsByEntityTag = new HashMap<>(); + + for (BrAPISpecificationAnalyserFactory.Analyser analyser : analysers) { + Response> analyserResponse = analyser.analyseAll(); + List analysisReports = analyserResponse.getResult(); + + Map entityTagByPath = getPathToTagMap(analyser.getOpenAPI()); + + for (AnalysisReport analysisReport : analysisReports) { + + var reportPath = analysisReport.getRequest().getValidatorRequest().getPath(); + var entityTag = entityTagByPath.get(reportPath); + + if (analysisReportsByEntityTag.containsKey(entityTag)) { + analysisReportsByEntityTag.get(entityTag).add(analysisReport); + } else { + analysisReportsByEntityTag.put(entityTag, new ArrayList<>(List.of(analysisReport))); + } + } + } + + return analysisReportsByEntityTag; + } + + private List buildTestedEndpoints(List analysisReports) { + + List testedEndpoints = new ArrayList<>(); + + for (AnalysisReport analysisReport : analysisReports) { + TestedEndpoint testedEndpoint = new TestedEndpoint(); + + testedEndpoint.setPath(analysisReport.getRequest().getValidatorRequest().getPath()); + testedEndpoint.setMethod(analysisReport.getRequest().getValidatorRequest().getMethod()); + testedEndpoint.setResponseTime(analysisReport.getTimeElapsed()); + + + if (analysisReport.getStatusCode() < 200 || analysisReport.getStatusCode() >= 300) { + + Validation validation = new Validation(); + + validation.setValidationSuccessLevel(ValidationSuccessLevel.ERROR); + testedEndpoint.setValidationSuccessLevel(ValidationSuccessLevel.ERROR); + validation.setMessage(String.format("Validation Failed with status code: %s", analysisReport.getStatusCode())); + + testedEndpoint.setValidations(List.of(validation)); + testedEndpoints.add(testedEndpoint); + // No need to look at other validations that failed if status code is not in success range. + continue; + } + + testedEndpoint.setStatusCode(analysisReport.getStatusCode()); + + // TODO: Add support to include full requests and responses in result. This can likely be done using analyze tool with cached variables + // TODO: Should we should show messages if everything is successful? What should we show, and does schema tools support this. + if (analysisReport.getValidationReport().hasErrors()) { + + List messages = analysisReport.getValidationReport() + .getMessages() + .stream() + .map(ValidationReport.Message::getMessage) + .toList(); + + testedEndpoint.setValidations(buildValidations(messages)); + testedEndpoint.setValidationSuccessLevel(ValidationSuccessLevel.WARNING); + } + + testedEndpoints.add(testedEndpoint); + } + return testedEndpoints; + } + + private List buildValidations(List validationMessages) { + + List validations = new ArrayList<>(); + + for (String validationMessage : validationMessages) { + Validation validation = new Validation(); + + validation.setValidationSuccessLevel(ValidationSuccessLevel.WARNING); + validation.setMessage(validationMessage); + + validations.add(validation); + } + + return validations; + } +} diff --git a/src/main/java/org/bravatools/config/BrAPISchemaProperties.java b/src/main/java/org/bravatools/config/BrAPISchemaProperties.java new file mode 100644 index 0000000..59a0fa7 --- /dev/null +++ b/src/main/java/org/bravatools/config/BrAPISchemaProperties.java @@ -0,0 +1,72 @@ +package org.bravatools.config; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import org.bravatools.brava.model.spec.SupportedSpecs; +import org.bravatools.brava.model.spec.Version; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +@ConfigurationProperties(prefix = "brapi.schema") +@Getter +@Setter +/** + * Loads schema properties for BRAVA like supported versions and where to get schemas for BrAPI modules. + */ +public class BrAPISchemaProperties { + + // Supported BrAPI versions. Comes from brapi.schema.versions in application.properties + private List versions = new ArrayList<>(); + + private Map> urls = new HashMap<>(); + + private SupportedSpecs supportedSpecs; + + @PostConstruct + public void init() { + if (this.versions.isEmpty()) { + throw new IllegalStateException("No BrAPI schema versions configured"); + } + + if (urls.isEmpty()) { + throw new IllegalStateException("No BrAPI schema URLs configured"); + } + + supportedSpecs = new SupportedSpecs(); + + List supportedVersions = new ArrayList<>(); + + for (String versionProperty : this.versions) { + var versionMatchUrlMap = "v" + versionProperty.replace('.', '_'); + + Version version = new Version(); + + version.setName(versionProperty); + + List moduleUrls = new ArrayList<>(); + + urls.get(versionMatchUrlMap).forEach((k, v) -> moduleUrls.add(v)); + + version.setModuleUrls(moduleUrls); + + if (version.getModuleUrls().isEmpty()) { + throw new IllegalStateException(String.format("No BrAPI schema module URLs configured for version [%s]", version.getName())); + } + + supportedVersions.add(version); + } + + supportedSpecs.setVersions(supportedVersions); + + if (supportedSpecs.getVersions().isEmpty()) { + throw new IllegalStateException("Unable to configure SupportedSpecs with supported versions"); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index eeae670..c84c30e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,2 +1,13 @@ server.port = 8080 -server.servlet.context-path=/brava \ No newline at end of file +server.servlet.context-path=/brava + +# Additional supported versions can be added here using commas like: 2.1,2.0,1.3, etc +brapi.schema.versions=2.1 + +# Here use '_' for all .'s in versions of BrAPI to load properly +brapi.schema.urls.v2_1.core=https://api.swaggerhub.com/apis/PlantBreedingAPI/BrAPI-Core/2.1/swagger.yaml?resolved=false&flatten=false +brapi.schema.urls.v2_1.germplasm=https://api.swaggerhub.com/apis/PlantBreedingAPI/BrAPI-Germplasm/2.1/swagger.yaml?resolved=false&flatten=false +brapi.schema.urls.v2_1.genotyping=https://api.swaggerhub.com/apis/PlantBreedingAPI/BrAPI-Genotyping/2.1/swagger.yaml?resolved=false&flatten=false +brapi.schema.urls.v2_1.phenotyping=https://api.swaggerhub.com/apis/PlantBreedingAPI/BrAPI-Phenotyping/2.1/swagger.yaml?resolved=false&flatten=false + +#Other schema version imports go here. Schema URLs will be loaded into the application on startup \ No newline at end of file