Quantcast
Channel: Baeldung
Viewing all articles
Browse latest Browse all 3550

How to Serve a Zip File With Spring Boot @RequestMapping

$
0
0

1. Overview

Sometimes we may need to allow our REST API to download ZIP archives. This can be useful for reducing network load. However, we might encounter difficulties downloading the files with the default configuration on our endpoints.

In this article, we’ll see how to use the @RequestMapping annotation to produce ZIP files from our endpoints, and we’ll explore a few approaches to serve ZIP archives from them.

2. Zip Archive as Byte Array

The first way to serve a ZIP file is by creating it as a byte array and returning it in the HTTP response. Let’s create the REST controller with the endpoint that returns us archive bytes:

@RestController
public class ZipArchiveController {
    @GetMapping(value = "/zip-archive", produces = "application/zip")
    public ResponseEntity<byte[]> getZipBytes() throws IOException {
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(byteArrayOutputStream);
        ZipOutputStream zipOutputStream = new ZipOutputStream(bufferedOutputStream);
        addFilesToArchive(zipOutputStream);
        IOUtils.closeQuietly(bufferedOutputStream);
        IOUtils.closeQuietly(byteArrayOutputStream);
        return ResponseEntity
          .ok()
          .header("Content-Disposition", "attachment; filename=\"files.zip\"")
          .body(byteArrayOutputStream.toByteArray());
    }
}

We use @GetMapping as a shortcut for @RequestMapping annotation. In the produces property we choose application/zip which is a MIME type for ZIP archives. Then we wrap the ByteArrayOutputStream with the  ZipOutputStream and add all the needed files there. Finally, we set the Content-Disposition header with attachment value so we’ll be able to download our archive after the call.

Now, let’s implement the addFilesToArchive() method:

void addFilesToArchive(ZipOutputStream zipOutputStream) throws IOException {
    List<String> filesNames = new ArrayList<>();
    filesNames.add("first-file.txt");
    filesNames.add("second-file.txt");
    for (String fileName : filesNames) {
        File file = new File(ZipArchiveController.class.getClassLoader()
          .getResource(fileName).getFile());
        zipOutputStream.putNextEntry(new ZipEntry(file.getName()));
        FileInputStream fileInputStream = new FileInputStream(file);
        IOUtils.copy(fileInputStream, zipOutputStream);
        fileInputStream.close();
        zipOutputStream.closeEntry();
    }
    zipOutputStream.finish();
    zipOutputStream.flush();
    IOUtils.closeQuietly(zipOutputStream);
}

Here, we simply populate the archive with a few files from the resources folder.

Finally, let’s call our endpoint and check if all the files are returned:

@WebMvcTest(ZipArchiveController.class)
public class ZipArchiveControllerUnitTest {
    @Autowired
    MockMvc mockMvc;
    @Test
    void givenZipArchiveController_whenGetZipArchiveBytes_thenExpectedArchiveShouldContainExpectedFiles() throws Exception {
        MvcResult result = mockMvc.perform(get("/zip-archive"))
          .andReturn();
        MockHttpServletResponse response = result.getResponse();
        byte[] content = response.getContentAsByteArray();
        List<String> fileNames = fetchFileNamesFromArchive(content);
        assertThat(fileNames)
          .containsExactly("first-file.txt", "second-file.txt");
    }
    List<String> fetchFileNamesFromArchive(byte[] content) throws IOException {
        InputStream byteStream = new ByteArrayInputStream(content);
        ZipInputStream zipStream = new ZipInputStream(byteStream);
        List<String> fileNames = new ArrayList<>();
        ZipEntry entry;
        while ((entry = zipStream.getNextEntry()) != null) {
            fileNames.add(entry.getName());
            zipStream.closeEntry();
        }
        return fileNames;
    }
}

As expected in the response we obtained the ZIP archive from the endpoint. We’ve unarchived all the files from there and double-checked if all the expected files are in place.

We can use this approach for smaller files, but larger files may cause issues with heap consumption. This is because ByteArrayInputStream holds the entire ZIP file in memory.

3. Zip Archive as a Stream

For larger archives, we should avoid loading everything into memory. Instead, we can stream the ZIP file directly to the client as it’s being created. This reduces memory consumption and allows us to serve huge files efficiently.

Let’s create another one endpoint on our controller:

@GetMapping(value = "/zip-archive-stream", produces = "application/zip")
public ResponseEntity<StreamingResponseBody> getZipStream() {
    return ResponseEntity
      .ok()
      .header("Content-Disposition", "attachment; filename=\"files.zip\"")
      .body(out -> {
          ZipOutputStream zipOutputStream = new ZipOutputStream(out);
          addFilesToArchive(zipOutputStream);
      });
}

We’ve used a Servlet output stream here instead of ByteArrayInputStream, so all our files will be streamed to the client without being fully stored in memory.

Let’s call this endpoint and check if it returns our files:

@Test
void givenZipArchiveController_whenGetZipArchiveStream_thenExpectedArchiveShouldContainExpectedFiles() throws Exception {
    MvcResult result = mockMvc.perform(get("/zip-archive-stream"))
     .andReturn();
    MockHttpServletResponse response = result.getResponse();
    byte[] content = response.getContentAsByteArray();
    List<String> fileNames = fetchFileNamesFromArchive(content);
    assertThat(fileNames)
      .containsExactly("first-file.txt", "second-file.txt");
}

