diff --git a/src/main/java/org/breedinginsight/brapi/v2/BrAPIV2Controller.java b/src/main/java/org/breedinginsight/brapi/v2/BrAPIV2Controller.java index be5046077..64216350b 100644 --- a/src/main/java/org/breedinginsight/brapi/v2/BrAPIV2Controller.java +++ b/src/main/java/org/breedinginsight/brapi/v2/BrAPIV2Controller.java @@ -65,11 +65,12 @@ public BrAPIV2Controller(SecurityService securityService, ProgramService program public BrAPIServerInfoResponse serverinfo() { BrAPIServerInfo serverInfo = new BrAPIServerInfo(); setBrAPIServerInfo(serverInfo); - serverInfo.setServerDescription("BrAPI endpoints are not implemented at the root of this domain. Please make BrAPI calls in the context of a program (ex: https://app.breedinginsight.net/v1/programs/{programId}/brapi/v2)"); + serverInfo.setServerDescription("DeltaBreed provides server information and program discovery at this root. Program-scoped BrAPI calls use https://app.breedinginsight.net/v1/programs/{programId}/brapi/v2"); serverInfo.setCalls( new ServiceBuilder().versions("2.0", "2.1") .setBase("serverinfo").GET().build() + .setBase("programs").GET().POST().addPath("{programDbId}").GET().PUT().build() ); return new BrAPIServerInfoResponse().result(serverInfo); @@ -79,82 +80,39 @@ public BrAPIServerInfoResponse serverinfo() { @Produces(MediaType.APPLICATION_JSON) @Secured(SecurityRule.IS_ANONYMOUS) public BrAPIServerInfoResponse programServerinfo(@PathVariable("programId") UUID programId) { - String programBrAPIBase = String.format("v1/programs/%s%s/", programId, BrapiVersion.BRAPI_V2); - List programServices = new ServiceBuilder() .versions("2.0", "2.1") //CORE .setBase("serverinfo").GET().build() .setBase("commoncropnames").GET().build() - .setBase("lists").GET().POST().addPath("{listDbId}").GET().PUT().withSearch() - .setBase("locations").GET().addPath("{locationDbId}").GET().withSearch() - .setBase("people").GET().addPath("{personDbId}").GET().withSearch() - .setBase("programs").GET().addPath("{programDbId}").GET().withSearch() - .setBase("seasons").GET().addPath("{seasonDbId}").GET().build() - .setBase("studies").GET().addPath("{studyDbId}").GET().withSearch() - .setBase("studytypes").GET().build() - .setBase("trials").GET().addPath("{trialDbId}").GET().withSearch() + .setBase("lists").GET().addPath("{listDbId}").DELETE().build() + .setBase("programs").GET().POST().addPath("{programDbId}").GET().PUT().build() + .setBase("studies").GET().POST().addPath("{studyDbId}").GET().PUT().build() + .setBase("trials").GET().POST().addPath("{trialDbId}").GET().PUT().build() //GERMPLASM - .setBase("attributes").GET().addPath("{attributeDbId}").GET().setPath("categories").GET().withSearch() - .setBase("attributevalues").GET().addPath("{attributeValueDbId}").GET().withSearch() - .setBase("breedingmethods").GET().addPath("{breedingMethodDbId}").GET().build() - .setBase("crosses").GET().build() - .setBase("plannedcrosses").GET().build() - .setBase("crossingprojects").GET().addPath("{crossingProjectDbId}").GET().build() - .setBase("seedlots").GET().addPath("transactions").GET().setPath("{seedLotDbId}").GET().addPath("transactions").GET().build() - .setBase("germplasm").GET().addPath("{germplasmDbId}").GET().addPath("mcpd").GET().withSearch() + .setBase("germplasm").GET().addPath("{germplasmDbId}").GET().build() + .setBase("search/germplasm").POST().addPath("{searchResultId}").GET().build() //PHENOTYPING - .setBase("events").GET().build() - .setBase("images").GET().addPath("{imageDbId}").GET().addPath("imagecontent").withSearch() - .setBase("ontologies").GET().build() - .setBase("traits").GET().addPath("{traitDbId}").GET().build() - .setBase("methods").GET().addPath("{methodDbId}").GET().build() - .setBase("scales").GET().addPath("{scaleDbId}").GET().build() - .setBase("variables").GET().addPath("{observationVariableDbId}").GET().withSearch() - .setBase("observationunits").GET().addPath("{observationUnitDbId}").GET().setPath("table").GET().withSearch() - .setBase("observations").GET().addPath("{observationDbId}").GET().setPath("table").GET().withSearch() - .setBase("observations").addPath("tables").GET() + .setBase("images").GET().POST().addPath("{imageDbId}").GET().PUT().addPath("imagecontent").PUT().build() .setBase("observationlevels").GET().build() - //GENOTYPING - TODO -// .setBase("calls").GET().withSearch() -// .setBase("callsets").GET().addPath("{callSetDbId}").GET().addPath("calls").GET().withSearch() -// .setBase("maps").GET().addPath("{mapDbId}").GET().addPath("linkagegroups").GET().build() -// .setBase("markerpositions").GET().withSearch() -// .setBase("references").GET().addPath("{referenceDbId}").GET().addPath("bases").GET().withSearch() -// .setBase("referencesets").GET().addPath("{referenceSetDbId}").GET().withSearch() -// .setBase("samples").GET().addPath("{sampleDbId}").GET().withSearch() -// .setBase("variants").GET().addPath("{variantDbId}").GET().addPath("calls").GET().withSearch() -// .setBase("variantsets").GET().addPath("extract").setPath("{variantSetDbId}").GET() -// .addPath("calls").GET().setPath("callsets").GET().setPath("variants").GET().withSearch() -// .setBase("vendor").addPath("specifications").GET().setPath("plates").addPath("{submissionId}").build() -// .setBase("vendor/orders").GET().addPath("{orderId}").addPath("plates").GET().setPath("results").GET().setPath("status").GET().build() - + .setBase("observationunits").GET().POST().PUT().addPath("{observationUnitDbId}").GET().PUT().build() + .setBase("observationunits/table").GET().build() + .setBase("variables").GET().POST().addPath("{observationVariableDbId}").GET().PUT().build() + .setBase("observations").GET().POST().PUT().addPath("{observationDbId}").GET().PUT().build() + .setBase("observations/table").GET().build() //V2.0 only .versions("2.0") .setBase("germplasm").addPath("{germplasmDbId}").addPath("pedigree").GET().setPath("progeny").GET().build() - .setBase("lists").addPath("{listDbId}").addPath("items").build() -// .setBase("samples").addPath("{sampleDbId}").build() //TODO //V2.1 only .versions("2.1") -// .setBase("allelematrix").GET().withSearch() //TODO -// .setBase("calls").build() //TODO - .setBase("delete").addPath("images").setPath("observations").build() - .setBase("lists").addPath("{listDbId}").addPath("data").POST().build() - .setBase("ontologies").addPath("{ontologyDbId}").GET().build() - .setBase("pedigree").GET().withSearch() -// .setBase("plates").GET().addPath("{plateDbId}").GET().withSearch() //TODO -// .setBase("samples").build() //TODO + .setBase("pedigree").GET().POST().PUT() .build(); - for(BrAPIService service : programServices) { - service.setService(service.getService()); - } - - BrAPIServerInfo programServerInfo = new BrAPIServerInfo(); - setBrAPIServerInfo(programServerInfo); - programServerInfo.setCalls(programServices); + BrAPIServerInfo programServerInfo = new BrAPIServerInfo(); + setBrAPIServerInfo(programServerInfo); + programServerInfo.setCalls(programServices); - return new BrAPIServerInfoResponse().result(programServerInfo); + return new BrAPIServerInfoResponse().result(programServerInfo); } private void setBrAPIServerInfo(BrAPIServerInfo serverInfo) { @@ -181,7 +139,7 @@ public HttpResponse getSeasons(@PathVariable("programId") UUID programId, Htt @Get("/${micronaut.bi.api.version}/programs/{programId}" + BrapiVersion.BRAPI_V2 + "/{+path}") @Produces(MediaType.APPLICATION_JSON) - @ProgramSecured(roleGroups = {ProgramSecuredRoleGroup.PROGRAM_SCOPED_ROLES}) + @ProgramSecured(roles = {ProgramSecuredRole.SYSTEM_ADMIN}) public HttpResponse getCatchall(@PathVariable("path") String path, @PathVariable("programId") UUID programId, HttpRequest request) { return executeRequest(path, programId, request, "GET"); } @@ -189,7 +147,7 @@ public HttpResponse getCatchall(@PathVariable("path") String path, @PathVaria @Post("/${micronaut.bi.api.version}/programs/{programId}" + BrapiVersion.BRAPI_V2 + "/{+path}") @Consumes(MediaType.ALL) @Produces(MediaType.APPLICATION_JSON) - @ProgramSecured(roleGroups = {ProgramSecuredRoleGroup.PROGRAM_SCOPED_ROLES}) + @ProgramSecured(roles = {ProgramSecuredRole.SYSTEM_ADMIN}) public HttpResponse postCatchall(@PathVariable("path") String path, @PathVariable("programId") UUID programId, HttpRequest request, @Header("Content-Type") String contentType) { return executeByteRequest(path, programId, request, contentType, "POST"); @@ -198,7 +156,7 @@ public HttpResponse postCatchall(@PathVariable("path") String path, @Pat @Put("/${micronaut.bi.api.version}/programs/{programId}" + BrapiVersion.BRAPI_V2 + "/{+path}") @Consumes(MediaType.ALL) @Produces(MediaType.APPLICATION_JSON) - @ProgramSecured(roleGroups = {ProgramSecuredRoleGroup.PROGRAM_SCOPED_ROLES}) + @ProgramSecured(roles = {ProgramSecuredRole.SYSTEM_ADMIN}) public HttpResponse putCatchall(@PathVariable("path") String path, @PathVariable("programId") UUID programId, HttpRequest request, @Header("Content-Type") String contentType) { return executeByteRequest(path, programId, request, contentType, "PUT"); diff --git a/src/main/java/org/breedinginsight/brapi/v2/ServiceBuilder.java b/src/main/java/org/breedinginsight/brapi/v2/ServiceBuilder.java index c5a0464a6..a373f4bdc 100644 --- a/src/main/java/org/breedinginsight/brapi/v2/ServiceBuilder.java +++ b/src/main/java/org/breedinginsight/brapi/v2/ServiceBuilder.java @@ -47,6 +47,11 @@ public ServiceBuilder POST() { return this; } + public ServiceBuilder DELETE() { + methods.add(MethodsEnum.DELETE); + return this; + } + public ServiceBuilder GET() { methods.add(MethodsEnum.GET); return this; diff --git a/src/test/java/org/breedinginsight/brapi/v2/BrAPIV2ControllerIntegrationTest.java b/src/test/java/org/breedinginsight/brapi/v2/BrAPIV2ControllerIntegrationTest.java index 24868f3fe..6988de78d 100644 --- a/src/test/java/org/breedinginsight/brapi/v2/BrAPIV2ControllerIntegrationTest.java +++ b/src/test/java/org/breedinginsight/brapi/v2/BrAPIV2ControllerIntegrationTest.java @@ -20,6 +20,8 @@ import com.google.gson.*; import io.kowalski.fannypack.FannyPack; import io.micronaut.context.annotation.Property; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; import io.micronaut.http.HttpStatus; import io.micronaut.http.MediaType; @@ -32,6 +34,8 @@ import org.brapi.client.v2.typeAdapters.PaginationTypeAdapter; import org.brapi.v2.model.BrAPIExternalReference; import org.brapi.v2.model.BrAPIPagination; +import org.brapi.v2.model.core.BrAPIService; +import org.brapi.v2.model.core.BrAPIService.MethodsEnum; import org.brapi.v2.model.core.BrAPIServerInfo; import org.brapi.v2.model.pheno.*; import org.breedinginsight.BrAPITest; @@ -42,12 +46,13 @@ import org.breedinginsight.model.User; import org.jooq.DSLContext; import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import javax.inject.Inject; import java.time.OffsetDateTime; -import java.util.Arrays; -import java.util.Collections; -import java.util.UUID; +import java.util.*; +import java.util.stream.Collectors; import static io.micronaut.http.HttpRequest.GET; import static io.micronaut.http.HttpRequest.POST; @@ -142,9 +147,76 @@ public void testRootServerInfo() { assertEquals("DeltaBreed", serverInfo.getServerName()); assertEquals("bidevteam@cornell.edu", serverInfo.getContactEmail()); assertEquals("https://breedinginsight.org", serverInfo.getOrganizationURL()); - assertEquals("BrAPI endpoints are not implemented at the root of this domain. Please make BrAPI calls in the context of a program (ex: https://app.breedinginsight.net/v1/programs/{programId}/brapi/v2)", serverInfo.getServerDescription()); + assertEquals("DeltaBreed provides server information and program discovery at this root. Program-scoped BrAPI calls use https://app.breedinginsight.net/v1/programs/{programId}/brapi/v2", serverInfo.getServerDescription()); assertEquals("Cornell University, Ithaca, NY, USA", serverInfo.getLocation()); assertEquals("https://brapi.org/specification", serverInfo.getDocumentationURL()); + + assertEquals(Map.ofEntries( + Map.entry("serverinfo", Set.of(MethodsEnum.GET)), + Map.entry("programs", Set.of(MethodsEnum.GET, MethodsEnum.POST)), + Map.entry("programs/{programDbId}", Set.of(MethodsEnum.GET, MethodsEnum.PUT)) + ), getMethodsByService(serverInfo)); + assertAllCallsHaveVersions(serverInfo, Set.of("2.0", "2.1")); + } + + @Test + public void testProgramServerInfoOnlyListsExplicitOverrides() { + String path = String.format("%s/programs/%s/brapi/v2/serverinfo", biApiVersion, validProgram.getId()); + Flowable> call = biClient.exchange(GET(path), String.class); + + HttpResponse response = call.blockingFirst(); + assertEquals(HttpStatus.OK, response.getStatus()); + assertNotNull(response.body(), "Response body is empty"); + + JsonObject result = JsonParser.parseString(response.body()) + .getAsJsonObject() + .getAsJsonObject("result"); + BrAPIServerInfo serverInfo = GSON.fromJson(result, BrAPIServerInfo.class); + + assertEquals(Map.ofEntries( + Map.entry("serverinfo", Set.of(MethodsEnum.GET)), + Map.entry("commoncropnames", Set.of(MethodsEnum.GET)), + Map.entry("lists", Set.of(MethodsEnum.GET)), + Map.entry("lists/{listDbId}", Set.of(MethodsEnum.DELETE)), + Map.entry("programs", Set.of(MethodsEnum.GET, MethodsEnum.POST)), + Map.entry("programs/{programDbId}", Set.of(MethodsEnum.GET, MethodsEnum.PUT)), + Map.entry("studies", Set.of(MethodsEnum.GET, MethodsEnum.POST)), + Map.entry("studies/{studyDbId}", Set.of(MethodsEnum.GET, MethodsEnum.PUT)), + Map.entry("trials", Set.of(MethodsEnum.GET, MethodsEnum.POST)), + Map.entry("trials/{trialDbId}", Set.of(MethodsEnum.GET, MethodsEnum.PUT)), + Map.entry("germplasm", Set.of(MethodsEnum.GET)), + Map.entry("germplasm/{germplasmDbId}", Set.of(MethodsEnum.GET)), + Map.entry("search/germplasm", Set.of(MethodsEnum.POST)), + Map.entry("search/germplasm/{searchResultId}", Set.of(MethodsEnum.GET)), + Map.entry("images", Set.of(MethodsEnum.GET, MethodsEnum.POST)), + Map.entry("images/{imageDbId}", Set.of(MethodsEnum.GET, MethodsEnum.PUT)), + Map.entry("images/{imageDbId}/imagecontent", Set.of(MethodsEnum.PUT)), + Map.entry("observationlevels", Set.of(MethodsEnum.GET)), + Map.entry("observationunits", Set.of(MethodsEnum.GET, MethodsEnum.POST, MethodsEnum.PUT)), + Map.entry("observationunits/{observationUnitDbId}", Set.of(MethodsEnum.GET, MethodsEnum.PUT)), + Map.entry("observationunits/table", Set.of(MethodsEnum.GET)), + Map.entry("variables", Set.of(MethodsEnum.GET, MethodsEnum.POST)), + Map.entry("variables/{observationVariableDbId}", Set.of(MethodsEnum.GET, MethodsEnum.PUT)), + Map.entry("observations", Set.of(MethodsEnum.GET, MethodsEnum.POST, MethodsEnum.PUT)), + Map.entry("observations/{observationDbId}", Set.of(MethodsEnum.GET, MethodsEnum.PUT)), + Map.entry("observations/table", Set.of(MethodsEnum.GET)), + Map.entry("germplasm/{germplasmDbId}/pedigree", Set.of(MethodsEnum.GET)), + Map.entry("germplasm/{germplasmDbId}/progeny", Set.of(MethodsEnum.GET)), + Map.entry("pedigree", Set.of(MethodsEnum.GET, MethodsEnum.POST, MethodsEnum.PUT)) + ), getMethodsByService(serverInfo)); + + serverInfo.getCalls().forEach(service -> { + Set expectedVersions; + if (service.getService().equals("germplasm/{germplasmDbId}/pedigree") || + service.getService().equals("germplasm/{germplasmDbId}/progeny")) { + expectedVersions = Set.of("2.0"); + } else if (service.getService().equals("pedigree")) { + expectedVersions = Set.of("2.1"); + } else { + expectedVersions = Set.of("2.0", "2.1"); + } + assertEquals(expectedVersions, new HashSet<>(service.getVersions()), service.getService()); + }); } @Test @@ -185,6 +257,21 @@ public void testPutVariablesNotFound() { assertEquals(HttpStatus.NOT_FOUND, e.getStatus()); } + @ParameterizedTest + @EnumSource(value = HttpMethod.class, names = {"GET", "POST", "PUT"}) + public void testCatchallRequiresSystemAdmin(HttpMethod method) { + String path = String.format("%s/programs/%s/brapi/v2/unsupported", biApiVersion, validProgram.getId()); + HttpRequest request = HttpRequest.create(method, path) + .contentType(MediaType.APPLICATION_JSON) + .bearerAuth("other-registered-user"); + + HttpClientResponseException exception = Assertions.assertThrows(HttpClientResponseException.class, + () -> biClient.exchange(request, String.class) + .blockingFirst()); + + assertEquals(HttpStatus.FORBIDDEN, exception.getStatus()); + } + private BrAPIObservationVariable generateVariable() { @@ -202,4 +289,15 @@ private BrAPIObservationVariable generateVariable() { .scale(new BrAPIScale().scaleName("test scale" + random) .dataType(BrAPITraitDataType.NUMERICAL)); } + + private Map> getMethodsByService(BrAPIServerInfo serverInfo) { + return serverInfo.getCalls().stream() + .collect(Collectors.toMap(BrAPIService::getService, + service -> new HashSet<>(service.getMethods()))); + } + + private void assertAllCallsHaveVersions(BrAPIServerInfo serverInfo, Set versions) { + serverInfo.getCalls().forEach(service -> + assertEquals(versions, new HashSet<>(service.getVersions()), service.getService())); + } }