2

Summary: ResponseEntity<byte[]> containing CSV is not returned as CSV


I'm writing a controller for a download button that will generate a CSV with data retrieved from another dependency.

    @PostMapping(BASE_ROUTE + "/download")
    public ResponseEntity<byte[]> downloadCases(@RequestBody RequestType request, final HttpServletResponse response) throws IOException {
        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
                PrintWriter writer = new PrintWriter(outputStreamWriter, true)) {
            String nextPageToken = null;
            do {
                ServiceResponse serviceResponse = makeServiceCall(request.getParam());
                // write a String of comma separated values
                writer.println(serviceResponse.getLine());
                // eventually null
                nextPageToken = serviceResponse.getToken(); 
            } while (nextPageToken != null);

            return ResponseEntity.ok()
                    .header(HttpHeaders.CONTENT_TYPE, "text/csv")
                    .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"report.csv\"")
                    .body(outputStream.toByteArray());
        }

As a test, I also tried setting the body to .body("Case ID,Assignee".getBytes(StandardCharsets.UTF_8)). This should be equivalent to https://stackoverflow.com/a/34508533/10327093

In both cases, the response I'm getting looks like Base64. Example (shortened): Q2FzZSBJRCxBc3NpZ25lZQ==

It doesn't seem to be actually Base64. Using .body(Base64.getDecoder().decode(outputStream.toByteArray())) gives me java.lang.IllegalArgumentException: Illegal base64 character

If I return void and copy the outputStream to response.getOutputStream() with IOUtils.write(outputStream.toByteArray(), response.getOutputStream());, the downloaded file is correct (CSV).

However I want to avoid calling response.getOutputStream() directly. If I get any Exception afterwards, I get the error getOutputStream() has already been called for this response in the @ExceptionHandler

EDIT: Base64 decoding the response gives me the correct value

System.out.println(new String(Base64.getDecoder().decode("Q2FzZSBJRCxBc3NpZ25lZQ==")));
// Case ID,Assignee

It seems the byte[] is getting Base64 encoded between returning the ResponseEntity and the client (tried with Postman and browser).

TwoPerfect
  • 21
  • 1
  • 3

2 Answers2

2

TL;DR: Don't give data as a byte[], give it as a String.


When using ResponseEntity, Spring uses a registered HttpMessageConverter.

If you had specified a content type of application/octet-stream, with a body type of byte[], Spring would use the ByteArrayHttpMessageConverter and would have sent the bytes as-is.

If you specify a content type of text/*, e.g. the text/csv you're specifying, with a body type of String, Spring would use the StringHttpMessageConverter, which would encode the text as appropriate, and send it. It would be best to explicitly specify the charset, e.g. using content type text/csv; charset=UTF-8, so the client knows the character set used by the server.

Since you specified a content type of text/csv, but with a body type of byte[], Spring is using some other HttpMessageConverter, probably a ObjectToStringHttpMessageConverter, with a ConversionService that encodes byte[] to String as Base-64.

Side note: For better performance, don't auto-flush each line of output. Turn auto-flush off and just do a final flush() when done.

Long story short, since you want text, don't convert to byte[]. Use a StringWriter to keep everything as text:

@PostMapping(path = BASE_ROUTE + "/download", produces = "text/csv; charset=UTF-8")
public ResponseEntity<String> downloadCases(@RequestBody RequestType request) throws IOException {
    try (StringWriter stringWriter = new StringWriter();
            PrintWriter writer = new PrintWriter(stringWriter)) {
        String nextPageToken = null;
        do {
            ServiceResponse serviceResponse = makeServiceCall(request.getParam());
            // write a String of comma separated values
            writer.println(serviceResponse.getLine());
            // eventually null
            nextPageToken = serviceResponse.getToken();
        } while (nextPageToken != null);
        writer.flush();

        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_TYPE, "text/csv; charset=UTF-8")
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"report.csv\"")
                .body(stringWriter.toString());
    }
}

Since you never used the response parameter, it was removed.

Andreas
  • 154,647
  • 11
  • 152
  • 247
  • I tried `application/octet-stream` and still got Base64. I'm starting to wonder if it has to do with using POST instead of GET? Changing to String works. That said, most of the examples I've seen on StackOverflow use byte[]. Are there any disadvantages to using String over byte[]? Or is it simply because most examples are reading from a File that might not be text? – TwoPerfect Oct 09 '19 at 17:05
0

Unfortunately, I cannot comment, so I will post as an answer, but this is truly just some suggestions:

Have you tried to use "application/csv" instead of "text"/csv"?

I had to do something very similar not so long ago and by just looking at your code, I don't see any problem.

Here is an example of what I did:

@GetMapping("/" , produces="application/vnd.ms-excel")
public ResponseEntity<Object> myMethod() {
  String filename = "filename.xlsx";
  byte[] bytes = getBytes();
  HttpHeaders headers = new HttpHeaders();
  headers.set("Content-Type", "application/vnd.ms-excel;");
  headers.setContentDispositionFormData(filename, filename);
  return new ResponseEntity<Object>(bytes, headers, HttpStatus.OK);
}
andre
  • 448
  • 1
  • 3
  • 8
  • I tried this but still got Base64 from both browser and Postman – TwoPerfect Oct 07 '19 at 18:16
  • From postman also? How are you trying to save the file on Postman? Are you clicking "Send and Download" (https://stackoverflow.com/questions/38975718/how-to-download-excel-xls-file-from-api-in-postman)? – andre Oct 07 '19 at 22:28
  • What happens if you add the "produces" attribute to the GetMapping? I edited my answer. – andre Oct 07 '19 at 22:37