diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/OpenApiDocs.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/OpenApiDocs.kt index 4f5c6cae..194796bb 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/OpenApiDocs.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/OpenApiDocs.kt @@ -28,7 +28,9 @@ import org.genspectrum.lapis.controller.LIMIT_PROPERTY import org.genspectrum.lapis.controller.LIMIT_SCHEMA import org.genspectrum.lapis.controller.MIN_PROPORTION_PROPERTY import org.genspectrum.lapis.controller.NUCLEOTIDE_INSERTIONS_PROPERTY +import org.genspectrum.lapis.controller.NUCLEOTIDE_INSERTIONS_REQUEST_SCHEMA import org.genspectrum.lapis.controller.NUCLEOTIDE_INSERTIONS_SCHEMA +import org.genspectrum.lapis.controller.NUCLEOTIDE_INSERTIONS_RESPONSE_SCHEMA import org.genspectrum.lapis.controller.NUCLEOTIDE_MUTATIONS_PROPERTY import org.genspectrum.lapis.controller.NUCLEOTIDE_MUTATIONS_RESPONSE_SCHEMA import org.genspectrum.lapis.controller.NUCLEOTIDE_MUTATIONS_SCHEMA @@ -89,6 +91,10 @@ fun buildOpenApiSchema(sequenceFilterFields: SequenceFilterFields, databaseConfi DETAILS_REQUEST_SCHEMA, requestSchemaWithFields(sequenceFilters, DETAILS_FIELDS_DESCRIPTION), ) + .addSchemas( + NUCLEOTIDE_INSERTIONS_REQUEST_SCHEMA, + requestSchemaForCommonSequencenFilters(sequenceFilters), + ) .addSchemas( AGGREGATED_RESPONSE_SCHEMA, lapisResponseSchema( @@ -137,6 +143,15 @@ fun buildOpenApiSchema(sequenceFilterFields: SequenceFilterFields, databaseConfi .properties(aminoAcidMutationProportionSchema()), ), ) + .addSchemas( + NUCLEOTIDE_INSERTIONS_RESPONSE_SCHEMA, + lapisResponseSchema( + Schema() + .type("object") + .description("Nucleotide Insertion data.") + .properties(nucleotideInsertionSchema()), + ), + ) .addSchemas(AMINO_ACID_MUTATIONS_SCHEMA, aminoAcidMutations()) .addSchemas(NUCLEOTIDE_INSERTIONS_SCHEMA, nucleotideInsertions()) .addSchemas(AMINO_ACID_INSERTIONS_SCHEMA, aminoAcidInsertions()) @@ -179,6 +194,14 @@ private fun primitiveSequenceFilterFieldSchemas(sequenceFilterFields: SequenceFi .map { (fieldName, fieldType) -> fieldName to Schema().type(fieldType.openApiType) } .toMap() +private fun requestSchemaForCommonSequencenFilters( + requestProperties: Map>, +): Schema<*> = + Schema() + .type("object") + .description("valid filters for sequence data") + .properties(requestProperties) + private fun requestSchemaWithFields( requestProperties: Map>, fieldsDescription: String, @@ -208,20 +231,29 @@ private fun accessKeySchema() = Schema() private fun nucleotideMutationProportionSchema() = mapOf( - "mutation" to Schema().type("string").description("The mutation that was found."), + "mutation" to Schema().type("string").example("T123C").description("The mutation that was found."), "proportion" to Schema().type("number").description("The proportion of sequences having the mutation."), "count" to Schema().type("number").description("The number of sequences matching having the mutation."), ) private fun aminoAcidMutationProportionSchema() = mapOf( - "mutation" to Schema().type("string").description( + "mutation" to Schema().type("string").example("ORF1a:123").description( "A amino acid mutation that was found in the format \"\\:\\", ), "proportion" to Schema().type("number").description("The proportion of sequences having the mutation."), "count" to Schema().type("number").description("The number of sequences matching having the mutation."), ) +private fun nucleotideInsertionSchema() = + mapOf( + "insertion" to Schema().type("string") + .example("ins_segment:123:AAT") + .description("The insertion that was found."), + "count" to Schema().type("number") + .description("The number of sequences matching having the insertion."), + ) + private fun nucleotideMutations() = Schema>() .type("array") 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 231880ab..699f4bdf 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt @@ -15,6 +15,7 @@ import org.genspectrum.lapis.model.SiloQueryModel import org.genspectrum.lapis.request.AminoAcidInsertion import org.genspectrum.lapis.request.AminoAcidMutation import org.genspectrum.lapis.request.CommonSequenceFilters +import org.genspectrum.lapis.request.InsertionsRequest import org.genspectrum.lapis.request.MutationProportionsRequest import org.genspectrum.lapis.request.NucleotideInsertion import org.genspectrum.lapis.request.NucleotideMutation @@ -23,6 +24,7 @@ import org.genspectrum.lapis.request.SequenceFiltersRequestWithFields import org.genspectrum.lapis.response.AggregationData import org.genspectrum.lapis.response.AminoAcidMutationResponse import org.genspectrum.lapis.response.DetailsData +import org.genspectrum.lapis.response.NucleotideInsertionResponse import org.genspectrum.lapis.response.NucleotideMutationResponse import org.springframework.http.MediaType import org.springframework.web.bind.annotation.GetMapping @@ -35,10 +37,13 @@ const val SEQUENCE_FILTERS_SCHEMA = "SequenceFilters" const val REQUEST_SCHEMA_WITH_MIN_PROPORTION = "SequenceFiltersWithMinProportion" const val AGGREGATED_REQUEST_SCHEMA = "AggregatedPostRequest" const val DETAILS_REQUEST_SCHEMA = "DetailsPostRequest" +const val NUCLEOTIDE_INSERTIONS_REQUEST_SCHEMA = "NucleotideInsertionsRequest" +const val AMINO_ACID_INSERTIONS_REQUEST_SCHEMA = "AminoAcidInsertionsRequest" const val AGGREGATED_RESPONSE_SCHEMA = "AggregatedResponse" const val DETAILS_RESPONSE_SCHEMA = "DetailsResponse" const val NUCLEOTIDE_MUTATIONS_RESPONSE_SCHEMA = "NucleotideMutationsResponse" const val AMINO_ACID_MUTATIONS_RESPONSE_SCHEMA = "AminoAcidMutationsResponse" +const val NUCLEOTIDE_INSERTIONS_RESPONSE_SCHEMA = "NucleotideInsertionsResponse" const val NUCLEOTIDE_MUTATIONS_SCHEMA = "NucleotideMutations" const val AMINO_ACID_MUTATIONS_SCHEMA = "AminoAcidMutations" @@ -58,6 +63,9 @@ const val NUCLEOTIDE_MUTATION_ENDPOINT_DESCRIPTION = const val AMINO_ACID_MUTATIONS_ENDPOINT_DESCRIPTION = "Returns the number of sequences matching the specified sequence filters, " + "grouped by amino acid mutations." +const val NUCLEOTIDE_INSERTIONS_ENDPOINT_DESCRIPTION = + "Returns the number of sequences matching the specified sequence filters, " + + "grouped by nucleotide insertions." const val AGGREGATED_GROUP_BY_FIELDS_DESCRIPTION = "The fields to stratify by. If empty, only the overall count is returned" const val AGGREGATED_ORDER_BY_FIELDS_DESCRIPTION = @@ -65,6 +73,9 @@ const val AGGREGATED_ORDER_BY_FIELDS_DESCRIPTION = "Fields specified here must either be \"count\" or also be present in \"fields\"." const val DETAILS_FIELDS_DESCRIPTION = "The fields that the response items should contain. If empty, all fields are returned" +const val NUCLEOTIDE_INSERTIONS_FIELDS_DESCRIPTION = + "The fields of the response to order by." + + "Fields specified here must either be \"count\" or also be present in \"fields\"." const val DETAILS_ORDER_BY_FIELDS_DESCRIPTION = "The fields of the response to order by. Fields specified here must also be present in \"fields\"." const val LIMIT_DESCRIPTION = "The maximum number of entries to return in the response" @@ -1048,6 +1059,248 @@ class LapisController( return getResponseAsCsv(request, TAB, siloQueryModel::getDetails) } + @GetMapping("/nucleotideInsertions", produces = [MediaType.APPLICATION_JSON_VALUE]) + @LapisNucleotideInsertionsResponse + @Operation( + description = NUCLEOTIDE_INSERTIONS_ENDPOINT_DESCRIPTION, + operationId = "postNucleotideInsertions", + ) + fun getNucleotideInsertions( + @SequenceFilters + @RequestParam + sequenceFilters: Map?, + @Parameter( + schema = Schema(ref = "#/components/schemas/$ORDER_BY_FIELDS_SCHEMA"), + description = AGGREGATED_ORDER_BY_FIELDS_DESCRIPTION, + ) + @RequestParam + orderBy: List?, + @Parameter( + schema = Schema(ref = "#/components/schemas/$NUCLEOTIDE_MUTATIONS_SCHEMA"), + explode = Explode.TRUE, + ) + @RequestParam + nucleotideMutations: List?, + @Parameter(schema = Schema(ref = "#/components/schemas/$AMINO_ACID_MUTATIONS_SCHEMA")) + @RequestParam + aminoAcidMutations: List?, + @RequestParam + nucleotideInsertions: List?, + @RequestParam + aminoAcidInsertions: List?, + @Parameter( + schema = Schema(ref = "#/components/schemas/$LIMIT_SCHEMA"), + description = LIMIT_DESCRIPTION, + ) + @RequestParam + limit: Int? = null, + @Parameter( + schema = Schema(ref = "#/components/schemas/$OFFSET_SCHEMA"), + description = OFFSET_DESCRIPTION, + ) + @RequestParam + offset: Int? = null, + @Parameter( + schema = Schema(ref = "#/components/schemas/$FORMAT_SCHEMA"), + description = FORMAT_DESCRIPTION, + ) + @RequestParam + dataFormat: String? = null, + ): LapisResponse> { + val insertionRequest = InsertionsRequest( + sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), + nucleotideMutations ?: emptyList(), + aminoAcidMutations ?: emptyList(), + nucleotideInsertions ?: emptyList(), + aminoAcidInsertions ?: emptyList(), + orderBy ?: emptyList(), + limit, + offset, + ) + + requestContext.filter = insertionRequest + + val result = siloQueryModel.getNucleotideInsertions(insertionRequest) + return LapisResponse(result) + } + + @GetMapping("/nucleotideInsertions", produces = [TEXT_CSV_HEADER]) + @Operation( + description = NUCLEOTIDE_INSERTIONS_ENDPOINT_DESCRIPTION, + operationId = "postNucleotideInsertionsAsCsv", + responses = [ApiResponse(responseCode = "200")], + ) + fun getNucleotideInsertionsAsCsv( + @SequenceFilters + @RequestParam + sequenceFilters: Map?, + @Parameter( + schema = Schema(ref = "#/components/schemas/$ORDER_BY_FIELDS_SCHEMA"), + description = AGGREGATED_ORDER_BY_FIELDS_DESCRIPTION, + ) + @RequestParam + orderBy: List?, + @Parameter( + schema = Schema(ref = "#/components/schemas/$NUCLEOTIDE_MUTATIONS_SCHEMA"), + explode = Explode.TRUE, + ) + @RequestParam + nucleotideMutations: List?, + @Parameter(schema = Schema(ref = "#/components/schemas/$AMINO_ACID_MUTATIONS_SCHEMA")) + @RequestParam + aminoAcidMutations: List?, + @RequestParam + nucleotideInsertions: List?, + @RequestParam + aminoAcidInsertions: List?, + @Parameter( + schema = Schema(ref = "#/components/schemas/$LIMIT_SCHEMA"), + description = LIMIT_DESCRIPTION, + ) + @RequestParam + limit: Int? = null, + @Parameter( + schema = Schema(ref = "#/components/schemas/$OFFSET_SCHEMA"), + description = OFFSET_DESCRIPTION, + ) + @RequestParam + offset: Int? = null, + @Parameter( + schema = Schema(ref = "#/components/schemas/$FORMAT_SCHEMA"), + description = FORMAT_DESCRIPTION, + ) + @RequestParam + dataFormat: String? = null, + ): String { + val insertionRequest = InsertionsRequest( + sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), + nucleotideMutations ?: emptyList(), + aminoAcidMutations ?: emptyList(), + nucleotideInsertions ?: emptyList(), + aminoAcidInsertions ?: emptyList(), + orderBy ?: emptyList(), + limit, + offset, + ) + + requestContext.filter = insertionRequest + + return getResponseAsCsv(insertionRequest, COMMA, siloQueryModel::getNucleotideInsertions) + } + + @GetMapping("/nucleotideInsertions", produces = [TEXT_TSV_HEADER]) + @Operation( + description = NUCLEOTIDE_INSERTIONS_ENDPOINT_DESCRIPTION, + operationId = "postNucleotideInsertionsAsTsv", + responses = [ApiResponse(responseCode = "200")], + ) + fun getNucleotideInsertionsAsTsv( + @SequenceFilters + @RequestParam + sequenceFilters: Map?, + @Parameter( + schema = Schema(ref = "#/components/schemas/$ORDER_BY_FIELDS_SCHEMA"), + description = AGGREGATED_ORDER_BY_FIELDS_DESCRIPTION, + ) + @RequestParam + orderBy: List?, + @Parameter( + schema = Schema(ref = "#/components/schemas/$NUCLEOTIDE_MUTATIONS_SCHEMA"), + explode = Explode.TRUE, + ) + @RequestParam + nucleotideMutations: List?, + @Parameter(schema = Schema(ref = "#/components/schemas/$AMINO_ACID_MUTATIONS_SCHEMA")) + @RequestParam + aminoAcidMutations: List?, + @RequestParam + nucleotideInsertions: List?, + @RequestParam + aminoAcidInsertions: List?, + @Parameter( + schema = Schema(ref = "#/components/schemas/$LIMIT_SCHEMA"), + description = LIMIT_DESCRIPTION, + ) + @RequestParam + limit: Int? = null, + @Parameter( + schema = Schema(ref = "#/components/schemas/$OFFSET_SCHEMA"), + description = OFFSET_DESCRIPTION, + ) + @RequestParam + offset: Int? = null, + @Parameter( + schema = Schema(ref = "#/components/schemas/$FORMAT_SCHEMA"), + description = FORMAT_DESCRIPTION, + ) + @RequestParam + dataFormat: String? = null, + ): String { + val insertionRequest = InsertionsRequest( + sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), + nucleotideMutations ?: emptyList(), + aminoAcidMutations ?: emptyList(), + nucleotideInsertions ?: emptyList(), + aminoAcidInsertions ?: emptyList(), + orderBy ?: emptyList(), + limit, + offset, + ) + + requestContext.filter = insertionRequest + + return getResponseAsCsv(insertionRequest, TAB, siloQueryModel::getNucleotideInsertions) + } + + @PostMapping("/nucleotideInsertions", produces = [MediaType.APPLICATION_JSON_VALUE]) + @LapisNucleotideInsertionsResponse + @Operation( + description = NUCLEOTIDE_INSERTIONS_ENDPOINT_DESCRIPTION, + operationId = "postNucleotideInsertions", + ) + fun postNucleotideInsertions( + @Parameter(schema = Schema(ref = "#/components/schemas/$NUCLEOTIDE_INSERTIONS_REQUEST_SCHEMA")) + @RequestBody + request: InsertionsRequest, + ): LapisResponse> { + requestContext.filter = request + + val result = siloQueryModel.getNucleotideInsertions(request) + return LapisResponse(result) + } + + @PostMapping("/nucleotideInsertions", produces = [TEXT_CSV_HEADER]) + @Operation( + description = NUCLEOTIDE_INSERTIONS_ENDPOINT_DESCRIPTION, + operationId = "postNucleotideInsertionsAsCsv", + responses = [ApiResponse(responseCode = "200")], + ) + fun postNucleotideInsertionsAsCsv( + @Parameter(schema = Schema(ref = "#/components/schemas/$NUCLEOTIDE_INSERTIONS_REQUEST_SCHEMA")) + @RequestBody + request: InsertionsRequest, + ): String { + requestContext.filter = request + + return getResponseAsCsv(request, COMMA, siloQueryModel::getNucleotideInsertions) + } + + @PostMapping("/nucleotideInsertions", produces = [TEXT_TSV_HEADER]) + @Operation( + description = NUCLEOTIDE_INSERTIONS_ENDPOINT_DESCRIPTION, + operationId = "postNucleotideInsertionsAsTsv", + responses = [ApiResponse(responseCode = "200")], + ) + fun postNucleotideInsertionsAsTsv( + @Parameter(schema = Schema(ref = "#/components/schemas/$NUCLEOTIDE_INSERTIONS_REQUEST_SCHEMA")) + @RequestBody + request: InsertionsRequest, + ): String { + requestContext.filter = request + + return getResponseAsCsv(request, TAB, siloQueryModel::getNucleotideInsertions) + } + private fun getResponseAsCsv( request: Request, delimiter: Delimiter, @@ -1119,6 +1372,21 @@ private annotation class LapisAminoAcidMutationsResponse ) private annotation class LapisDetailsResponse +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@Operation( + description = "Returns a list of insertions along with the counts Only sequences matching the specified " + + "sequence filters are considered.", +) +@ApiResponse( + responseCode = "200", + description = "OK", + content = [ + Content(schema = Schema(ref = "#/components/schemas/$NUCLEOTIDE_INSERTIONS_RESPONSE_SCHEMA")), + ], +) +private annotation class LapisNucleotideInsertionsResponse + @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) @Parameter( 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 ee82857f..c7af2599 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/model/SiloQueryModel.kt @@ -1,9 +1,11 @@ package org.genspectrum.lapis.model +import org.genspectrum.lapis.request.InsertionsRequest import org.genspectrum.lapis.request.MutationProportionsRequest import org.genspectrum.lapis.request.SequenceFiltersRequestWithFields import org.genspectrum.lapis.response.AminoAcidMutationResponse import org.genspectrum.lapis.response.DetailsData +import org.genspectrum.lapis.response.NucleotideInsertionResponse import org.genspectrum.lapis.response.NucleotideMutationResponse import org.genspectrum.lapis.silo.SiloAction import org.genspectrum.lapis.silo.SiloClient @@ -80,15 +82,46 @@ class SiloQueryModel( } } - fun getDetails(sequenceFilters: SequenceFiltersRequestWithFields): List = siloClient.sendQuery( - SiloQuery( - SiloAction.details( - sequenceFilters.fields, - sequenceFilters.orderByFields, - sequenceFilters.limit, - sequenceFilters.offset, + fun getDetails(sequenceFilters: SequenceFiltersRequestWithFields): List = + siloClient.sendQuery( + SiloQuery( + SiloAction.details( + sequenceFilters.fields, + sequenceFilters.orderByFields, + sequenceFilters.limit, + sequenceFilters.offset, + ), + siloFilterExpressionMapper.map(sequenceFilters), ), - siloFilterExpressionMapper.map(sequenceFilters), - ), - ) + ) + + fun getNucleotideInsertions(sequenceFilters: InsertionsRequest): List { + val data = siloClient.sendQuery( + SiloQuery( + SiloAction.nucleotideInsertions( + sequenceFilters.orderByFields, + sequenceFilters.limit, + sequenceFilters.offset, + ), + siloFilterExpressionMapper.map(sequenceFilters), + ), + ) + + return data.map { it -> + NucleotideInsertionResponse( + "ins_" + + if ( + it.sequenceName == "main" + ) { + "" + } else { + it.sequenceName + ":" + } + + it.position + + ":" + + it.insertions, + it.count, + ) + } + } } diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/request/InsertionsRequest.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/request/InsertionsRequest.kt new file mode 100644 index 00000000..4c28389e --- /dev/null +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/request/InsertionsRequest.kt @@ -0,0 +1,39 @@ +package org.genspectrum.lapis.request + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import org.springframework.boot.jackson.JsonComponent + +data class InsertionsRequest( + override val sequenceFilters: Map, + override val nucleotideMutations: List, + override val aaMutations: List, + override val nucleotideInsertions: List, + override val aminoAcidInsertions: List, + override val orderByFields: List = emptyList(), + override val limit: Int? = null, + override val offset: Int? = null, +) : CommonSequenceFilters + +@JsonComponent +class InsertionRequestDeserializer : JsonDeserializer() { + override fun deserialize(jsonParser: JsonParser, ctxt: DeserializationContext): InsertionsRequest { + val node = jsonParser.readValueAsTree() + val codec = jsonParser.codec + + val parsedCommonFields = parseCommonFields(node, codec) + + return InsertionsRequest( + parsedCommonFields.sequenceFilters, + parsedCommonFields.nucleotideMutations, + parsedCommonFields.aminoAcidMutations, + parsedCommonFields.nucleotideInsertions, + parsedCommonFields.aminoAcidInsertions, + parsedCommonFields.orderByFields, + parsedCommonFields.limit, + parsedCommonFields.offset, + ) + } +} diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/response/LapisResponse.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/response/LapisResponse.kt index 8d6e58e6..9227ce7b 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/response/LapisResponse.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/response/LapisResponse.kt @@ -45,3 +45,21 @@ data class AminoAcidMutationResponse( override fun asArray() = arrayOf(mutation, count.toString(), proportion.toString()) override fun getHeader() = arrayOf("mutation", "count", "proportion") } + +data class NucleotideInsertionResponse( + @Schema( + example = "ins_22204:CAGAA", + description = + "|A nucleotide insertion in the format \"ins_\\?:\\?:\\?\". " + + "|If the pathogen has only one segment LAPIS will omit the segment name.", + ) + val insertion: String, + @Schema( + example = "123", + description = "Total number of sequences with this insertion matching the given sequence filter criteria", + ) + val count: Int, +) : CsvRecord { + override fun asArray() = arrayOf(insertion, count.toString()) + override fun getHeader() = arrayOf("insertion", "count") +} diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/response/SiloResponse.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/response/SiloResponse.kt index c52d1b4e..86d08f30 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/response/SiloResponse.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/response/SiloResponse.kt @@ -57,3 +57,10 @@ data class MutationData( val proportion: Double, val sequenceName: String, ) + +data class InsertionData( + val count: Int, + val insertions: String, + val position: Int, + val sequenceName: String, +) 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 7e921905..711e588c 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/silo/SiloQuery.kt @@ -6,6 +6,7 @@ import com.fasterxml.jackson.core.type.TypeReference import org.genspectrum.lapis.request.OrderByField import org.genspectrum.lapis.response.AggregationData import org.genspectrum.lapis.response.DetailsData +import org.genspectrum.lapis.response.InsertionData import org.genspectrum.lapis.response.MutationData import java.time.LocalDate @@ -15,6 +16,7 @@ class AggregationDataTypeReference : TypeReference>>() class AminoAcidMutationDataTypeReference : TypeReference>>() class DetailsDataTypeReference : TypeReference>>() +class NucleotideInsertionDataTypeReference : TypeReference>>() interface CommonActionFields { val orderByFields: List @@ -64,6 +66,12 @@ sealed class SiloAction( limit: Int? = null, offset: Int? = null, ): SiloAction> = DetailsAction(fields, orderByFields, limit, offset) + + fun nucleotideInsertions( + orderByFields: List = emptyList(), + limit: Int? = null, + offset: Int? = null, + ): SiloAction> = NucleotideInsertionsAction(orderByFields, limit, offset) } @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -95,13 +103,20 @@ sealed class SiloAction( @JsonInclude(JsonInclude.Include.NON_EMPTY) private data class DetailsAction( - val fields: List = emptyList(), override val orderByFields: List = emptyList(), override val limit: Int? = null, override val offset: Int? = null, val type: String = "Details", ) : SiloAction>(DetailsDataTypeReference()) + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private data class NucleotideInsertionsAction( + override val orderByFields: List = emptyList(), + override val limit: Int? = null, + override val offset: Int? = null, + val type: String = "Insertions", + ) : SiloAction>(NucleotideInsertionDataTypeReference()) } sealed class SiloFilterExpression(val type: String) diff --git a/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerCsvTest.kt b/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerCsvTest.kt index 69676d00..4796dd48 100644 --- a/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerCsvTest.kt +++ b/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerCsvTest.kt @@ -11,8 +11,11 @@ import org.genspectrum.lapis.request.SequenceFiltersRequestWithFields import org.genspectrum.lapis.response.AggregationData import org.genspectrum.lapis.response.AminoAcidMutationResponse import org.genspectrum.lapis.response.DetailsData +import org.genspectrum.lapis.response.NucleotideInsertionResponse import org.genspectrum.lapis.response.NucleotideMutationResponse -import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -30,300 +33,58 @@ class LapisControllerCsvTest(@Autowired val mockMvc: MockMvc) { @MockkBean lateinit var siloQueryModelMock: SiloQueryModel - val listOfMetadata = listOf( - DetailsData(mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42))), - DetailsData(mapOf("country" to TextNode("Switzerland"), "age" to IntNode(43))), - ) - - val metadataCsv = """ - country,age - Switzerland,42 - Switzerland,43 - """.trimIndent() - - val metadataTsv = """ - country age - Switzerland 42 - Switzerland 43 - """.trimIndent() - - val aggregationData = listOf( - AggregationData( - 0, - mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42)), - ), - ) - - val aggregationDataCsv = """ - country,age,count - Switzerland,42,0 - """.trimIndent() - - val aggregationDataTsv = """ - country age count - Switzerland 42 0 - """.trimIndent() - - val nucleotideMutationData = listOf( - NucleotideMutationResponse( - "sequenceName:1234", - 2345, - 0.987, - ), - ) - - val aminoAcidMutationData = listOf( - AminoAcidMutationResponse( - "sequenceName:1234", - 2345, - 0.987, - ), - ) - - val mutationDataCsv = """ - mutation,count,proportion - sequenceName:1234,2345,0.987 - """.trimIndent() - - val mutationDataTsv = """ - mutation count proportion - sequenceName:1234 2345 0.987 - """.trimIndent() - - @Test - fun `GET empty details return empty CSV`() { - every { - siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) - } returns emptyList() - - mockMvc.perform(get("/details?country=Switzerland").header("Accept", "text/csv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string("")) - } - - @Test - fun `GET details as CSV with accept header`() { - every { - siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) - } returns listOf( - DetailsData( - mapOf( - "country" to TextNode("Switzerland"), - "age" to IntNode(42), - "floatValue" to DoubleNode(3.14), - ), - ), - DetailsData( - mapOf( - "country" to TextNode("Switzerland"), - "age" to IntNode(43), - "floatValue" to NullNode.instance, - ), - ), - ) - - mockMvc.perform(get("/details?country=Switzerland").header("Accept", "text/csv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect( - content().string( - """ - country,age,floatValue - Switzerland,42,3.14 - Switzerland,43,null - """.trimIndent(), - ), - ) - } - - @Test - fun `GET details as TSV with accept header`() { - every { - siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) - } returns listOfMetadata - - mockMvc.perform(get("/details?country=Switzerland").header("Accept", "text/tab-separated-values")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(metadataTsv)) - } - - @Test - fun `GET details as CSV with request parameter`() { - every { - siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) - } returns listOfMetadata - - mockMvc.perform(get("/details?country=Switzerland&dataFormat=csv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(metadataCsv)) - } - - @Test - fun `GET details as TSV with request parameter`() { - every { - siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) - } returns listOfMetadata - - mockMvc.perform(get("/details?country=Switzerland&dataFormat=tsv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(metadataTsv)) - } - - @Test - fun `POST details returns empty CSV`() { - every { - siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) - } returns emptyList() - - val request = post("/details") - .content("""{"country": "Switzerland"}""") - .contentType(MediaType.APPLICATION_JSON) - .accept("text/csv") + @ParameterizedTest(name = "GET empty {0}") + @MethodSource("getEndpoints") + fun `GET empty return empty CSV`( + endpoint: String, + ) { + mockEndpointReturnEmptyList(endpoint) - mockMvc.perform(request) + mockMvc.perform(get(endpoint + "?country=Switzerland").header("Accept", "text/csv")) .andExpect(status().isOk) .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) .andExpect(content().string("")) } - @Test - fun `POST details as CSV with accept header`() { - every { - siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) - } returns listOfMetadata + @ParameterizedTest(name = "POST empty {0}") + @MethodSource("getEndpoints") + fun `POST empty {0} return empty CSV`( + endpoint: String, + ) { + mockEndpointReturnEmptyList(endpoint) - val request = post("/details") + val request = post(endpoint) .content("""{"country": "Switzerland"}""") .contentType(MediaType.APPLICATION_JSON) .accept("text/csv") mockMvc.perform(request) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(metadataCsv)) - } - - @Test - fun `POST details as TSV with accept header`() { - every { - siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) - } returns listOfMetadata - - val request = post("/details") - .content("""{"country": "Switzerland"}""") - .contentType(MediaType.APPLICATION_JSON) - .accept("text/tab-separated-values") - - mockMvc.perform(request) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(metadataTsv)) - } - - @Test - fun `POST details as CSV with request parameter`() { - every { - siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) - } returns listOfMetadata - - val request = post("/details") - .content("""{"country": "Switzerland", "dataFormat": "csv"}""") - .contentType(MediaType.APPLICATION_JSON) - - mockMvc.perform(request) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(metadataCsv)) - } - - @Test - fun `POST details as TSV with request parameter`() { - every { - siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) - } returns listOfMetadata - - val request = post("/details") - .content("""{"country": "Switzerland", "dataFormat": "tsv"}""") - .contentType(MediaType.APPLICATION_JSON) - - mockMvc.perform(request) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(metadataTsv)) - } - - private fun sequenceFiltersRequestWithFields( - sequenceFilters: Map, - fields: List = emptyList(), - ) = SequenceFiltersRequestWithFields( - sequenceFilters, - emptyList(), - emptyList(), - emptyList(), - emptyList(), - fields, - emptyList(), - ) - - @Test - fun `GET aggregated returns empty CSV`() { - every { siloQueryModelMock.getAggregated(any()) } returns emptyList() - - mockMvc.perform(get("/aggregated?country=Switzerland").header("Accept", "text/csv")) .andExpect(status().isOk) .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) .andExpect(content().string("")) } - @Test - fun `GET aggregated as CSV with accept header`() { - every { siloQueryModelMock.getAggregated(any()) } returns aggregationData + @ParameterizedTest(name = "GET {0} returns as CSV with accept header") + @MethodSource("getEndpoints") + fun `GET returns as CSV with accept header`( + endpoint: String, + ) { + mockEndpointReturnData(endpoint) - mockMvc.perform(get("/aggregated?country=Switzerland").header("Accept", "text/csv")) + mockMvc.perform(get(endpoint + "?country=Switzerland").header("Accept", "text/csv")) .andExpect(status().isOk) .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(aggregationDataCsv)) + .andExpect(content().string(returnedCsvData(endpoint))) } - @Test - fun `GET aggregated as TSV with accept header`() { - every { siloQueryModelMock.getAggregated(any()) } returns aggregationData + @ParameterizedTest(name = "POST {0} returns as CSV with accept header") + @MethodSource("getEndpoints") + fun `POST returns as CSV with accept header`( + endpoint: String, + ) { + mockEndpointReturnData(endpoint) - mockMvc.perform(get("/aggregated?country=Switzerland").header("Accept", "text/tab-separated-values")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(aggregationDataTsv)) - } - - @Test - fun `GET aggregated as CSV with request parameter`() { - every { siloQueryModelMock.getAggregated(any()) } returns aggregationData - - mockMvc.perform(get("/aggregated?country=Switzerland&dataFormat=csv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(aggregationDataCsv)) - } - - @Test - fun `GET aggregated as TSV with request parameter`() { - every { siloQueryModelMock.getAggregated(any()) } returns aggregationData - - mockMvc.perform(get("/aggregated?country=Switzerland&dataFormat=tsv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(aggregationDataTsv)) - } - - @Test - fun `POST aggregated returns empty CSV`() { - every { siloQueryModelMock.getAggregated(any()) } returns emptyList() - - val request = post("/aggregated") + val request = post(endpoint) .content("""{"country": "Switzerland"}""") .contentType(MediaType.APPLICATION_JSON) .accept("text/csv") @@ -331,102 +92,60 @@ class LapisControllerCsvTest(@Autowired val mockMvc: MockMvc) { mockMvc.perform(request) .andExpect(status().isOk) .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string("")) + .andExpect(content().string(returnedCsvData(endpoint))) } - @Test - fun `POST aggregated as CSV with accept header`() { - every { siloQueryModelMock.getAggregated(any()) } returns aggregationData + @ParameterizedTest(name = "GET {0} returns as CSV with request parameter") + @MethodSource("getEndpoints") + fun `GET returns as CSV with request parameter`( + endpoint: String, + ) { + mockEndpointReturnData(endpoint) - val request = post("/aggregated") - .content("""{"country": "Switzerland"}""") - .contentType(MediaType.APPLICATION_JSON) - .accept("text/csv") - - mockMvc.perform(request) + mockMvc.perform(get(endpoint + "?country=Switzerland&dataFormat=csv")) .andExpect(status().isOk) .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(aggregationDataCsv)) + .andExpect(content().string(returnedCsvData(endpoint))) } - @Test - fun `POST aggregated as TSV with accept header`() { - every { siloQueryModelMock.getAggregated(any()) } returns aggregationData + @ParameterizedTest(name = "POST {0} returns as CSV with request parameter") + @MethodSource("getEndpoints") + fun `POST returns as CSV with request parameter`( + endpoint: String, + ) { + mockEndpointReturnData(endpoint) - val request = post("/aggregated") - .content("""{"country": "Switzerland"}""") - .contentType(MediaType.APPLICATION_JSON) - .accept("text/tab-separated-values") - - mockMvc.perform(request) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(aggregationDataTsv)) - } - - @Test - fun `POST aggregated as CSV with request parameter`() { - every { siloQueryModelMock.getAggregated(any()) } returns aggregationData - - val request = post("/aggregated") + val request = post(endpoint) .content("""{"country": "Switzerland", "dataFormat": "csv"}""") .contentType(MediaType.APPLICATION_JSON) mockMvc.perform(request) .andExpect(status().isOk) .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(aggregationDataCsv)) + .andExpect(content().string(returnedCsvData(endpoint))) } - @Test - fun `POST aggregated as TSV with request parameter`() { - every { siloQueryModelMock.getAggregated(any()) } returns aggregationData + @ParameterizedTest(name = "GET {0} returns as TSV with accept header") + @MethodSource("getEndpoints") + fun `GET returns as TSV with accept header`( + endpoint: String, + ) { + mockEndpointReturnData(endpoint) - val request = post("/aggregated") - .content("""{"country": "Switzerland", "dataFormat": "tsv"}""") - .contentType(MediaType.APPLICATION_JSON) - - mockMvc.perform(request) + mockMvc.perform(get(endpoint + "?country=Switzerland").header("Accept", "text/tab-separated-values")) .andExpect(status().isOk) .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(aggregationDataTsv)) - } - - @Test - fun `POST nucleotideMutations returns empty CSV`() { - every { siloQueryModelMock.computeNucleotideMutationProportions(any()) } returns emptyList() - - val request = post("/nucleotideMutations") - .content("""{"country": "Switzerland"}""") - .contentType(MediaType.APPLICATION_JSON) - .accept("text/csv") - - mockMvc.perform(request) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string("")) + .andExpect(content().string(returnedTsvData(endpoint))) } - @Test - fun `POST nucleotideMutations as CSV with accept header`() { - every { siloQueryModelMock.computeNucleotideMutationProportions(any()) } returns nucleotideMutationData + @ParameterizedTest(name = "POST {0} returns as TSV with accept header") + @MethodSource("getEndpoints") + fun `POST returns as TSV with accept header`( + endpoint: String, + ) { + mockEndpointReturnData(endpoint) - val request = post("/nucleotideMutations") - .content("""{"country": "Switzerland"}""") - .contentType(MediaType.APPLICATION_JSON) - .accept("text/csv") - - mockMvc.perform(request) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(mutationDataCsv)) - } - - @Test - fun `POST nucleotideMutations as TSV with accept header`() { - every { siloQueryModelMock.computeNucleotideMutationProportions(any()) } returns nucleotideMutationData - - val request = post("/nucleotideMutations") + val request = post(endpoint) .content("""{"country": "Switzerland"}""") .contentType(MediaType.APPLICATION_JSON) .accept("text/tab-separated-values") @@ -434,143 +153,231 @@ class LapisControllerCsvTest(@Autowired val mockMvc: MockMvc) { mockMvc.perform(request) .andExpect(status().isOk) .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(mutationDataTsv)) + .andExpect(content().string(returnedTsvData(endpoint))) } - @Test - fun `GET nucleotideMutations returns empty CSV`() { - every { siloQueryModelMock.computeNucleotideMutationProportions(any()) } returns emptyList() + @ParameterizedTest(name = "GET {0} returns as TSV with request parameter") + @MethodSource("getEndpoints") + fun `GET returns as TSV with request parameter`( + endpoint: String, + ) { + mockEndpointReturnData(endpoint) - mockMvc.perform(get("/nucleotideMutations?country=Switzerland").header("Accept", "text/csv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string("")) - } - - @Test - fun `GET nucleotideMutations as CSV with accept header`() { - every { siloQueryModelMock.computeNucleotideMutationProportions(any()) } returns nucleotideMutationData - mockMvc.perform(get("/nucleotideMutations?country=Switzerland").header("Accept", "text/csv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(mutationDataCsv)) - } - - @Test - fun `GET nucleotideMutations as TSV with accept header`() { - every { siloQueryModelMock.computeNucleotideMutationProportions(any()) } returns nucleotideMutationData - mockMvc.perform(get("/nucleotideMutations?country=Switzerland").header("Accept", "text/tab-separated-values")) + mockMvc.perform(get(endpoint + "?country=Switzerland&dataFormat=tsv")) .andExpect(status().isOk) .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(mutationDataTsv)) + .andExpect(content().string(returnedTsvData(endpoint))) } - @Test - fun `GET nucleotideMutations as CSV with request parameter`() { - every { siloQueryModelMock.computeNucleotideMutationProportions(any()) } returns nucleotideMutationData - mockMvc.perform(get("/nucleotideMutations?country=Switzerland&dataFormat=csv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(mutationDataCsv)) - } + @ParameterizedTest(name = "POST {0} returns as TSV with request parameter") + @MethodSource("getEndpoints") + fun `POST returns as TSV with request parameter`( + endpoint: String, + ) { + mockEndpointReturnData(endpoint) - @Test - fun `GET nucleotideMutations as TSV with request parameter`() { - every { siloQueryModelMock.computeNucleotideMutationProportions(any()) } returns nucleotideMutationData - mockMvc.perform(get("/nucleotideMutations?country=Switzerland&dataFormat=tsv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(mutationDataTsv)) - } - - @Test - fun `POST aminoAcidMutations returns empty CSV`() { - every { siloQueryModelMock.computeAminoAcidMutationProportions(any()) } returns emptyList() - - val request = post("/aminoAcidMutations") - .content("""{"country": "Switzerland"}""") + val request = post(endpoint) + .content("""{"country": "Switzerland", "dataFormat": "tsv"}""") .contentType(MediaType.APPLICATION_JSON) - .accept("text/csv") mockMvc.perform(request) .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string("")) - } + .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) + .andExpect(content().string(returnedTsvData(endpoint))) + } + + fun mockEndpointReturnEmptyList(endpoint: String) { + if (endpoint == "/details") { + every { + siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) + } returns emptyList() + } + if (endpoint == "/aggregated") { + every { + siloQueryModelMock.getAggregated(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) + } returns emptyList() + } + if (endpoint == "/nucleotideMutations") { + every { siloQueryModelMock.computeNucleotideMutationProportions(any()) } returns emptyList() + } + if (endpoint == "/aminoAcidMutations") { + every { siloQueryModelMock.computeAminoAcidMutationProportions(any()) } returns emptyList() + } + if (endpoint == "/nucleotideInsertions") { + every { siloQueryModelMock.getNucleotideInsertions(any()) } returns emptyList() + } + } + + fun mockEndpointReturnData(endpoint: String) { + if (endpoint == "/details") { + every { + siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) + } returns detailsData + } + if (endpoint == "/aggregated") { + every { + siloQueryModelMock.getAggregated(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) + } returns aggregationData + } + if (endpoint == "/nucleotideMutations") { + every { siloQueryModelMock.computeNucleotideMutationProportions(any()) } returns nucleotideMutationData + } + if (endpoint == "/aminoAcidMutations") { + every { siloQueryModelMock.computeAminoAcidMutationProportions(any()) } returns aminoAcidMutationData + } + if (endpoint == "/nucleotideInsertions") { + every { siloQueryModelMock.getNucleotideInsertions(any()) } returns nucleotideInsertionData + } + } + + fun returnedCsvData(endpoint: String): String { + if (endpoint == "/details") { + return detailsDataCsv + } + if (endpoint == "/aggregated") { + return aggregationDataCsv + } + if (endpoint == "/nucleotideMutations") { + return mutationDataCsv + } + if (endpoint == "/aminoAcidMutations") { + return mutationDataCsv + } + if (endpoint == "/nucleotideInsertions") { + return nucleotideInsertionDataCsv + } + return "" + } + + fun returnedTsvData(endpoint: String): String { + if (endpoint == "/details") { + return detailsDataTsv + } + if (endpoint == "/aggregated") { + return aggregationDataTsv + } + if (endpoint == "/nucleotideMutations") { + return mutationDataTsv + } + if (endpoint == "/aminoAcidMutations") { + return mutationDataTsv + } + if (endpoint == "/nucleotideInsertions") { + return nucleotideInsertionDataTsv + } + return "" + } + + val detailsData = listOf( + DetailsData( + mapOf( + "country" to TextNode("Switzerland"), + "age" to IntNode(42), + "floatValue" to DoubleNode(3.14), + ), + ), + DetailsData( + mapOf( + "country" to TextNode("Switzerland"), + "age" to IntNode(43), + "floatValue" to NullNode.instance, + ), + ), + ) - @Test - fun `POST aminoAcidMutations as CSV with accept header`() { - every { siloQueryModelMock.computeAminoAcidMutationProportions(any()) } returns aminoAcidMutationData + val detailsDataCsv = """ + country,age,floatValue + Switzerland,42,3.14 + Switzerland,43,null + """.trimIndent() - val request = post("/aminoAcidMutations") - .content("""{"country": "Switzerland"}""") - .contentType(MediaType.APPLICATION_JSON) - .accept("text/csv") + val detailsDataTsv = """ + country age floatValue + Switzerland 42 3.14 + Switzerland 43 null + """.trimIndent() - mockMvc.perform(request) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(mutationDataCsv)) - } + val aggregationData = listOf( + AggregationData( + 0, + mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42)), + ), + ) - @Test - fun `POST aminoAcidMutations as TSV with accept header`() { - every { siloQueryModelMock.computeAminoAcidMutationProportions(any()) } returns aminoAcidMutationData + val aggregationDataCsv = """ + country,age,count + Switzerland,42,0 + """.trimIndent() - val request = post("/aminoAcidMutations") - .content("""{"country": "Switzerland"}""") - .contentType(MediaType.APPLICATION_JSON) - .accept("text/tab-separated-values") + val aggregationDataTsv = """ + country age count + Switzerland 42 0 + """.trimIndent() - mockMvc.perform(request) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(mutationDataTsv)) - } + val nucleotideMutationData = listOf( + NucleotideMutationResponse( + "sequenceName:1234", + 2345, + 0.987, + ), + ) - @Test - fun `GET aminoAcidMutations returns empty CSV`() { - every { siloQueryModelMock.computeAminoAcidMutationProportions(any()) } returns emptyList() + val aminoAcidMutationData = listOf( + AminoAcidMutationResponse( + "sequenceName:1234", + 2345, + 0.987, + ), + ) - mockMvc.perform(get("/aminoAcidMutations?country=Switzerland").header("Accept", "text/csv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string("")) - } + val mutationDataCsv = """ + mutation,count,proportion + sequenceName:1234,2345,0.987 + """.trimIndent() - @Test - fun `GET aminoAcidMutations as CSV with accept header`() { - every { siloQueryModelMock.computeAminoAcidMutationProportions(any()) } returns aminoAcidMutationData - mockMvc.perform(get("/aminoAcidMutations?country=Switzerland").header("Accept", "text/csv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(mutationDataCsv)) - } + val mutationDataTsv = """ + mutation count proportion + sequenceName:1234 2345 0.987 + """.trimIndent() - @Test - fun `GET aminoAcidMutations as TSV with accept header`() { - every { siloQueryModelMock.computeAminoAcidMutationProportions(any()) } returns aminoAcidMutationData - mockMvc.perform(get("/aminoAcidMutations?country=Switzerland").header("Accept", "text/tab-separated-values")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(mutationDataTsv)) - } + val nucleotideInsertionData = listOf( + NucleotideInsertionResponse( + "ins_1234:CAGAA", + 41, + ), + ) - @Test - fun `GET aminoAcidMutations as CSV with request parameter`() { - every { siloQueryModelMock.computeAminoAcidMutationProportions(any()) } returns aminoAcidMutationData - mockMvc.perform(get("/aminoAcidMutations?country=Switzerland&dataFormat=csv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(mutationDataCsv)) - } + val nucleotideInsertionDataCsv = """ + insertion,count + ins_1234:CAGAA,41 + """.trimIndent() - @Test - fun `GET aminoAcidMutations as TSV with request parameter`() { - every { siloQueryModelMock.computeAminoAcidMutationProportions(any()) } returns aminoAcidMutationData - mockMvc.perform(get("/aminoAcidMutations?country=Switzerland&dataFormat=tsv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(mutationDataTsv)) + val nucleotideInsertionDataTsv = """ + insertion count + ins_1234:CAGAA 41 + """.trimIndent() + + private companion object { + @JvmStatic + fun getEndpoints() = listOf( + Arguments.of("/details"), + Arguments.of("/aggregated"), + Arguments.of("/nucleotideMutations"), + Arguments.of("/aminoAcidMutations"), + Arguments.of("/nucleotideInsertions"), + ) } + + private fun sequenceFiltersRequestWithFields( + sequenceFilters: Map, + fields: List = emptyList(), + ) = SequenceFiltersRequestWithFields( + sequenceFilters, + emptyList(), + emptyList(), + emptyList(), + emptyList(), + fields, + emptyList(), + ) } 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 ff186907..aa39ccce 100644 --- a/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt +++ b/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerTest.kt @@ -6,12 +6,14 @@ import com.ninjasquad.springmockk.MockkBean import io.mockk.every import org.genspectrum.lapis.model.SiloQueryModel import org.genspectrum.lapis.request.DataVersion +import org.genspectrum.lapis.request.InsertionsRequest import org.genspectrum.lapis.request.MutationProportionsRequest import org.genspectrum.lapis.request.NucleotideMutation import org.genspectrum.lapis.request.SequenceFiltersRequestWithFields import org.genspectrum.lapis.response.AggregationData import org.genspectrum.lapis.response.AminoAcidMutationResponse import org.genspectrum.lapis.response.DetailsData +import org.genspectrum.lapis.response.NucleotideInsertionResponse import org.genspectrum.lapis.response.NucleotideMutationResponse import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -220,6 +222,23 @@ class LapisControllerTest(@Autowired val mockMvc: MockMvc) { .andExpect(header().stringValues("Lapis-Data-Version", "1234")) } + @ParameterizedTest(name = "POST {0} with invalid minProportion returns bad request") + @MethodSource("getMutationEndpointTypes") + fun `POST mutations with invalid minProportion returns bad request`( + endpoint: String, + ) { + val request = post(endpoint) + .content("""{"country": "Switzerland", "minProportion": "this is not a float"}""") + .contentType(MediaType.APPLICATION_JSON) + + mockMvc.perform(request) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("\$.error.title").value("Bad request")) + .andExpect( + jsonPath("\$.error.message").value("minProportion must be a number"), + ) + } + private fun setupMutationMock(endpoint: String, minProportion: Double?) { if (endpoint == "/nucleotideMutations") { every { @@ -243,21 +262,50 @@ class LapisControllerTest(@Autowired val mockMvc: MockMvc) { } } - @ParameterizedTest(name = "POST {0} with invalid minProportion returns bad request") - @MethodSource("getMutationEndpointTypes") - fun `POST mutations with invalid minProportion returns bad request`( + @ParameterizedTest(name = "POST {0}") + @MethodSource("getInsertionEndpointTypes") + fun `POST insertions`( endpoint: String, ) { + setupInsertionMock(endpoint) + val request = post(endpoint) - .content("""{"country": "Switzerland", "minProportion": "this is not a float"}""") + .content("""{"country": "Switzerland"}""") .contentType(MediaType.APPLICATION_JSON) mockMvc.perform(request) - .andExpect(status().isBadRequest) - .andExpect(jsonPath("\$.error.title").value("Bad request")) - .andExpect( - jsonPath("\$.error.message").value("minProportion must be a number"), - ) + .andExpect(status().isOk) + .andExpect(jsonPath("\$.data[0].insertion").value("the insertion")) + .andExpect(jsonPath("\$.data[0].count").value(42)) + .andExpect(header().stringValues("Lapis-Data-Version", "1234")) + } + + @ParameterizedTest(name = "GET {0}") + @MethodSource("getInsertionEndpointTypes") + fun `GET insertions`( + endpoint: String, + ) { + setupInsertionMock(endpoint) + + val request = get("$endpoint?country=Switzerland") + + mockMvc.perform(request) + .andExpect(status().isOk) + .andExpect(jsonPath("\$.data[0].insertion").value("the insertion")) + .andExpect(jsonPath("\$.data[0].count").value(42)) + .andExpect(header().stringValues("Lapis-Data-Version", "1234")) + } + + private fun setupInsertionMock(endpoint: String) { + if (endpoint == "/nucleotideInsertions") { + every { + siloQueryModelMock.getNucleotideInsertions( + insertionRequest( + mapOf("country" to "Switzerland"), + ), + ) + } returns listOf(someNucleotideInsertion()) + } } private companion object { @@ -266,6 +314,12 @@ class LapisControllerTest(@Autowired val mockMvc: MockMvc) { Arguments.of("/nucleotideMutations"), Arguments.of("/aminoAcidMutations"), ) + + @JvmStatic + fun getInsertionEndpointTypes() = listOf( + Arguments.of("/nucleotideInsertions"), + Arguments.of("/aminoAcidInsertions") + ) } @Test @@ -349,6 +403,17 @@ class LapisControllerTest(@Autowired val mockMvc: MockMvc) { emptyList(), ) + private fun insertionRequest( + sequenceFilters: Map, + ) = InsertionsRequest( + sequenceFilters, + emptyList(), + emptyList(), + emptyList(), + emptyList(), + emptyList(), + ) + private fun mutationProportionsRequest(sequenceFilters: Map, minProportion: Double?) = MutationProportionsRequest( sequenceFilters, @@ -362,4 +427,5 @@ class LapisControllerTest(@Autowired val mockMvc: MockMvc) { private fun someNucleotideMutationProportion() = NucleotideMutationResponse("the mutation", 42, 0.5) private fun someAminoAcidMutationProportion() = AminoAcidMutationResponse("the mutation", 42, 0.5) + private fun someNucleotideInsertion() = NucleotideInsertionResponse("the insertion", 42) } diff --git a/lapis2/src/test/kotlin/org/genspectrum/lapis/model/SiloQueryModelTest.kt b/lapis2/src/test/kotlin/org/genspectrum/lapis/model/SiloQueryModelTest.kt index 42b695db..47f44246 100644 --- a/lapis2/src/test/kotlin/org/genspectrum/lapis/model/SiloQueryModelTest.kt +++ b/lapis2/src/test/kotlin/org/genspectrum/lapis/model/SiloQueryModelTest.kt @@ -5,11 +5,14 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify import org.genspectrum.lapis.request.CommonSequenceFilters +import org.genspectrum.lapis.request.InsertionsRequest import org.genspectrum.lapis.request.MutationProportionsRequest import org.genspectrum.lapis.request.SequenceFiltersRequestWithFields import org.genspectrum.lapis.response.AggregationData import org.genspectrum.lapis.response.AminoAcidMutationResponse +import org.genspectrum.lapis.response.InsertionData import org.genspectrum.lapis.response.MutationData +import org.genspectrum.lapis.response.NucleotideInsertionResponse import org.genspectrum.lapis.response.NucleotideMutationResponse import org.genspectrum.lapis.silo.SiloAction import org.genspectrum.lapis.silo.SiloClient @@ -114,4 +117,46 @@ class SiloQueryModelTest { assertThat(result, equalTo(listOf(AminoAcidMutationResponse("someName:A1234B", 1234, 0.1234)))) } + + @Test + fun `getNucleotideInsertions ignores the field sequenceName if it is called main`() { + every { siloClientMock.sendQuery(any>>()) } returns listOf( + InsertionData(42, "ABCD", 1234, "main"), + ) + every { siloFilterExpressionMapperMock.map(any()) } returns True + + val result = underTest.getNucleotideInsertions( + InsertionsRequest( + emptyMap(), + emptyList(), + emptyList(), + emptyList(), + emptyList(), + emptyList(), + ), + ) + + assertThat(result, equalTo(listOf(NucleotideInsertionResponse("ins_1234:ABCD", 42)))) + } + + @Test + fun `getNucleotideInsertions includes the field sequenceName if it is not called main`() { + every { siloClientMock.sendQuery(any>>()) } returns listOf( + InsertionData(42, "ABCD", 1234, "notMain"), + ) + every { siloFilterExpressionMapperMock.map(any()) } returns True + + val result = underTest.getNucleotideInsertions( + InsertionsRequest( + emptyMap(), + emptyList(), + emptyList(), + emptyList(), + emptyList(), + emptyList(), + ), + ) + + assertThat(result, equalTo(listOf(NucleotideInsertionResponse("ins_notMain:1234:ABCD", 42)))) + } } 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 ffddaac9..3b1ee3fc 100644 --- a/lapis2/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryTest.kt +++ b/lapis2/src/test/kotlin/org/genspectrum/lapis/silo/SiloQueryTest.kt @@ -170,6 +170,32 @@ class SiloQueryTest { } """, ), + Arguments.of( + SiloAction.nucleotideInsertions(), + """ + { + "type": "Insertions" + } + """, + ), + Arguments.of( + SiloAction.nucleotideInsertions( + listOf(OrderByField("field3", Order.ASCENDING), OrderByField("field4", Order.DESCENDING)), + 100, + 50, + ), + """ + { + "type": "Insertions", + "orderByFields": [ + {"field": "field3", "order": "ascending"}, + {"field": "field4", "order": "descending"} + ], + "limit": 100, + "offset": 50 + } + """, + ), ) @JvmStatic diff --git a/siloLapisTests/test/nucleotideInsertions.spec.ts b/siloLapisTests/test/nucleotideInsertions.spec.ts new file mode 100644 index 00000000..f349f2a3 --- /dev/null +++ b/siloLapisTests/test/nucleotideInsertions.spec.ts @@ -0,0 +1,147 @@ +import { expect } from 'chai'; +import { basePath, lapisClient } from './common'; + +describe('The /nucleotideInsertions endpoint', () => { + let someInsertion = 'ins_25701:CCC'; + + it('should return nucleotide insertions for Switzerland', async () => { + const result = await lapisClient.postNucleotideInsertions1({ + nucleotideInsertionsRequest: { country: 'Switzerland' }, + }); + + expect(result.data).to.have.length(4); + + const specificInsertion = result.data.find(insertionData => insertionData.insertion === someInsertion); + expect(specificInsertion?.count).to.equal(17); + }); + + it('should order by specified fields', async () => { + const ascendingOrderedResult = await lapisClient.postNucleotideInsertions1({ + nucleotideInsertionsRequest: { + orderBy: [{ field: 'count', type: 'ascending' }], + }, + }); + + expect(ascendingOrderedResult.data[0]).to.have.property('insertion', 'ins_5959:TAT'); + + const descendingOrderedResult = await lapisClient.postNucleotideInsertions1({ + nucleotideInsertionsRequest: { + orderBy: [{ field: 'count', type: 'descending' }], + }, + }); + + expect(descendingOrderedResult.data[0]).to.have.property('insertion', 'ins_25701:CCC'); + }); + + it('should apply limit and offset', async () => { + const resultWithLimit = await lapisClient.postNucleotideInsertions1({ + nucleotideInsertionsRequest: { + orderBy: [{ field: 'count', type: 'ascending' }], + limit: 2, + }, + }); + + expect(resultWithLimit.data).to.have.length(2); + expect(resultWithLimit.data[1]).to.have.property('insertion', 'ins_22339:GCTGGT'); + + const resultWithLimitAndOffset = await lapisClient.postNucleotideInsertions1({ + nucleotideInsertionsRequest: { + orderBy: [{ field: 'count', type: 'ascending' }], + limit: 2, + offset: 1, + }, + }); + + expect(resultWithLimitAndOffset.data).to.have.length(2); + expect(resultWithLimitAndOffset.data[0]).to.deep.equal(resultWithLimit.data[1]); + }); + + it('should correctly handle nucleotide insertion requests in GET requests', async () => { + const expectedFirstResultWithNucleotideInsertion = { + count: 1, + insertion: 'ins_25701:CCC', + }; + + const result = await lapisClient.postNucleotideInsertions1({ + nucleotideInsertionsRequest: { + nucleotideInsertions: ['ins_25701:CC?'], + }, + }); + + expect(result.data).to.have.length(1); + expect(result.data).to.contain.deep.equal(expectedFirstResultWithNucleotideInsertion); + }); + + it('should correctly handle amino acid insertion requests in GET requests', async () => { + const expectedFirstResultWithAminoAcidInsertion = { + count: 1, + insertion: 'ins_25701:CC?', + }; + + const result = await lapisClient.postNucleotideInsertions1({ + nucleotideInsertionsRequest: { + aminoAcidInsertions: ['ins_S:214:E?E'], + }, + }); + + expect(result.data).to.have.length(1); + expect(result.data).to.contain.deep.equal(expectedFirstResultWithAminoAcidInsertion); + }); + + it('should return the data as CSV', async () => { + const urlParams = new URLSearchParams({ + country: 'Switzerland', + dataFormat: 'csv', + }); + + const result = await fetch(basePath + '/nucleotideInsertions?' + urlParams.toString()); + const resultText = await result.text(); + + expect(resultText).to.contain( + String.raw` +insertion,count + `.trim() + ); + + expect(resultText).to.contain( + String.raw` +ins_5959:TAT,1 +ins_22339:GCTGGT,1 +ins_22204:CAGAA,1 +ins_25701:CCC,17 +`.trim() + ); + }); + + it('should return the data as TSV', async () => { + const urlParams = new URLSearchParams({ + country: 'Switzerland', + dataFormat: 'tsv', + }); + + const result = await fetch(basePath + '/nucleotideInsertions?' + urlParams.toString()); + const resultText = await result.text(); + + expect(resultText).to.contain( + String.raw` +insertion count + `.trim() + ); + + expect(resultText).to.contain( + String.raw` +ins_5959:TAT 1 +ins_22339:GCTGGT 1 +ins_22204:CAGAA 1 +ins_25701:CCC 17 + `.trim() + ); + }); + + it('should return the lapis data version in the response', async () => { + const result = await fetch(basePath + '/nucleotideInsertions'); + + expect(result.status).equals(200); + expect(result.headers.get('lapis-data-version')).to.match(/\d{10}/); + }); +});