Skip to content

Commit

Permalink
feat: also enable returning TSV #284
Browse files Browse the repository at this point in the history
  • Loading branch information
fengelniederhammer committed Aug 4, 2023
1 parent 151e18f commit 9597e28
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ interface CsvRecord {

@Component
class CsvWriter {
fun write(headers: Array<String>, data: List<CsvRecord>): String {
fun write(headers: Array<String>, data: List<CsvRecord>, 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())
Expand All @@ -32,3 +36,8 @@ fun DetailsData.asCsvRecord() = JsonValuesCsvRecord(this.values)
data class JsonValuesCsvRecord(val values: Collection<JsonNode>) : CsvRecord {
override fun asArray() = values.map { it.asText() }.toTypedArray()
}

enum class Delimiter(val value: Char) {
COMMA(','),
TAB('\t'),
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ 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
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() {

Expand All @@ -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 -> {}
Expand All @@ -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 -> {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, String>?,
@Parameter(description = DETAILS_FIELDS_DESCRIPTION)
@RequestParam
fields: List<String>?,
@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<OrderByField>?,
@Parameter(schema = Schema(ref = "#/components/schemas/$NUCLEOTIDE_MUTATIONS_SCHEMA"))
@RequestParam
nucleotideMutations: List<NucleotideMutation>?,
@Parameter(schema = Schema(ref = "#/components/schemas/$AMINO_ACID_MUTATIONS_SCHEMA"))
@RequestParam
aminoAcidMutations: List<AminoAcidMutation>?,
@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])
Expand All @@ -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)
Expand All @@ -327,7 +395,7 @@ class LapisController(
}

val headers = data[0].keys.toTypedArray<String>()
return csvWriter.write(headers, data.map { it.asCsvRecord() })
return csvWriter.write(headers, data.map { it.asCsvRecord() }, delimiter)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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"}""")
Expand All @@ -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"}""")
Expand All @@ -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(
Expand Down
20 changes: 20 additions & 0 deletions siloLapisTests/test/details.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
});
});

0 comments on commit 9597e28

Please sign in to comment.