Skip to content

Commit aed93e9

Browse files
feature: implement /details endpoint
issue: #283
1 parent ae5b876 commit aed93e9

File tree

9 files changed

+275
-34
lines changed

9 files changed

+275
-34
lines changed

lapis2/src/main/kotlin/org/genspectrum/lapis/OpenApiDocs.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import org.genspectrum.lapis.config.OpennessLevel
88
import org.genspectrum.lapis.config.SequenceFilterFields
99
import org.genspectrum.lapis.controller.MIN_PROPORTION_PROPERTY
1010
import org.genspectrum.lapis.controller.REQUEST_SCHEMA
11-
import org.genspectrum.lapis.controller.REQUEST_SCHEMA_WITH_GROUP_BY_FIELDS
11+
import org.genspectrum.lapis.controller.REQUEST_SCHEMA_WITH_FIELDS
1212
import org.genspectrum.lapis.controller.REQUEST_SCHEMA_WITH_MIN_PROPORTION
1313
import org.genspectrum.lapis.controller.RESPONSE_SCHEMA_AGGREGATED
1414

@@ -41,7 +41,7 @@ fun buildOpenApiSchema(sequenceFilterFields: SequenceFilterFields, databaseConfi
4141
.description("valid filters for sequence data")
4242
.properties(requestProperties + Pair(MIN_PROPORTION_PROPERTY, Schema<String>().type("number"))),
4343
).addSchemas(
44-
REQUEST_SCHEMA_WITH_GROUP_BY_FIELDS,
44+
REQUEST_SCHEMA_WITH_FIELDS,
4545
Schema<String>()
4646
.type("object")
4747
.description("valid filters for sequence data")

lapis2/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,21 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse
1111
import org.genspectrum.lapis.auth.ACCESS_KEY_PROPERTY
1212
import org.genspectrum.lapis.logging.RequestContext
1313
import org.genspectrum.lapis.model.SiloQueryModel
14-
import org.genspectrum.lapis.request.AggregationRequest
14+
import org.genspectrum.lapis.request.SequenceFiltersRequestWithFields
1515
import org.genspectrum.lapis.response.AggregationData
1616
import org.genspectrum.lapis.response.MutationData
17+
import org.genspectrum.lapis.silo.DetailsData
1718
import org.springframework.web.bind.annotation.GetMapping
1819
import org.springframework.web.bind.annotation.PostMapping
1920
import org.springframework.web.bind.annotation.RequestBody
2021
import org.springframework.web.bind.annotation.RequestParam
2122
import org.springframework.web.bind.annotation.RestController
2223

2324
const val MIN_PROPORTION_PROPERTY = "minProportion"
24-
const val GROUP_BY_FIELDS_PROPERTY = "fields"
25+
const val FIELDS_PROPERTY = "fields"
2526
const val REQUEST_SCHEMA = "SequenceFilters"
2627
const val REQUEST_SCHEMA_WITH_MIN_PROPORTION = "SequenceFiltersWithMinProportion"
27-
const val REQUEST_SCHEMA_WITH_GROUP_BY_FIELDS = "SequenceFiltersWithGroupByFields"
28+
const val REQUEST_SCHEMA_WITH_FIELDS = "SequenceFiltersWithFields"
2829
const val RESPONSE_SCHEMA_AGGREGATED = "AggregatedResponse"
2930

3031
private const val DEFAULT_MIN_PROPORTION = 0.05
@@ -33,14 +34,14 @@ private const val DEFAULT_MIN_PROPORTION = 0.05
3334
class LapisController(private val siloQueryModel: SiloQueryModel, private val requestContext: RequestContext) {
3435
companion object {
3536
private val nonSequenceFilterFields =
36-
listOf(MIN_PROPORTION_PROPERTY, ACCESS_KEY_PROPERTY, GROUP_BY_FIELDS_PROPERTY)
37+
listOf(MIN_PROPORTION_PROPERTY, ACCESS_KEY_PROPERTY, FIELDS_PROPERTY)
3738
}
3839

3940
@GetMapping("/aggregated")
4041
@LapisAggregatedResponse
4142
fun aggregated(
4243
@Parameter(
43-
schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_GROUP_BY_FIELDS"),
44+
schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_FIELDS"),
4445
explode = Explode.TRUE,
4546
style = ParameterStyle.FORM,
4647
)
@@ -59,9 +60,9 @@ class LapisController(private val siloQueryModel: SiloQueryModel, private val re
5960
@PostMapping("/aggregated")
6061
@LapisAggregatedResponse
6162
fun postAggregated(
62-
@Parameter(schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_GROUP_BY_FIELDS"))
63+
@Parameter(schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_FIELDS"))
6364
@RequestBody()
64-
request: AggregationRequest,
65+
request: SequenceFiltersRequestWithFields,
6566
): List<AggregationData> {
6667
requestContext.filter = request.sequenceFilters
6768

@@ -118,6 +119,41 @@ class LapisController(private val siloQueryModel: SiloQueryModel, private val re
118119
sequenceFilters.associate { it.key to it.value },
119120
)
120121
}
122+
123+
@GetMapping("/details")
124+
@LapisDetailsResponse
125+
fun details(
126+
@Parameter(
127+
schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_FIELDS"),
128+
explode = Explode.TRUE,
129+
style = ParameterStyle.FORM,
130+
)
131+
@RequestParam
132+
sequenceFilters: Map<String, String>,
133+
@RequestParam(defaultValue = "") fields: List<String>,
134+
): List<DetailsData> {
135+
requestContext.filter = sequenceFilters
136+
137+
return siloQueryModel.getDetails(
138+
sequenceFilters.filterKeys { !nonSequenceFilterFields.contains(it) },
139+
fields,
140+
)
141+
}
142+
143+
@PostMapping("/details")
144+
@LapisDetailsResponse
145+
fun postDetails(
146+
@Parameter(schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_FIELDS"))
147+
@RequestBody()
148+
request: SequenceFiltersRequestWithFields,
149+
): List<DetailsData> {
150+
requestContext.filter = request.sequenceFilters
151+
152+
return siloQueryModel.getDetails(
153+
request.sequenceFilters,
154+
request.fields,
155+
)
156+
}
121157
}
122158

123159
@Target(AnnotationTarget.FUNCTION)
@@ -186,3 +222,40 @@ private annotation class LapisAggregatedResponse
186222
],
187223
)
188224
private annotation class LapisNucleotideMutationsResponse
225+
226+
@Target(AnnotationTarget.FUNCTION)
227+
@Retention(AnnotationRetention.RUNTIME)
228+
@Operation(
229+
description = "Returns the specified metadata fields of sequences matching the filter.",
230+
responses = [
231+
ApiResponse(
232+
responseCode = "200",
233+
description = "OK",
234+
content = [
235+
Content(
236+
array = ArraySchema(
237+
schema = Schema(
238+
ref = "#/components/schemas/$RESPONSE_SCHEMA_AGGREGATED",
239+
),
240+
),
241+
),
242+
],
243+
),
244+
ApiResponse(
245+
responseCode = "400",
246+
description = "Bad Request",
247+
content = [Content(schema = Schema(implementation = LapisHttpErrorResponse::class))],
248+
),
249+
ApiResponse(
250+
responseCode = "403",
251+
description = "Forbidden",
252+
content = [Content(schema = Schema(implementation = LapisHttpErrorResponse::class))],
253+
),
254+
ApiResponse(
255+
responseCode = "500",
256+
description = "Internal Server Error",
257+
content = [Content(schema = Schema(implementation = LapisHttpErrorResponse::class))],
258+
),
259+
],
260+
)
261+
private annotation class LapisDetailsResponse

lapis2/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,14 @@ class SiloQueryModel(
2828
siloFilterExpressionMapper.map(sequenceFilters),
2929
),
3030
)
31+
32+
fun getDetails(
33+
sequenceFilters: Map<SequenceFilterFieldName, String>,
34+
fields: List<SequenceFilterFieldName> = emptyList(),
35+
) = siloClient.sendQuery(
36+
SiloQuery(
37+
SiloAction.details(fields),
38+
siloFilterExpressionMapper.map(sequenceFilters),
39+
),
40+
)
3141
}

lapis2/src/main/kotlin/org/genspectrum/lapis/request/LapisRequest.kt

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,30 @@ import com.fasterxml.jackson.databind.DeserializationContext
55
import com.fasterxml.jackson.databind.JsonDeserializer
66
import com.fasterxml.jackson.databind.JsonNode
77
import com.fasterxml.jackson.databind.node.ArrayNode
8+
import org.genspectrum.lapis.controller.FIELDS_PROPERTY
89
import org.springframework.boot.jackson.JsonComponent
910

10-
data class AggregationRequest(
11+
data class SequenceFiltersRequestWithFields(
1112
val sequenceFilters: Map<String, String>,
1213
val fields: List<String>,
1314
)
1415

1516
@JsonComponent
16-
class AggregationRequestDeserializer : JsonDeserializer<AggregationRequest>() {
17-
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): AggregationRequest {
17+
class SequenceFiltersRequestWithFieldsDeserializer : JsonDeserializer<SequenceFiltersRequestWithFields>() {
18+
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): SequenceFiltersRequestWithFields {
1819
val node = p.readValueAsTree<JsonNode>()
1920

20-
val fields = when (node.get("fields")) {
21+
val fields = when (node.get(FIELDS_PROPERTY)) {
2122
null -> emptyList()
22-
is ArrayNode -> node.get("fields").asSequence().map { it.asText() }.toList()
23-
else -> throw IllegalArgumentException("Fields in AggregationRequest must be an array or null")
23+
is ArrayNode -> node.get(FIELDS_PROPERTY).asSequence().map { it.asText() }.toList()
24+
else -> throw IllegalArgumentException(
25+
"Fields in SequenceFiltersRequestWithFields must be an array or null",
26+
)
2427
}
2528

2629
val sequenceFilters =
27-
node.fields().asSequence().filter { it.key != "fields" }.associate { it.key to it.value.asText() }
30+
node.fields().asSequence().filter { it.key != FIELDS_PROPERTY }.associate { it.key to it.value.asText() }
2831

29-
return AggregationRequest(sequenceFilters, fields)
32+
return SequenceFiltersRequestWithFields(sequenceFilters, fields)
3033
}
3134
}

lapis2/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ package org.genspectrum.lapis.silo
33
import com.fasterxml.jackson.annotation.JsonIgnore
44
import com.fasterxml.jackson.annotation.JsonInclude
55
import com.fasterxml.jackson.core.type.TypeReference
6+
import com.fasterxml.jackson.databind.JsonNode
67
import org.genspectrum.lapis.response.AggregationData
78
import org.genspectrum.lapis.response.MutationData
89
import java.time.LocalDate
910

11+
typealias DetailsData = Map<String, JsonNode>
12+
1013
data class SiloQuery<ResponseType>(val action: SiloAction<ResponseType>, val filterExpression: SiloFilterExpression)
1114

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

1720
fun mutations(minProportion: Double? = null): SiloAction<List<MutationData>> =
1821
MutationsAction("Mutations", minProportion)
22+
23+
fun details(fields: List<String> = emptyList()): SiloAction<List<DetailsData>> =
24+
DetailsAction("Details", fields)
1925
}
2026

2127
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@@ -25,6 +31,13 @@ sealed class SiloAction<ResponseType>(@JsonIgnore val typeReference: TypeReferen
2531
@JsonInclude(JsonInclude.Include.NON_NULL)
2632
private data class MutationsAction(val type: String, val minProportion: Double?) :
2733
SiloAction<List<MutationData>>(object : TypeReference<SiloQueryResponse<List<MutationData>>>() {})
34+
35+
@JsonInclude(JsonInclude.Include.NON_EMPTY)
36+
private data class DetailsAction(val type: String, val fields: List<String> = emptyList()) :
37+
SiloAction<List<DetailsData>>(
38+
object :
39+
TypeReference<SiloQueryResponse<List<DetailsData>>>() {},
40+
)
2841
}
2942

3043
sealed class SiloFilterExpression(val type: String)

lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,5 +188,73 @@ class LapisControllerTest(@Autowired val mockMvc: MockMvc) {
188188
)
189189
}
190190

191+
@Test
192+
fun `GET details`() {
193+
every {
194+
siloQueryModelMock.getDetails(
195+
mapOf("country" to "Switzerland"),
196+
emptyList(),
197+
)
198+
} returns listOf(mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42)))
199+
200+
mockMvc.perform(get("/details?country=Switzerland"))
201+
.andExpect(status().isOk)
202+
.andExpect(jsonPath("\$[0].country").value("Switzerland"))
203+
.andExpect(jsonPath("\$[0].age").value(42))
204+
}
205+
206+
@Test
207+
fun `GET details with fields`() {
208+
every {
209+
siloQueryModelMock.getDetails(
210+
mapOf("country" to "Switzerland"),
211+
listOf("country", "age"),
212+
)
213+
} returns listOf(mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42)))
214+
215+
mockMvc.perform(get("/details?country=Switzerland&fields=country&fields=age"))
216+
.andExpect(status().isOk)
217+
.andExpect(jsonPath("\$[0].country").value("Switzerland"))
218+
.andExpect(jsonPath("\$[0].age").value(42))
219+
}
220+
221+
@Test
222+
fun `POST details`() {
223+
every {
224+
siloQueryModelMock.getDetails(
225+
mapOf("country" to "Switzerland"),
226+
emptyList(),
227+
)
228+
} returns listOf(mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42)))
229+
230+
val request = post("/details")
231+
.content("""{"country": "Switzerland"}""")
232+
.contentType(MediaType.APPLICATION_JSON)
233+
234+
mockMvc.perform(request)
235+
.andExpect(status().isOk)
236+
.andExpect(jsonPath("\$[0].country").value("Switzerland"))
237+
.andExpect(jsonPath("\$[0].age").value(42))
238+
}
239+
240+
@Test
241+
fun `POST details with fields`() {
242+
every {
243+
siloQueryModelMock.getDetails(
244+
mapOf("country" to "Switzerland"),
245+
listOf("country", "age"),
246+
)
247+
} returns listOf(mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42)))
248+
249+
val request = post("/details")
250+
.content("""{"country": "Switzerland", "fields": ["country", "age"]}""")
251+
.contentType(MediaType.APPLICATION_JSON)
252+
253+
mockMvc.perform(request)
254+
.andExpect(status().isOk)
255+
.andExpect(jsonPath("\$[0].country").value("Switzerland"))
256+
.andExpect(jsonPath("\$[0].age").value(42))
257+
}
258+
191259
private fun someMutationProportion() = MutationData("the mutation", 42, 0.5)
192260
}

