From 0f9fad1e3c5b203cc9cc1207a0aff99bb5975147 Mon Sep 17 00:00:00 2001 From: Fabian Engelniederhammer Date: Fri, 2 Feb 2024 15:50:24 +0100 Subject: [PATCH] feat: implement the header parameter "headers=false" to disable the header in the returned CSV/TSV #624 --- .../controller/ControllerDescriptions.kt | 6 +- .../genspectrum/lapis/controller/CsvWriter.kt | 8 +- .../controller/DataFormatParameterFilter.kt | 58 +++--- .../lapis/controller/LapisController.kt | 115 +++++++++-- .../genspectrum/lapis/openApi/OpenApiDocs.kt | 5 +- .../controller/LapisControllerCsvTest.kt | 188 ++++++++---------- siloLapisTests/test/details.spec.ts | 21 +- 7 files changed, 237 insertions(+), 164 deletions(-) diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt index a3265ad4..172e30fb 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/ControllerDescriptions.kt @@ -50,8 +50,10 @@ const val OFFSET_DESCRIPTION = """The offset of the first entry to return in the response. This is useful for pagination in combination with \"limit\".""" const val FORMAT_DESCRIPTION = - """The data format of the response. Alternatively, the data format can be specified by setting the - \"Accept\"-header. When both are specified, this parameter takes precedence.""" + """The data format of the response. + Alternatively, the data format can be specified by setting the \"Accept\"-header. + You can include the parameter to return the CSV/TSV without headers: "$TEXT_CSV_WITHOUT_HEADERS_HEADER". + When both are specified, this parameter takes precedence.""" private const val MAYBE_DESCRIPTION = """ A mutation can be wrapped in a maybe expression "MAYBE(\)" 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 62d71784..0817e5c8 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/CsvWriter.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/CsvWriter.kt @@ -14,7 +14,7 @@ interface CsvRecord { @Component class CsvWriter { fun write( - headers: Array, + headers: Array?, data: List, delimiter: Delimiter, ): String { @@ -24,7 +24,11 @@ class CsvWriter { CSVFormat.DEFAULT.builder() .setRecordSeparator("\n") .setDelimiter(delimiter.value) - .setHeader(*headers) + .also { + when { + headers != null -> it.setHeader(*headers) + } + } .build(), ).use { for (datum in data) { 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 50602ce3..43f01e1e 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/DataFormatParameterFilter.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/DataFormatParameterFilter.kt @@ -16,11 +16,21 @@ import java.util.Enumeration private val log = KotlinLogging.logger {} +const val HEADERS_ACCEPT_HEADER_PARAMETER = "headers" + const val TEXT_CSV_HEADER = "text/csv" +const val TEXT_CSV_WITHOUT_HEADERS_HEADER = "text/csv;$HEADERS_ACCEPT_HEADER_PARAMETER=false" const val TEXT_TSV_HEADER = "text/tab-separated-values" const val DATA_FORMAT_FILTER_ORDER = 0 +object DataFormat { + const val JSON = "JSON" + const val CSV = "CSV" + const val CSV_WITHOUT_HEADERS = "CSV-WITHOUT-HEADERS" + const val TSV = "TSV" +} + @Component @Order(DATA_FORMAT_FILTER_ORDER) class DataFormatParameterFilter(val objectMapper: ObjectMapper) : OncePerRequestFilter() { @@ -41,23 +51,10 @@ class AcceptHeaderModifyingRequestWrapper( override fun getHeader(name: String): String? { if (name.equals("Accept", ignoreCase = true)) { when (reReadableRequest.getStringField(FORMAT_PROPERTY)?.uppercase()) { - "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 - } - + DataFormat.CSV -> overwriteWith(TEXT_CSV_HEADER) + DataFormat.CSV_WITHOUT_HEADERS -> overwriteWith(TEXT_CSV_WITHOUT_HEADERS_HEADER) + DataFormat.TSV -> overwriteWith(TEXT_TSV_HEADER) + DataFormat.JSON -> overwriteWith(MediaType.APPLICATION_JSON_VALUE) else -> {} } } @@ -68,27 +65,24 @@ class AcceptHeaderModifyingRequestWrapper( override fun getHeaders(name: String): Enumeration? { if (name.equals("Accept", ignoreCase = true)) { when (reReadableRequest.getStringField(FORMAT_PROPERTY)?.uppercase()) { - "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)) + DataFormat.CSV -> return Collections.enumeration(listOf(overwriteWith(TEXT_CSV_HEADER))) + DataFormat.CSV_WITHOUT_HEADERS -> { + return Collections.enumeration(listOf(overwriteWith(TEXT_CSV_WITHOUT_HEADERS_HEADER))) } + DataFormat.TSV -> return Collections.enumeration(listOf(overwriteWith(TEXT_TSV_HEADER))) + DataFormat.JSON -> return Collections.enumeration( + listOf(overwriteWith(MediaType.APPLICATION_JSON_VALUE)), + ) else -> {} } } return super.getHeaders(name) } + + private fun overwriteWith(value: String): String { + log.debug { "Overwriting Accept header to $value due to format property" } + return value + } } 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 a43e62c6..bd8973e1 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/controller/LapisController.kt @@ -56,11 +56,13 @@ import org.genspectrum.lapis.response.DetailsData import org.genspectrum.lapis.response.NucleotideInsertionResponse import org.genspectrum.lapis.response.NucleotideMutationResponse import org.genspectrum.lapis.silo.SequenceType +import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -160,6 +162,7 @@ class LapisController( @AminoAcidInsertions @RequestParam aminoAcidInsertions: List?, + @RequestHeader httpHeaders: HttpHeaders, ): String { val request = SequenceFiltersRequestWithFields( sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), @@ -173,7 +176,7 @@ class LapisController( offset, ) - return getResponseAsCsv(request, COMMA, siloQueryModel::getAggregated) + return getResponseAsCsv(request, httpHeaders.accept, COMMA, siloQueryModel::getAggregated) } @GetMapping(AGGREGATED_ROUTE, produces = [TEXT_TSV_HEADER]) @@ -213,6 +216,7 @@ class LapisController( @AminoAcidInsertions @RequestParam aminoAcidInsertions: List?, + @RequestHeader httpHeaders: HttpHeaders, ): String { val request = SequenceFiltersRequestWithFields( sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), @@ -226,7 +230,7 @@ class LapisController( offset, ) - return getResponseAsCsv(request, TAB, siloQueryModel::getAggregated) + return getResponseAsCsv(request, httpHeaders.accept, TAB, siloQueryModel::getAggregated) } @PostMapping(AGGREGATED_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -254,8 +258,9 @@ class LapisController( @Parameter(schema = Schema(ref = "#/components/schemas/$AGGREGATED_REQUEST_SCHEMA")) @RequestBody request: SequenceFiltersRequestWithFields, + @RequestHeader httpHeaders: HttpHeaders, ): String { - return getResponseAsCsv(request, COMMA, siloQueryModel::getAggregated) + return getResponseAsCsv(request, httpHeaders.accept, COMMA, siloQueryModel::getAggregated) } @PostMapping(AGGREGATED_ROUTE, produces = [TEXT_TSV_HEADER]) @@ -268,8 +273,9 @@ class LapisController( @Parameter(schema = Schema(ref = "#/components/schemas/$AGGREGATED_REQUEST_SCHEMA")) @RequestBody request: SequenceFiltersRequestWithFields, + @RequestHeader httpHeaders: HttpHeaders, ): String { - return getResponseAsCsv(request, TAB, siloQueryModel::getAggregated) + return getResponseAsCsv(request, httpHeaders.accept, TAB, siloQueryModel::getAggregated) } @GetMapping(NUCLEOTIDE_MUTATIONS_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -355,6 +361,7 @@ class LapisController( @AminoAcidInsertions @RequestParam aminoAcidInsertions: List?, + @RequestHeader httpHeaders: HttpHeaders, ): String { val request = MutationProportionsRequest( sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), @@ -369,7 +376,12 @@ class LapisController( ) requestContext.filter = request - return getResponseAsCsv(request, COMMA, siloQueryModel::computeNucleotideMutationProportions) + return getResponseAsCsv( + request, + httpHeaders.accept, + COMMA, + siloQueryModel::computeNucleotideMutationProportions, + ) } @GetMapping(NUCLEOTIDE_MUTATIONS_ROUTE, produces = [TEXT_TSV_HEADER]) @@ -404,6 +416,7 @@ class LapisController( @AminoAcidInsertions @RequestParam aminoAcidInsertions: List?, + @RequestHeader httpHeaders: HttpHeaders, ): String { val request = MutationProportionsRequest( sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), @@ -418,7 +431,7 @@ class LapisController( ) requestContext.filter = request - return getResponseAsCsv(request, TAB, siloQueryModel::computeNucleotideMutationProportions) + return getResponseAsCsv(request, httpHeaders.accept, TAB, siloQueryModel::computeNucleotideMutationProportions) } @PostMapping(NUCLEOTIDE_MUTATIONS_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -447,8 +460,14 @@ class LapisController( @Parameter(schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_MIN_PROPORTION")) @RequestBody mutationProportionsRequest: MutationProportionsRequest, + @RequestHeader httpHeaders: HttpHeaders, ): String { - return getResponseAsCsv(mutationProportionsRequest, COMMA, siloQueryModel::computeNucleotideMutationProportions) + return getResponseAsCsv( + mutationProportionsRequest, + httpHeaders.accept, + COMMA, + siloQueryModel::computeNucleotideMutationProportions, + ) } @PostMapping(NUCLEOTIDE_MUTATIONS_ROUTE, produces = [TEXT_TSV_HEADER]) @@ -461,8 +480,14 @@ class LapisController( @Parameter(schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_MIN_PROPORTION")) @RequestBody mutationProportionsRequest: MutationProportionsRequest, + @RequestHeader httpHeaders: HttpHeaders, ): String { - return getResponseAsCsv(mutationProportionsRequest, TAB, siloQueryModel::computeNucleotideMutationProportions) + return getResponseAsCsv( + mutationProportionsRequest, + httpHeaders.accept, + TAB, + siloQueryModel::computeNucleotideMutationProportions, + ) } @GetMapping(AMINO_ACID_MUTATIONS_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -544,6 +569,7 @@ class LapisController( @AminoAcidInsertions @RequestParam aminoAcidInsertions: List?, + @RequestHeader httpHeaders: HttpHeaders, ): String { val mutationProportionsRequest = MutationProportionsRequest( sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), @@ -558,7 +584,12 @@ class LapisController( ) requestContext.filter = mutationProportionsRequest - return getResponseAsCsv(mutationProportionsRequest, COMMA, siloQueryModel::computeAminoAcidMutationProportions) + return getResponseAsCsv( + mutationProportionsRequest, + httpHeaders.accept, + COMMA, + siloQueryModel::computeAminoAcidMutationProportions, + ) } @GetMapping(AMINO_ACID_MUTATIONS_ROUTE, produces = [TEXT_TSV_HEADER]) @@ -593,6 +624,7 @@ class LapisController( @AminoAcidInsertions @RequestParam aminoAcidInsertions: List?, + @RequestHeader httpHeaders: HttpHeaders, ): String { val mutationProportionsRequest = MutationProportionsRequest( sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), @@ -609,6 +641,7 @@ class LapisController( return getResponseAsCsv( mutationProportionsRequest, + httpHeaders.accept, TAB, siloQueryModel::computeAminoAcidMutationProportions, ) @@ -640,11 +673,13 @@ class LapisController( @Parameter(schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_MIN_PROPORTION")) @RequestBody mutationProportionsRequest: MutationProportionsRequest, + @RequestHeader httpHeaders: HttpHeaders, ): String { requestContext.filter = mutationProportionsRequest return getResponseAsCsv( mutationProportionsRequest, + httpHeaders.accept, COMMA, siloQueryModel::computeAminoAcidMutationProportions, ) @@ -660,11 +695,13 @@ class LapisController( @Parameter(schema = Schema(ref = "#/components/schemas/$REQUEST_SCHEMA_WITH_MIN_PROPORTION")) @RequestBody mutationProportionsRequest: MutationProportionsRequest, + @RequestHeader httpHeaders: HttpHeaders, ): String { requestContext.filter = mutationProportionsRequest return getResponseAsCsv( mutationProportionsRequest, + httpHeaders.accept, TAB, siloQueryModel::computeAminoAcidMutationProportions, ) @@ -753,6 +790,7 @@ class LapisController( @AminoAcidInsertions @RequestParam aminoAcidInsertions: List?, + @RequestHeader httpHeaders: HttpHeaders, ): String { val request = SequenceFiltersRequestWithFields( sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), @@ -766,7 +804,7 @@ class LapisController( offset, ) requestContext.filter = request - return getResponseAsCsv(request, COMMA, siloQueryModel::getDetails) + return getResponseAsCsv(request, httpHeaders.accept, COMMA, siloQueryModel::getDetails) } @GetMapping(DETAILS_ROUTE, produces = [TEXT_TSV_HEADER]) @@ -803,6 +841,7 @@ class LapisController( @AminoAcidInsertions @RequestParam aminoAcidInsertions: List?, + @RequestHeader httpHeaders: HttpHeaders, ): String { val request = SequenceFiltersRequestWithFields( sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), @@ -816,7 +855,7 @@ class LapisController( offset, ) - return getResponseAsCsv(request, TAB, siloQueryModel::getDetails) + return getResponseAsCsv(request, httpHeaders.accept, TAB, siloQueryModel::getDetails) } @PostMapping(DETAILS_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -844,8 +883,9 @@ class LapisController( @Parameter(schema = Schema(ref = "#/components/schemas/$DETAILS_REQUEST_SCHEMA")) @RequestBody request: SequenceFiltersRequestWithFields, + @RequestHeader httpHeaders: HttpHeaders, ): String { - return getResponseAsCsv(request, COMMA, siloQueryModel::getDetails) + return getResponseAsCsv(request, httpHeaders.accept, COMMA, siloQueryModel::getDetails) } @PostMapping(DETAILS_ROUTE, produces = [TEXT_TSV_HEADER]) @@ -858,8 +898,9 @@ class LapisController( @Parameter(schema = Schema(ref = "#/components/schemas/$DETAILS_REQUEST_SCHEMA")) @RequestBody request: SequenceFiltersRequestWithFields, + @RequestHeader httpHeaders: HttpHeaders, ): String { - return getResponseAsCsv(request, TAB, siloQueryModel::getDetails) + return getResponseAsCsv(request, httpHeaders.accept, TAB, siloQueryModel::getDetails) } @GetMapping(NUCLEOTIDE_INSERTIONS_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -944,6 +985,7 @@ class LapisController( @DataFormat @RequestParam dataFormat: String? = null, + @RequestHeader httpHeaders: HttpHeaders, ): String { val request = SequenceFiltersRequest( sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), @@ -958,7 +1000,7 @@ class LapisController( requestContext.filter = request - return getResponseAsCsv(request, COMMA, siloQueryModel::getNucleotideInsertions) + return getResponseAsCsv(request, httpHeaders.accept, COMMA, siloQueryModel::getNucleotideInsertions) } @GetMapping(NUCLEOTIDE_INSERTIONS_ROUTE, produces = [TEXT_TSV_HEADER]) @@ -995,6 +1037,7 @@ class LapisController( @DataFormat @RequestParam dataFormat: String? = null, + @RequestHeader httpHeaders: HttpHeaders, ): String { val request = SequenceFiltersRequest( sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), @@ -1009,7 +1052,7 @@ class LapisController( requestContext.filter = request - return getResponseAsCsv(request, TAB, siloQueryModel::getNucleotideInsertions) + return getResponseAsCsv(request, httpHeaders.accept, TAB, siloQueryModel::getNucleotideInsertions) } @PostMapping(NUCLEOTIDE_INSERTIONS_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -1038,10 +1081,11 @@ class LapisController( @Parameter(schema = Schema(ref = "#/components/schemas/$INSERTIONS_REQUEST_SCHEMA")) @RequestBody request: SequenceFiltersRequest, + @RequestHeader httpHeaders: HttpHeaders, ): String { requestContext.filter = request - return getResponseAsCsv(request, COMMA, siloQueryModel::getNucleotideInsertions) + return getResponseAsCsv(request, httpHeaders.accept, COMMA, siloQueryModel::getNucleotideInsertions) } @PostMapping(NUCLEOTIDE_INSERTIONS_ROUTE, produces = [TEXT_TSV_HEADER]) @@ -1054,10 +1098,11 @@ class LapisController( @Parameter(schema = Schema(ref = "#/components/schemas/$INSERTIONS_REQUEST_SCHEMA")) @RequestBody request: SequenceFiltersRequest, + @RequestHeader httpHeaders: HttpHeaders, ): String { requestContext.filter = request - return getResponseAsCsv(request, TAB, siloQueryModel::getNucleotideInsertions) + return getResponseAsCsv(request, httpHeaders.accept, TAB, siloQueryModel::getNucleotideInsertions) } @GetMapping(AMINO_ACID_INSERTIONS_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -1142,6 +1187,7 @@ class LapisController( @DataFormat @RequestParam dataFormat: String? = null, + @RequestHeader httpHeaders: HttpHeaders, ): String { val request = SequenceFiltersRequest( sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), @@ -1156,7 +1202,7 @@ class LapisController( requestContext.filter = request - return getResponseAsCsv(request, COMMA, siloQueryModel::getAminoAcidInsertions) + return getResponseAsCsv(request, httpHeaders.accept, COMMA, siloQueryModel::getAminoAcidInsertions) } @GetMapping(AMINO_ACID_INSERTIONS_ROUTE, produces = [TEXT_TSV_HEADER]) @@ -1193,6 +1239,7 @@ class LapisController( @DataFormat @RequestParam dataFormat: String? = null, + @RequestHeader httpHeaders: HttpHeaders, ): String { val request = SequenceFiltersRequest( sequenceFilters?.filter { !SPECIAL_REQUEST_PROPERTIES.contains(it.key) } ?: emptyMap(), @@ -1207,7 +1254,7 @@ class LapisController( requestContext.filter = request - return getResponseAsCsv(request, TAB, siloQueryModel::getAminoAcidInsertions) + return getResponseAsCsv(request, httpHeaders.accept, TAB, siloQueryModel::getAminoAcidInsertions) } @PostMapping(AMINO_ACID_INSERTIONS_ROUTE, produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -1236,10 +1283,11 @@ class LapisController( @Parameter(schema = Schema(ref = "#/components/schemas/$INSERTIONS_REQUEST_SCHEMA")) @RequestBody request: SequenceFiltersRequest, + @RequestHeader httpHeaders: HttpHeaders, ): String { requestContext.filter = request - return getResponseAsCsv(request, COMMA, siloQueryModel::getAminoAcidInsertions) + return getResponseAsCsv(request, httpHeaders.accept, COMMA, siloQueryModel::getAminoAcidInsertions) } @PostMapping(AMINO_ACID_INSERTIONS_ROUTE, produces = [TEXT_TSV_HEADER]) @@ -1252,10 +1300,11 @@ class LapisController( @Parameter(schema = Schema(ref = "#/components/schemas/$INSERTIONS_REQUEST_SCHEMA")) @RequestBody request: SequenceFiltersRequest, + @RequestHeader httpHeaders: HttpHeaders, ): String { requestContext.filter = request - return getResponseAsCsv(request, TAB, siloQueryModel::getAminoAcidInsertions) + return getResponseAsCsv(request, httpHeaders.accept, TAB, siloQueryModel::getAminoAcidInsertions) } @GetMapping("$ALIGNED_AMINO_ACID_SEQUENCES_ROUTE/{gene}", produces = ["text/x-fasta"]) @@ -1322,6 +1371,7 @@ class LapisController( private fun getResponseAsCsv( request: Request, + acceptHeader: List, delimiter: Delimiter, getResponse: (request: Request) -> List, ): String { @@ -1332,7 +1382,28 @@ class LapisController( return "" } - val headers = data[0].getHeader() + val headersParameter = getHeadersParameter(delimiter, acceptHeader) + val dontIncludeHeaders = headersParameter == "false" + + val headers = when (dontIncludeHeaders) { + true -> null + false -> data[0].getHeader() + } return csvWriter.write(headers, data, delimiter) } + + private fun getHeadersParameter( + delimiter: Delimiter, + acceptHeader: List, + ): String? { + val targetMediaType = MediaType.valueOf( + when (delimiter) { + COMMA -> TEXT_CSV_HEADER + TAB -> TEXT_TSV_HEADER + }, + ) + return acceptHeader.find { it.includes(targetMediaType) } + ?.parameters + ?.get(HEADERS_ACCEPT_HEADER_PARAMETER) + } } diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt index 477144ad..1f3f29db 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/openApi/OpenApiDocs.kt @@ -21,6 +21,7 @@ import org.genspectrum.lapis.controller.AMINO_ACID_MUTATIONS_PROPERTY import org.genspectrum.lapis.controller.AMINO_ACID_MUTATION_DESCRIPTION import org.genspectrum.lapis.controller.DETAILS_FIELDS_DESCRIPTION import org.genspectrum.lapis.controller.DOWNLOAD_AS_FILE_PROPERTY +import org.genspectrum.lapis.controller.DataFormat import org.genspectrum.lapis.controller.FIELDS_PROPERTY import org.genspectrum.lapis.controller.FORMAT_DESCRIPTION import org.genspectrum.lapis.controller.FORMAT_PROPERTY @@ -488,8 +489,8 @@ private fun formatSchema() = .description( FORMAT_DESCRIPTION, ) - ._enum(listOf("csv", "tsv", "json")) - ._default("json") + ._enum(listOf(DataFormat.JSON, DataFormat.CSV, DataFormat.CSV_WITHOUT_HEADERS, DataFormat.TSV)) + ._default(DataFormat.JSON) private fun fieldsArray( databaseConfig: List, 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 0b758d88..b4e8a5cf 100644 --- a/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerCsvTest.kt +++ b/lapis2/src/test/kotlin/org/genspectrum/lapis/controller/LapisControllerCsvTest.kt @@ -16,13 +16,13 @@ import org.genspectrum.lapis.response.NucleotideInsertionResponse import org.genspectrum.lapis.response.NucleotideMutationResponse import org.junit.jupiter.api.BeforeEach 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 import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath @@ -100,110 +100,37 @@ class LapisControllerCsvTest( .andExpect(content().string("")) } - @ParameterizedTest(name = "GET {0} returns as CSV with accept header") - @MethodSource("getEndpoints") - fun `GET returns as CSV with accept header`(endpoint: String) { - mockEndpointReturnData(endpoint) + @ParameterizedTest(name = "{0} returns data as CSV") + @MethodSource("getCsvRequests") + fun `request returns data as CSV`(requestsScenario: RequestScenario) { + mockEndpointReturnData(requestsScenario.endpoint) - mockMvc.perform(getSample("$endpoint?country=Switzerland").header("Accept", "text/csv")) + mockMvc.perform(requestsScenario.request) .andExpect(status().isOk) .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(returnedCsvData(endpoint))) + .andExpect(content().string(returnedCsvData(requestsScenario.endpoint))) } - @ParameterizedTest(name = "POST {0} returns as CSV with accept header") - @MethodSource("getEndpoints") - fun `POST returns as CSV with accept header`(endpoint: String) { - mockEndpointReturnData(endpoint) + @ParameterizedTest(name = "{0} returns data as CSV without headers") + @MethodSource("getCsvWithoutHeadersRequests") + fun `request returns data as CSV without headers`(requestsScenario: RequestScenario) { + mockEndpointReturnData(requestsScenario.endpoint) - val request = postSample(endpoint) - .content("""{"country": "Switzerland"}""") - .contentType(MediaType.APPLICATION_JSON) - .accept("text/csv") - - mockMvc.perform(request) + mockMvc.perform(requestsScenario.request) .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(returnedCsvData(endpoint))) + .andExpect(header().string("Content-Type", "text/csv;headers=false;charset=UTF-8")) + .andExpect(content().string(returnedCsvWithoutHeadersData(requestsScenario.endpoint))) } - @ParameterizedTest(name = "GET {0} returns as CSV with request parameter") - @MethodSource("getEndpoints") - fun `GET returns as CSV with request parameter`(endpoint: String) { - mockEndpointReturnData(endpoint) + @ParameterizedTest(name = "{0} returns data as TSV") + @MethodSource("getTsvRequests") + fun `request returns data as TSV`(requestsScenario: RequestScenario) { + mockEndpointReturnData(requestsScenario.endpoint) - mockMvc.perform(getSample("$endpoint?country=Switzerland&dataFormat=csv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/csv;charset=UTF-8")) - .andExpect(content().string(returnedCsvData(endpoint))) - } - - @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 = postSample(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(returnedCsvData(endpoint))) - } - - @ParameterizedTest(name = "GET {0} returns as TSV with accept header") - @MethodSource("getEndpoints") - fun `GET returns as TSV with accept header`(endpoint: String) { - mockEndpointReturnData(endpoint) - - mockMvc.perform(getSample("$endpoint?country=Switzerland").header("Accept", "text/tab-separated-values")) + mockMvc.perform(requestsScenario.request) .andExpect(status().isOk) .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(returnedTsvData(endpoint))) - } - - @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 = postSample(endpoint) - .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(returnedTsvData(endpoint))) - } - - @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(getSample("$endpoint?country=Switzerland&dataFormat=tsv")) - .andExpect(status().isOk) - .andExpect(header().string("Content-Type", "text/tab-separated-values;charset=UTF-8")) - .andExpect(content().string(returnedTsvData(endpoint))) - } - - @ParameterizedTest(name = "POST {0} returns as TSV with request parameter") - @MethodSource("getEndpoints") - fun `POST returns as TSV with request parameter`(endpoint: String) { - mockEndpointReturnData(endpoint) - - val request = postSample(endpoint) - .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(returnedTsvData(endpoint))) + .andExpect(content().string(returnedTsvData(requestsScenario.endpoint))) } fun mockEndpointReturnEmptyList(endpoint: String) = @@ -295,6 +222,12 @@ class LapisControllerCsvTest( else -> throw IllegalArgumentException("Unknown endpoint: $endpoint") } + fun returnedCsvWithoutHeadersData(endpoint: String) = + returnedCsvData(endpoint) + .lines() + .drop(1) + .joinToString("\n") + fun returnedTsvData(endpoint: String) = when (endpoint) { DETAILS_ROUTE -> detailsDataTsv @@ -414,14 +347,65 @@ class LapisControllerCsvTest( private companion object { @JvmStatic - fun getEndpoints() = - listOf( - Arguments.of(DETAILS_ROUTE), - Arguments.of(AGGREGATED_ROUTE), - Arguments.of(NUCLEOTIDE_MUTATIONS_ROUTE), - Arguments.of(AMINO_ACID_MUTATIONS_ROUTE), - Arguments.of(NUCLEOTIDE_INSERTIONS_ROUTE), - Arguments.of(AMINO_ACID_INSERTIONS_ROUTE), - ) + val endpoints = SampleRoute.entries.filter { !it.servesFasta }.map { it.pathSegment } + + @JvmStatic + fun getRequests(dataFormat: String) = + endpoints.flatMap { endpoint -> + listOf( + RequestScenario( + "GET $endpoint with request parameter", + endpoint, + getSample("$endpoint?country=Switzerland&dataFormat=$dataFormat"), + ), + RequestScenario( + "GET $endpoint with accept header", + endpoint, + getSample("$endpoint?country=Switzerland") + .header("Accept", getAcceptHeaderFor(dataFormat)), + ), + RequestScenario( + "POST $endpoint with request parameter", + endpoint, + postSample(endpoint) + .content("""{"country": "Switzerland", "dataFormat": "$dataFormat"}""") + .contentType(MediaType.APPLICATION_JSON), + ), + RequestScenario( + "POST $endpoint with accept header", + endpoint, + postSample(endpoint) + .content("""{"country": "Switzerland", "dataFormat": "$dataFormat"}""") + .contentType(MediaType.APPLICATION_JSON) + .header("Accept", getAcceptHeaderFor(dataFormat)), + ), + ) + } + + private fun getAcceptHeaderFor(dataFormat: String) = + when (dataFormat) { + "csv" -> TEXT_CSV_HEADER + "csv-without-headers" -> TEXT_CSV_WITHOUT_HEADERS_HEADER + "tsv" -> TEXT_TSV_HEADER + "json" -> MediaType.APPLICATION_JSON_VALUE + else -> throw IllegalArgumentException("Unknown data format: $dataFormat") + } + + @JvmStatic + fun getCsvRequests() = getRequests("csv") + + @JvmStatic + fun getCsvWithoutHeadersRequests() = getRequests("csv-without-headers") + + @JvmStatic + fun getTsvRequests() = getRequests("tsv") + } + + data class RequestScenario( + val description: String, + val endpoint: String, + val request: MockHttpServletRequestBuilder, + ) { + override fun toString() = description } } diff --git a/siloLapisTests/test/details.spec.ts b/siloLapisTests/test/details.spec.ts index 2a9db96c..5ed7268a 100644 --- a/siloLapisTests/test/details.spec.ts +++ b/siloLapisTests/test/details.spec.ts @@ -1,7 +1,5 @@ import { expect } from 'chai'; import { basePath, lapisClient } from './common'; -import fs from 'fs'; -import { SequenceFilters } from './lapisClient'; describe('The /details endpoint', () => { it('should return details with specified fields', async () => { @@ -183,4 +181,23 @@ Solothurn B.1 key_1002052 expect(result.data).to.have.length(1); expect(result.data[0]).to.deep.equal(expectedResultWithAminoAcidInsertion); }); + + it("should provide a way to get a plain list of primary keys for CoV-Spectrum's UShER integration", async () => { + const urlParams = new URLSearchParams({ + dataFormat: 'CSV-WITHOUT-HEADERS', + fields: 'primaryKey', + limit: '3', + orderBy: 'primaryKey', + }); + + const result = await fetch(basePath + '/sample/details?' + urlParams.toString()); + + expect(await result.text()).to.be.equal( + String.raw` +key_1001493 +key_1001920 +key_1002052 + `.trim() + ); + }); });