Response.java

package com.renomad.minum.web;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.zip.GZIPOutputStream;

import static com.renomad.minum.utils.FileUtils.badFilePathPatterns;
import static com.renomad.minum.web.StatusLine.StatusCode.CODE_200_OK;
import static com.renomad.minum.web.StatusLine.StatusCode.CODE_206_PARTIAL_CONTENT;

/**
 * Represents an HTTP response. This is what will get sent back to the
 * client (that is, to the browser).  There are a variety of overloads
 * of this record for different situations.  The overarching paradigm is
 * to provide you high flexibility.
 * <p>
 * A response message is sent by a server to a client as a reply to its former request message.
 * </p>
 * <h3>
 * Response syntax
 * </h3>
 * <p>
 * A server sends response messages to the client, which consist of:
 * </p>
 * <ul>
 * <li>
 * a status line, consisting of the protocol version, a space, the
 * response status code, another space, a possibly empty reason
 * phrase, a carriage return and a line feed, e.g.:
 * <pre>
 * HTTP/1.1 200 OK
 * </pre>
 * </li>
 *
 * <li>
 * zero or more response header fields, each consisting of the case-insensitive
 * field name, a colon, optional leading whitespace, the field value, an
 * optional trailing whitespace and ending with a carriage return and a line feed, e.g.:
 * <pre>
 * Content-Type: text/html
 * </pre>
 * </li>
 *
 * <li>
 * an empty line, consisting of a carriage return and a line feed;
 * </li>
 *
 * <li>
 * an optional message body.
 * </li>
 *</ul>

 */
public final class Response implements IResponse {

    private final StatusLine.StatusCode statusCode;
    private final Map<String, String> extraHeaders;
    private final byte[] body;
    private final ThrowingConsumer<ISocketWrapper> outputGenerator;
    private final long bodyLength;


    /**
     * This is the constructor that provides access to all fields.  It is not intended
     * to be used from outside this class.
     * @param extraHeaders extra headers we want to return with the response.
     * @param outputGenerator a {@link ThrowingConsumer} that will use a {@link ISocketWrapper} parameter
     *                        to send bytes on the wire back to the client.  See the static factory methods
     *                        such as {@link #buildResponse(StatusLine.StatusCode, Map, byte[])} for more details on this.
     * @param bodyLength this is used to set the content-length header for the response.  If this is
     *                   not provided, we set the header to "transfer-encoding: chunked", or in other words, streaming.
     */
    Response(StatusLine.StatusCode statusCode, Map<String, String> extraHeaders, byte[] body,
             ThrowingConsumer<ISocketWrapper> outputGenerator, long bodyLength) {
        this.statusCode = statusCode;
        this.extraHeaders = new HashMap<>(extraHeaders);
        this.body = body;
        this.outputGenerator = outputGenerator;
        this.bodyLength = bodyLength;
    }

    /**
     * This factory method is intended for situations where the user wishes to stream data
     * but lacks the content length.  This is only for unusual situations where the developer
     * needs the extra control.  In most cases, other methods are more suitable.
     * @param extraHeaders any extra headers for the response, such as the content-type
     * @param outputGenerator a function that will be given a {@link ISocketWrapper}, providing the
     *                        ability to send bytes on the socket.
     */
    public static IResponse buildStreamingResponse(StatusLine.StatusCode statusCode, Map<String, String> extraHeaders, ThrowingConsumer<ISocketWrapper> outputGenerator) {
        return new Response(statusCode, extraHeaders, null, outputGenerator, 0);
    }

    /**
     * Similar to {@link Response#buildStreamingResponse(StatusLine.StatusCode, Map, ThrowingConsumer)} but here we know
     * the body length, so that will be sent to the client as content-length.
     * @param extraHeaders any extra headers for the response, such as the content-type
     * @param outputGenerator a function that will be given a {@link ISocketWrapper}, providing the
     *                        ability to send bytes on the socket.
     * @param bodyLength the length, in bytes, of the data to be sent to the client
     */
    public static IResponse buildStreamingResponse(StatusLine.StatusCode statusCode, Map<String, String> extraHeaders, ThrowingConsumer<ISocketWrapper> outputGenerator, long bodyLength) {
        return new Response(statusCode, extraHeaders, null, outputGenerator, bodyLength);
    }

