diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/CsvWriter.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/CsvWriter.kt index 2ddc5b64..47b3afd3 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/CsvWriter.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/CsvWriter.kt @@ -13,11 +13,15 @@ interface CsvRecord { @Component class CsvWriter { - fun write(headers: Array, data: List): String { + fun write(headers: Array, data: List, delimiter: Delimiter): String { val stringWriter = StringWriter() CSVPrinter( stringWriter, - CSVFormat.DEFAULT.builder().setRecordSeparator("\n").setHeader(*headers).build(), + CSVFormat.DEFAULT.builder() + .setRecordSeparator("\n") + .setDelimiter(delimiter.value) + .setHeader(*headers) + .build(), ).use { for (datum in data) { it.printRecord(*datum.asArray()) @@ -32,3 +36,8 @@ fun DetailsData.asCsvRecord() = JsonValuesCsvRecord(this.values) data class JsonValuesCsvRecord(val values: Collection) : CsvRecord { override fun asArray() = values.map { it.asText() }.toTypedArray() } + +enum class Delimiter(val value: Char) { + COMMA(','), + TAB('\t'), +} diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/DataFormatParameterFilter.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/DataFormatParameterFilter.kt index 98516f01..07589405 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/DataFormatParameterFilter.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/DataFormatParameterFilter.kt @@ -7,6 +7,7 @@ import jakarta.servlet.http.HttpServletRequestWrapper import jakarta.servlet.http.HttpServletResponse import mu.KotlinLogging import org.genspectrum.lapis.util.CachedBodyHttpServletRequest +import org.springframework.http.MediaType import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter import java.util.Collections @@ -14,6 +15,9 @@ import java.util.Enumeration private val log = KotlinLogging.logger {} +const val TEXT_CSV_HEADER = "text/csv" +const val TEXT_TSV_HEADER = "text/tab-separated-values" + @Component class DataFormatParameterFilter(val objectMapper: ObjectMapper) : OncePerRequestFilter() { @@ -36,8 +40,20 @@ class AcceptHeaderModifyingRequestWrapper( if (name.equals("Accept", ignoreCase = true)) { when (reReadableRequest.getRequestFields()[FORMAT_PROPERTY]?.textValue()?.uppercase()) { "CSV" -> { - log.debug { "Overwriting Accept header to text/csv due to format property" } - return "text/csv" + log.debug { "Overwriting Accept header to $TEXT_CSV_HEADER due to format property" } + return TEXT_CSV_HEADER + } + + "TSV" -> { + log.debug { "Overwriting Accept header to $TEXT_TSV_HEADER due to format property" } + return TEXT_TSV_HEADER + } + + "JSON" -> { + log.debug { + "Overwriting Accept header to ${MediaType.APPLICATION_JSON_VALUE} due to format property" + } + return MediaType.APPLICATION_JSON_VALUE } else -> {} @@ -51,8 +67,20 @@ class AcceptHeaderModifyingRequestWrapper( if (name.equals("Accept", ignoreCase = true)) { when (reReadableRequest.getRequestFields()[FORMAT_PROPERTY]?.textValue()?.uppercase()) { "CSV" -> { - log.debug { "Overwriting Accept header to text/csv due to format property" } - return Collections.enumeration(listOf("text/csv")) + log.debug { "Overwriting Accept header to $TEXT_CSV_HEADER due to format property" } + return Collections.enumeration(listOf(TEXT_CSV_HEADER)) + } + + "TSV" -> { + log.debug { "Overwriting Accept header to $TEXT_TSV_HEADER due to format property" } + return Collections.enumeration(listOf(TEXT_TSV_HEADER)) + } + + "JSON" -> { + log.debug { + "Overwriting Accept header to ${MediaType.APPLICATION_JSON_VALUE} due to format property" + } + return Collections.enumeration(listOf(MediaType.APPLICATION_JSON_VALUE)) } else -> {} 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 d31218b2..6b667661 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt @@ -237,10 +237,11 @@ class LapisController( return siloQueryModel.getDetails(request) } - @GetMapping("/details", produces = ["text/csv"]) + @GetMapping("/details", produces = [TEXT_CSV_HEADER]) @Operation( description = DETAILS_ENDPOINT_DESCRIPTION, operationId = "getDetailsAsCsv", + responses = [ApiResponse(responseCode = "200")], ) fun getDetailsAsCsv( @SequenceFilters @@ -285,7 +286,59 @@ class LapisController( offset, ) - return getDetailsAsCsv(request) + return getDetailsAsCsv(request, Delimiter.COMMA) + } + + @GetMapping("/details", produces = [TEXT_TSV_HEADER]) + @Operation( + description = DETAILS_ENDPOINT_DESCRIPTION, + operationId = "getDetailsAsTsv", + responses = [ApiResponse(responseCode = "200")], + ) + fun getDetailsAsTsv( + @SequenceFilters + @RequestParam + sequenceFilters: Map?, + @Parameter(description = DETAILS_FIELDS_DESCRIPTION) + @RequestParam + fields: List?, + @Parameter( + schema = Schema(ref = "#/components/schemas/$ORDER_BY_FIELDS_SCHEMA"), + description = "The fields of the response to order by." + + " Fields specified here must also be present in \"fields\".", + ) + @RequestParam + orderBy: List?, + @Parameter(schema = Schema(ref = "#/components/schemas/$NUCLEOTIDE_MUTATIONS_SCHEMA")) + @RequestParam + nucleotideMutations: List?, + @Parameter(schema = Schema(ref = "#/components/schemas/$AMINO_ACID_MUTATIONS_SCHEMA")) + @RequestParam + aminoAcidMutations: 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, + ): String { + val request = SequenceFiltersRequestWithFields( + sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), + nucleotideMutations ?: emptyList(), + aminoAcidMutations ?: emptyList(), + fields ?: emptyList(), + orderBy ?: emptyList(), + limit, + offset, + ) + + return getDetailsAsCsv(request, Delimiter.TAB) } @PostMapping("/details", produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -304,20 +357,35 @@ class LapisController( return siloQueryModel.getDetails(request) } - @PostMapping("/details", produces = ["text/csv"]) + @PostMapping("/details", produces = [TEXT_CSV_HEADER]) @Operation( description = DETAILS_ENDPOINT_DESCRIPTION, operationId = "postDetailsAsCsv", + responses = [ApiResponse(responseCode = "200")], ) fun postDetailsAsCsv( @Parameter(schema = Schema(ref = "#/components/schemas/$DETAILS_REQUEST_SCHEMA")) @RequestBody request: SequenceFiltersRequestWithFields, ): String { - return getDetailsAsCsv(request) + return getDetailsAsCsv(request, Delimiter.COMMA) + } + + @PostMapping("/details", produces = [TEXT_TSV_HEADER]) + @Operation( + description = DETAILS_ENDPOINT_DESCRIPTION, + operationId = "postDetailsAsTsv", + responses = [ApiResponse(responseCode = "200")], + ) + fun postDetailsAsTsv( + @Parameter(schema = Schema(ref = "#/components/schemas/$DETAILS_REQUEST_SCHEMA")) + @RequestBody + request: SequenceFiltersRequestWithFields, + ): String { + return getDetailsAsCsv(request, Delimiter.TAB) } - private fun getDetailsAsCsv(request: SequenceFiltersRequestWithFields): String { + private fun getDetailsAsCsv(request: SequenceFiltersRequestWithFields, delimiter: Delimiter): String { requestContext.filter = request val data = siloQueryModel.getDetails(request) @@ -327,7 +395,7 @@ class LapisController( } val headers = data[0].keys.toTypedArray() - return csvWriter.write(headers, data.map { it.asCsvRecord() }) + return csvWriter.write(headers, data.map { it.asCsvRecord() }, delimiter) } } 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 aa2817c6..b52a20fd 100644 --- a/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerCsvTest.kt +++ b/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerCsvTest.kt @@ -26,6 +26,23 @@ class LapisControllerCsvTest(@Autowired val mockMvc: MockMvc) { @MockkBean lateinit var siloQueryModelMock: SiloQueryModel + val listOfMetadata = listOf( + mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42)), + 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() + @Test fun `GET empty details return empty CSV`() { every { @@ -61,27 +78,40 @@ class LapisControllerCsvTest(@Autowired val mockMvc: MockMvc) { ) } + @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 listOf( - mapOf("country" to TextNode("Switzerland"), "age" to IntNode(42)), - mapOf("country" to TextNode("Switzerland"), "age" to IntNode(43)), - ) + } 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( - """ - country,age - Switzerland,42 - Switzerland,43 - """.trimIndent(), - ), - ) + .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 @@ -105,7 +135,7 @@ class LapisControllerCsvTest(@Autowired val mockMvc: MockMvc) { fun `POST details as CSV with accept header`() { every { siloQueryModelMock.getDetails(sequenceFiltersRequestWithFields(mapOf("country" to "Switzerland"))) - } returns emptyList() + } returns listOfMetadata val request = post("/details") .content("""{"country": "Switzerland"}""") @@ -115,14 +145,31 @@ 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(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 emptyList() + } returns listOfMetadata val request = post("/details") .content("""{"country": "Switzerland", "dataFormat": "csv"}""") @@ -131,7 +178,23 @@ 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(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( diff --git a/siloLapisTests/test/details.spec.ts b/siloLapisTests/test/details.spec.ts index 4d1f119c..ec9adddd 100644 --- a/siloLapisTests/test/details.spec.ts +++ b/siloLapisTests/test/details.spec.ts @@ -109,4 +109,24 @@ Solothurn,EPI_ISL_1002052,B.1 `.trim() ); }); + + it('should return the data as TSV', async () => { + const urlParams = new URLSearchParams({ + fields: 'gisaid_epi_isl,pango_lineage,division', + orderBy: 'gisaid_epi_isl', + limit: '3', + dataFormat: 'tsv', + }); + + const result = await fetch(basePath + '/details?' + urlParams.toString()); + + expect(await result.text()).to.be.equal( + String.raw` +division gisaid_epi_isl pango_lineage +Vaud EPI_ISL_1001493 B.1.177.44 +Bern EPI_ISL_1001920 B.1.177 +Solothurn EPI_ISL_1002052 B.1 + `.trim() + ); + }); });