Stream Dynamically Created ZIP File with Kotlin and Spring

By Rob Pulsipher

Summary

This post discusses how to stream a dynamically generated ZIP file using Kotlin and Spring. It includes details on setting the HTTP headers so that browsers will treat it as a file, and prompt the user to save.

This turns out to be fairly straight-forward:

@RestController
class ZipController{
    @GetMapping("/zip-files")
  fun zipFiles(@RequestParam count: Long): ResponseEntity<StreamingResponseBody> {
    val FILENAME = "myzip.zip"
    val count0 = minOf(maxOf(count, 0), 100) //Constrain between 0 and 100

    val responseBody = StreamingResponseBody { outputStream: OutputStream ->
      ZipOutputStream(outputStream).use { zipOut: ZipOutputStream ->
        for (i in 0 until count0) {
    val entry = ZipEntry("$i.txt")
          val text = "$i\n".toByteArray(Charsets.UTF_8)

          zipOut.putNextEntry(entry)
          zipOut.write(text)
          zipOut.closeEntry()
        }
      }
    }

    return ResponseEntity.ok()
    .contentType(MediaType.APPLICATION_OCTET_STREAM)
    .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$FILENAME\"")
    .body(responseBody)
  }
}

Directory Entries

It is possible to create empty directories in the ZIP archive. This is accomplished by simply adding an entry with a filename ending with a slash(/), as in the following example:

@GetMapping("/zip-empty-directories")
fun zipEmptyDirectories(@RequestParam count: Long): ResponseEntity<StreamingResponseBody> {
    val FILENAME = "myzip.zip"
  val count0 = minOf(maxOf(count, 0), 100) //Constrain between 0 and 100
  val responseBody = StreamingResponseBody { outputStream: OutputStream ->
    ZipOutputStream(outputStream).use { zipOut: ZipOutputStream ->
      for (i in 0 until count0) {
        val entry = ZipEntry("$i/")
        zipOut.putNextEntry(entry)
        zipOut.closeEntry()
      }
    }
  }

  return ResponseEntity.ok()
  .contentType(MediaType.APPLICATION_OCTET_STREAM)
  .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$FILENAME\"")
  .body(responseBody)
}

Subdirectories

Files can be placed in subdirectories within the archive by prepending the directory name to the file name. Example:

val entry = ZipEntry("subdirectory/$i.txt")

It is not necessary to add the directory entries separately. Just add the subdirectory to any files that should be inside it.

Empty Archives

Empty archives work fine. The above with a count of zero will emit an empty archive, which is perfectly valid.

Misc.

Why Stream the Response?

Depending on the use case, the response may include quite a few files and/or be quite large. Therefore, it is generally a good idea to stream the response in order to make the request faster and to avoid running out of memory to process the request.

Note: The documentation includes the following point:

Note: when using this option it is highly recommended to configure explicitly the TaskExecutor used in Spring MVC for executing asynchronous requests. Both the MVC Java config and the MVC namespaces provide options to configure asynchronous handling. If not using those, an application can set the taskExecutor property of RequestMappingHandlerAdapter.

I haven't investigated this point yet, but everything seems to work fine as is.

Curl Command to Save with Default Filename

Use the -O -J flags with curl to save the file with the filename provided in the Content-Disposition header:

$ curl localhost:8080/zip-files?count=15 -O -J

References