Serving files with JAX-RS

Although it is a very common requirement, serving files with Jax-RS can be challenging. Especially, if you want to provide media files like MP3s or videos and you expect e.g. chrome to be able to seek through these files, then you must support range requests. Furthermore, you want to avoid to load a whole file into the memory each this file is requested. In the following it will be described how to serve files with Jax-RS. (If you do not want to use Jax-RS there is also a widely used implementation of BalusC directly implemented in a servlet.)

flavor wheel

The following code snippet shows the Jax-RS method. The @HeaderParam("Range") String range will contain the range information or will be null if no information is provided. In case this field is empty the full file shall be returned. If the field contains useful information, only the requested range shall be returned. (Implementation of getMimeType() and getDownloadFileName() is left out.)

@GET
@Path("/files/{fileId}")
public Response getFile(
        @PathParam("fileId") long fileId,
        @HeaderParam("Range") String range
) throws IOException {
    final File file = fileService.getFileById(fileId);

    if (range == null) {
        LOGGER.trace("getFile: range is null, serve full file");
        return streamAll(file, getMimeType(file), getDownloadFileName(file));
    } else {
        LOGGER.trace("getFile: range was provided");
        return streamRange(range, file, getMimeType(file));
    }
}

The next snippet shows the implementation to stream the full file. (Basically, we only return the file, the streaming of the file is done by the Jax-RS implementation.)

private Response streamAll(final File file, String mimeType, String fileName) {
    return Response.ok(file)
        .type(mimeType)
        .header(HttpHeaders.CONTENT_DISPOSITION, fileName)
        .header(HttpHeaders.CONTENT_LENGTH, file.length())
        .header(HttpHeaders.LAST_MODIFIED, new Date(file.lastModified()))
        .build();
}

The more interesting part is the implementation with the Range header. First of all we check the provided range value. If there is no upper value provided, we use a default value of one MB. We also check the plausibility of the values and adjust them if possible. Finally, we return a MediaStreamer that takes care of copying the required range from the file into the OutputStream.

private static final int CHUNK_SIZE = 1024 * 1024; // 1MB chunks

private Response streamRange(String range, File file, String mimeType) throws IOException {
    final String[] ranges = range.split("=")[1].split("-");

    final long from = Long.parseLong(ranges[0]);
    if (from >= file.length()) {
        LOGGER.trace("streamRange: start was after end of file, nothing to return");
        return Response.status(Response.Status.REQUESTED_RANGE_NOT_SATISFIABLE)
                .header("Content-Range", "bytes */" + file.length())
                .build();
    }

    long to = 0;

    if (ranges.length == 2) {
        // the header specifies that last included byte so we increase by one for easier handling
        to = Long.parseLong(ranges[1]) + 1;

        if (to < from) {
            LOGGER.trace("streamRange: to was smaller than from");
            return Response.status(Response.Status.REQUESTED_RANGE_NOT_SATISFIABLE)
                    .header("Content-Range", "bytes */" + file.length())
                    .build();
        }
    } else {
        // use default range if the range upper bound is unspecified. Chrome sends "bytes=0-"
        to = CHUNK_SIZE + from;
    }

    if (to > file.length()) {
        LOGGER.trace("streamRange: to was greater than possible, limit to max length");
        to = file.length();
    }

    final long length = to - from;

    final String responseRange = String.format("bytes %d-%d/%d", from, to - 1, file.length());

    final MediaStreamer streamer = new MediaStreamer(file, from, length);
    return Response.ok(streamer)
            .type(mimeType)
            .status(Response.Status.PARTIAL_CONTENT)
            .header("Accept-Ranges", "bytes")
            .header("Content-Range", responseRange)
            .header(HttpHeaders.CONTENT_LENGTH, length)
            .header(HttpHeaders.LAST_MODIFIED, file.lastModified())
            .build();
}

It is important to provide the correct content type, otherwise some Browsers will not download or play the file.

The MediaStreamer is an implementation of the StreamingOutput interface. It uses a Guava to limit the file stream to the requested range and to copy the data into the output stream. I would also be possible to implement this directly with a lambda expression.

import com.google.common.io.ByteStreams;

import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.StreamingOutput;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

public class MediaStreamer implements StreamingOutput {
    private final File file;
    private final long length;
    private final long from;

    public MediaStreamer(File file, long from, long length) {
        this.length = length;
        this.file = file;
        this.from = from;
    }

    @Override
    public void write(OutputStream outputStream) throws IOException, WebApplicationException {
        try (FileInputStream fis = new FileInputStream(file)) {
            fis.skip(from);

            InputStream limit = ByteStreams.limit(fis, length);
            ByteStreams.copy(limit, outputStream);
        }
    }
}