We successfully retrieved the archive and all the files were found there.

4. Control the Archive Compression

When we use ZipOutputStream, it already provides compression. We can adjust the compression level using the zipOutputStream.setLevel() method.

Let’s modify one of our endpoints code to set the compression level:

@GetMapping(value = "/zip-archive-stream", produces = "application/zip")
public ResponseEntity<StreamingResponseBody> getZipStream() {
    return ResponseEntity
      .ok()
      .header("Content-Disposition", "attachment; filename=\"files.zip\"")
      .body(out -> {
          ZipOutputStream zipOutputStream = new ZipOutputStream(out);
          zipOutputStream.setLevel(9);
          addFilesToArchive(zipOutputStream);
      });
}

We set the compression level to 9, giving us the maximum compression level. We can choose a value between 0 and 9. A lower compression level gives us faster processing, while a higher level produces a smaller output but slows the archiving.

5. Add Archive Password Protection

We’re also able to set up a password for our ZIP archives. To do this, let’s add the zip4j dependency:

<dependency>
    <groupId>net.lingala.zip4j</groupId>
    <artifactId>zip4j</artifactId>
    <version>${zip4j.version}</version>
</dependency>

Now we’ll add a new endpoint to our controller where we return password-encrypted archive streams:

import net.lingala.zip4j.io.outputstream.ZipOutputStream;
@GetMapping(value = "/zip-archive-stream-secured", produces = "application/zip")
public ResponseEntity<StreamingResponseBody> getZipSecuredStream() {
    return ResponseEntity
      .ok()
      .header("Content-Disposition", "attachment; filename=\"files.zip\"")
      .body(out -> {
          ZipOutputStream zipOutputStream = new ZipOutputStream(out, "password".toCharArray());
          addFilesToArchive(zipOutputStream);
      });
}

Here we’ve used ZipOutputStream from the zip4j library, which can handle passwords.

Now let’s implement the addFilesToArchive() method:

import net.lingala.zip4j.model.ZipParameters;
void addFilesToArchive(ZipOutputStream zipOutputStream) throws IOException {
    List<String> filesNames = new ArrayList<>();
    filesNames.add("first-file.txt");
    filesNames.add("second-file.txt");
    ZipParameters zipParameters = new ZipParameters();
    zipParameters.setCompressionMethod(CompressionMethod.DEFLATE);
    zipParameters.setEncryptionMethod(EncryptionMethod.ZIP_STANDARD);
    zipParameters.setEncryptFiles(true);
    for (String fileName : filesNames) {
        File file = new File(ZipArchiveController.class.getClassLoader()
          .getResource(fileName).getFile());
        zipParameters.setFileNameInZip(file.getName());
        zipOutputStream.putNextEntry(zipParameters);
        FileInputStream fileInputStream = new FileInputStream(file);
        IOUtils.copy(fileInputStream, zipOutputStream);
        fileInputStream.close();
        zipOutputStream.closeEntry();
    }
    zipOutputStream.flush();
    IOUtils.closeQuietly(zipOutputStream);
}

We’ve used the encryptionMethod and encryptFiles parameters of ZIP entry to encrypt the files.

Finally, let’s call our new endpoint and check the response:

@Test
void givenZipArchiveController_whenGetZipArchiveSecuredStream_thenExpectedArchiveShouldContainExpectedFilesSecuredByPassword() throws Exception {
    MvcResult result = mockMvc.perform(get("/zip-archive-stream-secured"))
      .andReturn();
    MockHttpServletResponse response = result.getResponse();
    byte[] content = response.getContentAsByteArray();
    List<String> fileNames = fetchFileNamesFromArchive(content);
    assertThat(fileNames)
      .containsExactly("first-file.txt", "second-file.txt");
}

In fetchFileNamesFromArchive(), we’ll implement the logic for retrieving data from our ZIP archive:

import net.lingala.zip4j.io.inputstream.ZipInputStream;
List<String> fetchFileNamesFromArchive(byte[] content) throws IOException {
    InputStream byteStream = new ByteArrayInputStream(content);
    ZipInputStream zipStream = new ZipInputStream(byteStream, "password".toCharArray());
    List<String> fileNames = new ArrayList<>();
    LocalFileHeader entry = zipStream.getNextEntry();
    while (entry != null) {
        fileNames.add(entry.getFileName());
        entry = zipStream.getNextEntry();
    }
    zipStream.close();
    return fileNames;
}

Here we use ZipInputStream from the zip4j library again and set the password we used during encryption. Otherwise, we’ll encounter a ZipException.

6. Conclusion

In this tutorial, we explored two approaches for serving ZIP files in a Spring Boot application. We can use byte arrays for small to medium-sized archives. For larger files, we should consider streaming the ZIP archive directly in the HTTP response to keep memory usage low. By adjusting the compression level, we can control the network load and the latency of our endpoints.

As always, the code is available over on GitHub.

       

Viewing all articles
Browse latest Browse all 3550

Trending Articles