From 148ea4bdad673261247afd9b91440e654f2f9fff Mon Sep 17 00:00:00 2001 From: Fabian Engelniederhammer Date: Tue, 4 Jul 2023 17:50:33 +0200 Subject: [PATCH] feature: implement /details endpoint issue: #283 --- .../org/genspectrum/lapis/OpenApiDocs.kt | 4 +- .../lapis/controller/LapisController.kt | 87 +++++++++++++++++-- .../genspectrum/lapis/model/SiloQueryModel.kt | 10 +++ .../genspectrum/lapis/request/LapisRequest.kt | 8 +- .../org/genspectrum/lapis/silo/SiloQuery.kt | 13 +++ .../lapis/controller/LapisControllerTest.kt | 68 +++++++++++++++ .../genspectrum/lapis/silo/SiloClientTest.kt | 54 ++++++++++++ .../genspectrum/lapis/silo/SiloQueryTest.kt | 17 ++++ 8 files changed, 248 insertions(+), 13 deletions(-) diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/OpenApiDocs.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/OpenApiDocs.kt index 6b3010a3a..108b54ba4 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/OpenApiDocs.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/OpenApiDocs.kt @@ -8,7 +8,7 @@ import org.genspectrum.lapis.config.OpennessLevel import org.genspectrum.lapis.config.SequenceFilterFields import org.genspectrum.lapis.controller.MIN_PROPORTION_PROPERTY import org.genspectrum.lapis.controller.REQUEST_SCHEMA -import org.genspectrum.lapis.controller.REQUEST_SCHEMA_WITH_GROUP_BY_FIELDS +import org.genspectrum.lapis.controller.REQUEST_SCHEMA_WITH_FIELDS import org.genspectrum.lapis.controller.REQUEST_SCHEMA_WITH_MIN_PROPORTION import org.genspectrum.lapis.controller.RESPONSE_SCHEMA_AGGREGATED @@ -36,7 +36,7 @@ fun buildOpenApiSchema(sequenceFilterFields: SequenceFilterFields, databaseConfi .description("valid filters for sequence data") .properties(properties + Pair(MIN_PROPORTION_PROPERTY, Schema().type("number"))), ).addSchemas( - REQUEST_SCHEMA_WITH_GROUP_BY_FIELDS, + REQUEST_SCHEMA_WITH_FIELDS, Schema() .type("object") .description("valid filters for sequence data") diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt index bea9c0c3f..5cf8fe690 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt @@ -11,9 +11,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse import org.genspectrum.lapis.auth.ACCESS_KEY_PROPERTY import org.genspectrum.lapis.logging.RequestContext import org.genspectrum.lapis.model.SiloQueryModel -import org.genspectrum.lapis.request.AggregationRequest +import org.genspectrum.lapis.request.SequenceFiltersRequestWithFields import org.genspectrum.lapis.response.AggregationData import org.genspectrum.lapis.response.MutationData +import org.genspectrum.lapis.silo.DetailsData import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -21,10 +22,10 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController const val MIN_PROPORTION_PROPERTY = "minProportion" -const val GROUP_BY_FIELDS_PROPERTY = "fields" +const val FIELDS_PROPERTY = "fields" const val REQUEST_SCHEMA = "SequenceFilters" const val REQUEST_SCHEMA_WITH_MIN_PROPORTION = "SequenceFiltersWithMinProportion" -const val REQUEST_SCHEMA_WITH_GROUP_BY_FIELDS = "SequenceFiltersWithGroupByFields" +const val REQUEST_SCHEMA_WITH_FIELDS = "SequenceFiltersWithFields" const val RESPONSE_SCHEMA_AGGREGATED = "AggregatedResponse" private const val DEFAULT_MIN_PROPORTION = 0.05 @@ -33,14 +34,14 @@ private const val DEFAULT_MIN_PROPORTION = 0.05 class LapisController(private val siloQueryModel: SiloQueryModel, private val requestContext: RequestContext) { companion object { private val nonSequenceFilterFields = - listOf(MIN_PROPORTION_PROPERTY, ACCESS_KEY_PROPERTY, GROUP_BY_FIELDS_PROPERTY) + listOf(MIN_PROPORTION_PROPERTY, ACCESS_KEY_PROPERTY, FIELDS_PROPERTY) } @GetMapping("/aggregated") @LapisAggregatedResponse fun aggregated( @Parameter( - schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_GROUP_BY_FIELDS"), + schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_FIELDS"), explode = Explode.TRUE, style = ParameterStyle.FORM, ) @@ -59,9 +60,9 @@ class LapisController(private val siloQueryModel: SiloQueryModel, private val re @PostMapping("/aggregated") @LapisAggregatedResponse fun postAggregated( - @Parameter(schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_GROUP_BY_FIELDS")) + @Parameter(schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_FIELDS")) @RequestBody() - request: AggregationRequest, + request: SequenceFiltersRequestWithFields, ): List { requestContext.filter = request.sequenceFilters @@ -118,6 +119,41 @@ class LapisController(private val siloQueryModel: SiloQueryModel, private val re sequenceFilters.associate { it.key to it.value }, ) } + + @GetMapping("/details") + @LapisDetailsResponse + fun details( + @Parameter( + schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_FIELDS"), + explode = Explode.TRUE, + style = ParameterStyle.FORM, + ) + @RequestParam + sequenceFilters: Map, + @RequestParam(defaultValue = "") fields: List, + ): List { + requestContext.filter = sequenceFilters + + return siloQueryModel.getDetails( + sequenceFilters.filterKeys { !nonSequenceFilterFields.contains(it) }, + fields, + ) + } + + @PostMapping("/details") + @LapisDetailsResponse + fun postDetails( + @Parameter(schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_FIELDS")) + @RequestBody() + request: SequenceFiltersRequestWithFields, + ): List { + requestContext.filter = request.sequenceFilters + + return siloQueryModel.getDetails( + request.sequenceFilters, + request.fields, + ) + } } @Target(AnnotationTarget.FUNCTION) @@ -186,3 +222,40 @@ private annotation class LapisAggregatedResponse ], ) private annotation class LapisNucleotideMutationsResponse + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@Operation( + description = "Returns the specified metadata fields of sequences matching the filter.", + responses = [ + ApiResponse( + responseCode = "200", + description = "OK", + content = [ + Content( + array = ArraySchema( + schema = Schema( + ref = "#/components/schemas/$RESPONSE_SCHEMA_AGGREGATED", + ), + ), + ), + ], + ), + ApiResponse( + responseCode = "400", + description = "Bad Request", + content = [Content(schema = Schema(implementation = LapisHttpErrorResponse::class))], + ), + ApiResponse( + responseCode = "403", + description = "Forbidden", + content = [Content(schema = Schema(implementation = LapisHttpErrorResponse::class))], + ), + ApiResponse( + responseCode = "500", + description = "Internal Server Error", + content = [Content(schema = Schema(implementation = LapisHttpErrorResponse::class))], + ), + ], +) +private annotation class LapisDetailsResponse diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt index 5eefa13c7..5be196319 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt @@ -28,4 +28,14 @@ class SiloQueryModel( siloFilterExpressionMapper.map(sequenceFilters), ), ) + + fun getDetails( + sequenceFilters: Map, + fields: List = emptyList(), + ) = siloClient.sendQuery( + SiloQuery( + SiloAction.details(fields), + siloFilterExpressionMapper.map(sequenceFilters), + ), + ) } diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/request/LapisRequest.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/request/LapisRequest.kt index 8302ddfe7..e7c808b26 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/request/LapisRequest.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/request/LapisRequest.kt @@ -7,14 +7,14 @@ import com.fasterxml.jackson.databind.JsonNode import io.swagger.v3.oas.annotations.media.Schema import org.springframework.boot.jackson.JsonComponent -data class AggregationRequest( +data class SequenceFiltersRequestWithFields( val sequenceFilters: Map, @Schema(hidden = true) val fields: List, ) @JsonComponent -class AggregationRequestDeserializer : JsonDeserializer() { - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): AggregationRequest { +class AggregationRequestDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): SequenceFiltersRequestWithFields { val node = p.readValueAsTree() val fields = if (node.get("fields") == null) { @@ -26,6 +26,6 @@ class AggregationRequestDeserializer : JsonDeserializer() { val sequenceFilters = node.fields().asSequence().filter { it.key != "fields" }.associate { it.key to it.value.asText() } - return AggregationRequest(sequenceFilters, fields) + return SequenceFiltersRequestWithFields(sequenceFilters, fields) } } diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt index 9d41ce0b8..c1c6a25e1 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt @@ -3,10 +3,13 @@ package org.genspectrum.lapis.silo import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.JsonNode import org.genspectrum.lapis.response.AggregationData import org.genspectrum.lapis.response.MutationData import java.time.LocalDate +typealias DetailsData = Map + data class SiloQuery(val action: SiloAction, val filterExpression: SiloFilterExpression) sealed class SiloAction(@JsonIgnore val typeReference: TypeReference>) { @@ -16,6 +19,9 @@ sealed class SiloAction(@JsonIgnore val typeReference: TypeReferen fun mutations(minProportion: Double? = null): SiloAction> = MutationsAction("Mutations", minProportion) + + fun details(fields: List = emptyList()): SiloAction> = + DetailsAction("Details", fields) } private data class AggregatedAction(val type: String, val groupByFields: List) : @@ -24,6 +30,13 @@ sealed class SiloAction(@JsonIgnore val typeReference: TypeReferen @JsonInclude(JsonInclude.Include.NON_NULL) private data class MutationsAction(val type: String, val minProportion: Double?) : SiloAction>(object : TypeReference>>() {}) + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private data class DetailsAction(val type: String, val fields: List = emptyList()) : + SiloAction>( + object : + TypeReference>>() {}, + ) } sealed class SiloFilterExpression(val type: String) diff --git a/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt b/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt index 824c599ea..582a3062d 100644 --- a/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt +++ b/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt @@ -188,5 +188,73 @@ class LapisControllerTest(@Autowired val mockMvc: MockMvc) { ) } + @Test + fun `GET details`() { + every { + siloQueryModelMock.getDetails( + mapOf("country" to "Switzerland"), + emptyList(), + ) + } returns listOf(mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42))) + + mockMvc.perform(get("/details?country=Switzerland")) + .andExpect(status().isOk) + .andExpect(jsonPath("\$[0].country").value("Switzerland")) + .andExpect(jsonPath("\$[0].age").value(42)) + } + + @Test + fun `GET details with fields`() { + every { + siloQueryModelMock.getDetails( + mapOf("country" to "Switzerland"), + listOf("country", "age"), + ) + } returns listOf(mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42))) + + mockMvc.perform(get("/details?country=Switzerland&fields=country&fields=age")) + .andExpect(status().isOk) + .andExpect(jsonPath("\$[0].country").value("Switzerland")) + .andExpect(jsonPath("\$[0].age").value(42)) + } + + @Test + fun `POST details`() { + every { + siloQueryModelMock.getDetails( + mapOf("country" to "Switzerland"), + emptyList(), + ) + } returns listOf(mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42))) + + val request = post("/details") + .content("""{"country": "Switzerland"}""") + .contentType(MediaType.APPLICATION_JSON) + + mockMvc.perform(request) + .andExpect(status().isOk) + .andExpect(jsonPath("\$[0].country").value("Switzerland")) + .andExpect(jsonPath("\$[0].age").value(42)) + } + + @Test + fun `POST details with fields`() { + every { + siloQueryModelMock.getDetails( + mapOf("country" to "Switzerland"), + listOf("country", "age"), + ) + } returns listOf(mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42))) + + val request = post("/details") + .content("""{"country": "Switzerland", "fields": ["country", "age"]}""") + .contentType(MediaType.APPLICATION_JSON) + + mockMvc.perform(request) + .andExpect(status().isOk) + .andExpect(jsonPath("\$[0].country").value("Switzerland")) + .andExpect(jsonPath("\$[0].age").value(42)) + } + private fun someMutationProportion() = MutationData("the mutation", 42, 0.5) } diff --git a/lapis2/src/test/kotlin/org/genspectrum/lapis/silo/SiloClientTest.kt b/lapis2/src/test/kotlin/org/genspectrum/lapis/silo/SiloClientTest.kt index c8772a009..b9a714769 100644 --- a/lapis2/src/test/kotlin/org/genspectrum/lapis/silo/SiloClientTest.kt +++ b/lapis2/src/test/kotlin/org/genspectrum/lapis/silo/SiloClientTest.kt @@ -1,5 +1,7 @@ package org.genspectrum.lapis.silo +import com.fasterxml.jackson.databind.node.DoubleNode +import com.fasterxml.jackson.databind.node.IntNode import com.fasterxml.jackson.databind.node.TextNode import org.genspectrum.lapis.response.AggregationData import org.genspectrum.lapis.response.MutationData @@ -108,6 +110,58 @@ class SiloClientTest { ) } + @Test + fun `given server returns details response then response can be deserialized`() { + expectQueryRequestAndRespondWith( + response() + .withContentType(MediaType.APPLICATION_JSON_UTF_8) + .withBody( + """{ + "queryResult": [ + { + "age": 50, + "country": "Switzerland", + "date": "2021-02-23", + "pango_lineage": "B.1.1.7", + "qc_value": 0.95 + }, + { + "age": 54, + "country": "Switzerland", + "date": "2021-03-19", + "pango_lineage": "B.1.1.7", + "qc_value": 0.94 + } + ] + }""", + ), + ) + + val query = SiloQuery(SiloAction.details(), StringEquals("theColumn", "theValue")) + val result = underTest.sendQuery(query) + + assertThat(result, hasSize(2)) + assertThat( + result, + containsInAnyOrder( + mapOf( + "age" to IntNode(50), + "country" to TextNode("Switzerland"), + "date" to TextNode("2021-02-23"), + "pango_lineage" to TextNode("B.1.1.7"), + "qc_value" to DoubleNode(0.95), + ), + mapOf( + "age" to IntNode(54), + "country" to TextNode("Switzerland"), + "date" to TextNode("2021-03-19"), + "pango_lineage" to TextNode("B.1.1.7"), + "qc_value" to DoubleNode(0.94), + ), + ), + ) + } + @Test fun `given server returns error then throws exception`() { expectQueryRequestAndRespondWith( diff --git a/lapis2/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryTest.kt b/lapis2/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryTest.kt index 935aeccea..805d2176d 100644 --- a/lapis2/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryTest.kt +++ b/lapis2/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryTest.kt @@ -81,6 +81,23 @@ class SiloQueryTest { } """, ), + Arguments.of( + SiloAction.details(), + """ + { + "type": "Details" + } + """, + ), + Arguments.of( + SiloAction.details(listOf("age", "pango_lineage")), + """ + { + "type": "Details", + "fields": ["age", "pango_lineage"] + } + """, + ), ) @JvmStatic