Skip to content

Commit

Permalink
feature: implement /details endpoint
Browse files Browse the repository at this point in the history
issue: #283
  • Loading branch information
fengelniederhammer committed Jul 6, 2023
1 parent ae5b876 commit 34b36f8
Show file tree
Hide file tree
Showing 8 changed files with 248 additions and 13 deletions.
4 changes: 2 additions & 2 deletions lapis2/src/main/kotlin/org/genspectrum/lapis/OpenApiDocs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -41,7 +41,7 @@ fun buildOpenApiSchema(sequenceFilterFields: SequenceFilterFields, databaseConfi
.description("valid filters for sequence data")
.properties(requestProperties + Pair(MIN_PROPORTION_PROPERTY, Schema<String>().type("number"))),
).addSchemas(
REQUEST_SCHEMA_WITH_GROUP_BY_FIELDS,
REQUEST_SCHEMA_WITH_FIELDS,
Schema<String>()
.type("object")
.description("valid filters for sequence data")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,21 @@ 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
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
Expand All @@ -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,
)
Expand All @@ -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<AggregationData> {
requestContext.filter = request.sequenceFilters

Expand Down Expand Up @@ -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<String, String>,
@RequestParam(defaultValue = "") fields: List<String>,
): List<DetailsData> {
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<DetailsData> {
requestContext.filter = request.sequenceFilters

return siloQueryModel.getDetails(
request.sequenceFilters,
request.fields,
)
}
}

@Target(AnnotationTarget.FUNCTION)
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,14 @@ class SiloQueryModel(
siloFilterExpressionMapper.map(sequenceFilters),
),
)

fun getDetails(
sequenceFilters: Map<SequenceFilterFieldName, String>,
fields: List<SequenceFilterFieldName> = emptyList(),
) = siloClient.sendQuery(
SiloQuery(
SiloAction.details(fields),
siloFilterExpressionMapper.map(sequenceFilters),
),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ArrayNode
import org.springframework.boot.jackson.JsonComponent

data class AggregationRequest(
data class SequenceFiltersRequestWithFields(
val sequenceFilters: Map<String, String>,
val fields: List<String>,
)

@JsonComponent
class AggregationRequestDeserializer : JsonDeserializer<AggregationRequest>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): AggregationRequest {
class AggregationRequestDeserializer : JsonDeserializer<SequenceFiltersRequestWithFields>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): SequenceFiltersRequestWithFields {
val node = p.readValueAsTree<JsonNode>()

val fields = when (node.get("fields")) {
Expand All @@ -26,6 +26,6 @@ class AggregationRequestDeserializer : JsonDeserializer<AggregationRequest>() {
val sequenceFilters =
node.fields().asSequence().filter { it.key != "fields" }.associate { it.key to it.value.asText() }

return AggregationRequest(sequenceFilters, fields)
return SequenceFiltersRequestWithFields(sequenceFilters, fields)
}
}
13 changes: 13 additions & 0 deletions lapis2/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, JsonNode>

data class SiloQuery<ResponseType>(val action: SiloAction<ResponseType>, val filterExpression: SiloFilterExpression)

sealed class SiloAction<ResponseType>(@JsonIgnore val typeReference: TypeReference<SiloQueryResponse<ResponseType>>) {
Expand All @@ -16,6 +19,9 @@ sealed class SiloAction<ResponseType>(@JsonIgnore val typeReference: TypeReferen

fun mutations(minProportion: Double? = null): SiloAction<List<MutationData>> =
MutationsAction("Mutations", minProportion)

fun details(fields: List<String> = emptyList()): SiloAction<List<DetailsData>> =
DetailsAction("Details", fields)
}

@JsonInclude(JsonInclude.Include.NON_EMPTY)
Expand All @@ -25,6 +31,13 @@ sealed class SiloAction<ResponseType>(@JsonIgnore val typeReference: TypeReferen
@JsonInclude(JsonInclude.Include.NON_NULL)
private data class MutationsAction(val type: String, val minProportion: Double?) :
SiloAction<List<MutationData>>(object : TypeReference<SiloQueryResponse<List<MutationData>>>() {})

@JsonInclude(JsonInclude.Include.NON_EMPTY)
private data class DetailsAction(val type: String, val fields: List<String> = emptyList()) :
SiloAction<List<DetailsData>>(
object :
TypeReference<SiloQueryResponse<List<DetailsData>>>() {},
)
}

sealed class SiloFilterExpression(val type: String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions lapis2/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ class SiloQueryTest {
}
""",
),
Arguments.of(
SiloAction.details(),
"""
{
"type": "Details"
}
""",
),
Arguments.of(
SiloAction.details(listOf("age", "pango_lineage")),
"""
{
"type": "Details",
"fields": ["age", "pango_lineage"]
}
""",
),
)

@JvmStatic
Expand Down

0 comments on commit 34b36f8

Please sign in to comment.