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 2007c7f
Show file tree
Hide file tree
Showing 9 changed files with 271 additions and 31 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,25 +7,27 @@ 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 SequenceFiltersRequestWithFieldsDeserializer : JsonDeserializer<SequenceFiltersRequestWithFields>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): SequenceFiltersRequestWithFields {
val node = p.readValueAsTree<JsonNode>()

val fields = when (node.get("fields")) {
null -> emptyList()
is ArrayNode -> node.get("fields").asSequence().map { it.asText() }.toList()
else -> throw IllegalArgumentException("Fields in AggregationRequest must be an array or null")
else -> throw IllegalArgumentException(
"Fields in SequenceFiltersRequestWithFields must be an array or null",
)
}

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
Expand Up @@ -12,40 +12,43 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest

@SpringBootTest
class AggregationRequestDeserializerTest {
class SequenceFiltersRequestWithFieldsDeserializerTest {
@Autowired
private lateinit var objectMapper: ObjectMapper

@ParameterizedTest(name = "Test AggregationRequestDeserializer {1}")
@MethodSource("getTestAggregationRequest")
fun `AggregationRequest is correctly deserialized from JSON`(underTest: String, expected: AggregationRequest) {
val result = objectMapper.readValue(underTest, AggregationRequest::class.java)
@ParameterizedTest(name = "Test SequenceFiltersRequestWithFieldsDeserializer {1}")
@MethodSource("getTestSequenceFiltersRequestWithFields")
fun `AggregationRequest is correctly deserialized from JSON`(
underTest: String,
expected: SequenceFiltersRequestWithFields,
) {
val result = objectMapper.readValue(underTest, SequenceFiltersRequestWithFields::class.java)

MatcherAssert.assertThat(result, Matchers.equalTo(expected))
}

companion object {
@JvmStatic
fun getTestAggregationRequest() = listOf(
fun getTestSequenceFiltersRequestWithFields() = listOf(
Arguments.of(
"""
{
"country": "Switzerland",
"fields": ["division", "country"]
"country": "Switzerland",
"fields": ["division", "country"]
}
""",
AggregationRequest(
SequenceFiltersRequestWithFields(
mapOf("country" to "Switzerland"),
listOf("division", "country"),
),
),
Arguments.of(
"""
{
"country": "Switzerland"
"country": "Switzerland"
}
""",
AggregationRequest(
SequenceFiltersRequestWithFields(
mapOf("country" to "Switzerland"),
emptyList(),
),
Expand All @@ -55,16 +58,16 @@ class AggregationRequestDeserializerTest {
}

@Test
fun `Given an AggregationRequest with fields not null or ArrayList it should return an error`() {
fun `Given a SequenceFiltersRequestWithFields with fields not null or ArrayList it should return an error`() {
val underTest = """
{
{
"country": "Switzerland",
"fields": "notAnArrayNode"
}
"""
"fields": "notAnArrayNode"
}
"""

assertThrows(IllegalArgumentException::class.java) {
objectMapper.readValue(underTest, AggregationRequest::class.java)
objectMapper.readValue(underTest, SequenceFiltersRequestWithFields::class.java)
}
}
}
Loading

0 comments on commit 2007c7f

Please sign in to comment.