    /**
     * A constructor for situations where the developer wishes to send a small (less than a megabyte) byte array
     * to the client.  If there is need to send something of larger size, choose one these
     * alternate constructors:
     * FileChannel - for sending a large file: {@link Response#buildLargeFileResponse(Map, String, Headers)}
     * Streaming - for more custom streaming control with a known body size: {@link Response#buildStreamingResponse(StatusLine.StatusCode, Map, ThrowingConsumer, long)}
     * Streaming - for more custom streaming control with body size unknown: {@link Response#buildStreamingResponse(StatusLine.StatusCode, Map, ThrowingConsumer)}
     *
     * @param extraHeaders any extra headers for the response, such as the content-type.
     */
    public static IResponse buildResponse(StatusLine.StatusCode statusCode, Map<String, String> extraHeaders, byte[] body) {
        return new Response(statusCode, extraHeaders, body, socketWrapper -> sendByteArrayResponse(socketWrapper, body), body.length);
    }

    /**
     * Build an ordinary response, with a known body
     * @param extraHeaders extra HTTP headers, like <pre>content-type: text/html</pre>
     */
    public static IResponse buildResponse(StatusLine.StatusCode statusCode, Map<String, String> extraHeaders, String body) {
        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
        return new Response(statusCode, extraHeaders, bytes, socketWrapper -> sendByteArrayResponse(socketWrapper, bytes), bytes.length);
    }

    public static IResponse buildLargeFileResponse(Map<String, String> extraHeaders, String filePath, Headers requestHeaders) throws IOException {
        if (badFilePathPatterns.matcher(filePath).find()) {
            throw new WebServerException(String.format("Bad path requested at readFile: %s", filePath));
        }

        Map<String, String> adjustedHeaders = new HashMap<>(extraHeaders);
        long fileSize = Files.size(Path.of(filePath));
        var range = new Range(requestHeaders, fileSize);
        StatusLine.StatusCode responseCode = CODE_200_OK;
        long length = fileSize;
        if (range.hasRangeHeader()) {
            long offset = range.getOffset();
            length = range.getLength();
            var lastIndex = (offset + length) - 1;
            adjustedHeaders.put("Content-Range", String.format("bytes %d-%d/%d", offset, lastIndex, fileSize));
            responseCode = CODE_206_PARTIAL_CONTENT;
        }

        ThrowingConsumer<ISocketWrapper> outputGenerator = socketWrapper -> {
            try (RandomAccessFile reader = new RandomAccessFile(filePath, "r")) {
                reader.seek(range.getOffset());
                var fileChannel = reader.getChannel();
                sendFileChannelResponse(socketWrapper, fileChannel, range.getLength());
            }
        };

        return new Response(responseCode, adjustedHeaders, null, outputGenerator, length);
    }

    /**
     * Build a {@link Response} with just a status code and headers, without a body
     * @param extraHeaders extra HTTP headers
     */
    public static IResponse buildLeanResponse(StatusLine.StatusCode statusCode, Map<String, String> extraHeaders) {
        return new Response(statusCode, extraHeaders, null, socketWrapper -> {}, 0);
    }

    /**
     * Build a {@link Response} with only a status code, with no body and no extra headers.
     */
    public static IResponse buildLeanResponse(StatusLine.StatusCode statusCode) {
        return new Response(statusCode, Map.of(), null, socketWrapper -> {}, 0);
    }


    /**
     * A helper method to create a response that returns a
     * 303 status code ("see other").  Provide a url that will
     * be handed to the browser.  This url may be relative or absolute.
     */
    public static IResponse redirectTo(String locationUrl) {
        return buildLeanResponse(StatusLine.StatusCode.CODE_303_SEE_OTHER, Map.of("location", locationUrl));
    }