lapis2/src/test/kotlin/org/genspectrum/lapis/request/AggregationRequestDeserializerTest.kt renamed to lapis2/src/test/kotlin/org/genspectrum/lapis/request/SequenceFiltersRequestWithFieldsDeserializerTest.kt

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,40 +12,43 @@ import org.springframework.beans.factory.annotation.Autowired
1212
import org.springframework.boot.test.context.SpringBootTest
1313

1414
@SpringBootTest
15-
class AggregationRequestDeserializerTest {
15+
class SequenceFiltersRequestWithFieldsDeserializerTest {
1616
@Autowired
1717
private lateinit var objectMapper: ObjectMapper
1818

19-
@ParameterizedTest(name = "Test AggregationRequestDeserializer {1}")
20-
@MethodSource("getTestAggregationRequest")
21-
fun `AggregationRequest is correctly deserialized from JSON`(underTest: String, expected: AggregationRequest) {
22-
val result = objectMapper.readValue(underTest, AggregationRequest::class.java)
19+
@ParameterizedTest(name = "Test SequenceFiltersRequestWithFieldsDeserializer {1}")
20+
@MethodSource("getTestSequenceFiltersRequestWithFields")
21+
fun `AggregationRequest is correctly deserialized from JSON`(
22+
underTest: String,
23+
expected: SequenceFiltersRequestWithFields,
24+
) {
25+
val result = objectMapper.readValue(underTest, SequenceFiltersRequestWithFields::class.java)
2326

2427
MatcherAssert.assertThat(result, Matchers.equalTo(expected))
2528
}
2629

2730
companion object {
2831
@JvmStatic
29-
fun getTestAggregationRequest() = listOf(
32+
fun getTestSequenceFiltersRequestWithFields() = listOf(
3033
Arguments.of(
3134
"""
3235
{
33-
"country": "Switzerland",
34-
"fields": ["division", "country"]
36+
"country": "Switzerland",
37+
"fields": ["division", "country"]
3538
}
3639
""",
37-
AggregationRequest(
40+
SequenceFiltersRequestWithFields(
3841
mapOf("country" to "Switzerland"),
3942
listOf("division", "country"),
4043
),
4144
),
4245
Arguments.of(
4346
"""
4447
{
45-
"country": "Switzerland"
48+
"country": "Switzerland"
4649
}
4750
""",
48-
AggregationRequest(
51+
SequenceFiltersRequestWithFields(
4952
mapOf("country" to "Switzerland"),
5053
emptyList(),
5154
),
@@ -55,16 +58,16 @@ class AggregationRequestDeserializerTest {
5558
}
5659

5760
@Test
58-
fun `Given an AggregationRequest with fields not null or ArrayList it should return an error`() {
61+
fun `Given a SequenceFiltersRequestWithFields with fields not null or ArrayList it should return an error`() {
5962
val underTest = """
60-
{
63+
{
6164
"country": "Switzerland",
62-
"fields": "notAnArrayNode"
63-
}
64-
"""
65+
"fields": "notAnArrayNode"
66+
}
67+
"""
6568

6669
assertThrows(IllegalArgumentException::class.java) {
67-
objectMapper.readValue(underTest, AggregationRequest::class.java)
70+
objectMapper.readValue(underTest, SequenceFiltersRequestWithFields::class.java)
6871
}
6972
}
7073
}

0 commit comments

Comments
 (0)