    /**
     * If you are returning HTML text with a 200 ok, this is a helper that
     * lets you skip some of the boilerplate. This version of the helper
     * lets you add extra headers on top of the basic content-type headers
     * that are needed to specify this is HTML.
     */
    public static IResponse htmlOk(String body, Map<String, String> extraHeaders) {
        var headers = new HashMap<String, String>();
        headers.put("Content-Type", "text/html; charset=UTF-8");
        headers.putAll(extraHeaders);
        return buildResponse(StatusLine.StatusCode.CODE_200_OK, headers, body);
    }

    /**
     * If you are returning HTML text with a 200 ok, this is a helper that
     * lets you skip some of the boilerplate.
     */
    public static IResponse htmlOk(String body) {
        return htmlOk(body, Map.of());
    }

    @Override
    public Map<String, String> getExtraHeaders() {
        return new HashMap<>(extraHeaders);
    }

    @Override
    public StatusLine.StatusCode getStatusCode() {
        return statusCode;
    }

    /**
     * Gets the length of the body for this response.  If the body
     * is an array of bytes set by the user, we grab this value by the
     * length() method.  If the outgoing data is set by a lambda, the user
     * will set the bodyLength value.
     */
    long getBodyLength() {
        if (body != null) {
            return body.length;
        } else {
            return bodyLength;
        }
    }

    /**
     * By calling this method with a {@link ISocketWrapper} parameter, the method
     * will send bytes on the associated socket.
     */
    void sendBody(ISocketWrapper sw) throws IOException {
        try {
            outputGenerator.accept(sw);
        } catch (Exception ex) {
            throw new IOException(ex.getMessage(), ex);
        }
    }

    /**
     * put bytes from a file into the socket, sending to the client
     * @param fileChannel the file we are reading from, based on a {@link RandomAccessFile}
     * @param length the number of bytes to send.  May be less than the full length of this {@link FileChannel}
     */
    private static void sendFileChannelResponse(ISocketWrapper sw, FileChannel fileChannel, long length) throws IOException {
        try {
            int bufferSize = 8 * 1024;
            ByteBuffer buff = ByteBuffer.allocate(bufferSize);
            long countBytesLeftToSend = length;
            while (true) {
                int countBytesRead = fileChannel.read(buff);
                if (countBytesRead <= 0) {
                    break;
                } else {
                    if (countBytesLeftToSend < countBytesRead) {
                        sw.send(buff.array(), 0, (int)countBytesLeftToSend);
                        break;
                    } else {
                        sw.send(buff.array(), 0, countBytesRead);
                    }
                    buff.clear();
                }
                countBytesLeftToSend -= countBytesRead;
            }
        } finally {
            fileChannel.close();
        }
    }

    private static void sendByteArrayResponse(ISocketWrapper sw, byte[] body) throws IOException {
        sw.send(body);
    }

    @Override
    public byte[] getBody() {
        return body;
    }

    /**
     * Compress the data in this body using gzip.
     * <br>
     * This operates by getting the body field from this instance of {@link Response} and
     * creating a new Response with the compressed data.
     */
    Response compressBody() throws IOException {

        ByteArrayOutputStream out = new ByteArrayOutputStream();
        var gos = new GZIPOutputStream(out);
        gos.write(body);
        gos.finish();
        return (Response)Response.buildResponse(
                statusCode,
                extraHeaders,
                out.toByteArray()
        );
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Response response = (Response) o;
        return bodyLength == response.bodyLength && statusCode == response.statusCode && Objects.equals(extraHeaders, response.extraHeaders) && Arrays.equals(body, response.body);
    }

    @Override
    public int hashCode() {
        int result = Objects.hash(statusCode, extraHeaders, bodyLength);
        result = 31 * result + Arrays.hashCode(body);
        return result;
    }

    @Override
    public String toString() {
        return "Response{" +
                "statusCode=" + statusCode +
                ", extraHeaders=" + extraHeaders +
                ", bodyLength=" + bodyLength +
                '}';
    }
}