Body.java

package com.renomad.minum.web;

import com.renomad.minum.utils.StringUtils;

import java.util.*;

/**
 * This class represents the body of an HTML message.
 * See <a href="https://en.wikipedia.org/wiki/HTTP_message_body">Message Body on Wikipedia</a>
 *<br>
 * <pre>{@code
 * This could be a response from the web server:
 *
 * HTTP/1.1 200 OK
 * Date: Sun, 10 Oct 2010 23:26:07 GMT
 * Server: Apache/2.2.8 (Ubuntu) mod_ssl/2.2.8 OpenSSL/0.9.8g
 * Last-Modified: Sun, 26 Sep 2010 22:04:35 GMT
 * ETag: "45b6-834-49130cc1182c0"
 * Accept-Ranges: bytes
 * Content-Length: 12
 * Connection: close
 * Content-Type: text/html
 *
 * Hello world!
 * }</pre>
 * <p>
 *     The message body (or content) in this example is the text <pre>Hello world!</pre>.
 * </p>
 */
public final class Body {

    private static final byte[] EMPTY_BYTES = new byte[0];
    private final Map<String, byte[]> bodyMap;
    private final byte[] raw;
    private final List<Partition> partitions;
    private final BodyType bodyType;

    /**
     * An empty body instance, useful when you
     * need an instantiated body.
     */
    public static final Body EMPTY = new Body(Map.of(), EMPTY_BYTES, List.of(), BodyType.NONE);

    /**
     * Build a body for an HTTP message
     * @param bodyMap a map of key-value pairs, presumably extracted from form data.  Empty
     *                if our body isn't one of the form data protocols we understand.
     * @param raw the raw bytes of this body
     * @param partitions if the body is of type form/multipart, these will be the list of partitions
     */
    public Body(Map<String, byte[]> bodyMap, byte[] raw, List<Partition> partitions, BodyType bodyType) {
        this.bodyMap = new HashMap<>(bodyMap);
        this.raw = raw.clone();
        this.partitions = partitions;
        this.bodyType = bodyType;
    }

    /**
     * Return the value for a key, as a string. This method
     * presumes the data was sent URL-encoded.
     * <p>
     *     If there is no value found for the
     *     provided key, an empty string will be
     *     returned.
     * </p>
     * <p>
     *     Otherwise, the value found will be converted
     *     to a string, and trimmed.
     * </p>
     * <p>
     *     Note: if the request is a multipart/form-data, this
     *     method will throw a helpful exception to indicate that.
     * </p>
     *
     */
    public String asString(String key) {
        if (this.equals(EMPTY)) {
            return "";
        }
        if (this.bodyType.equals(BodyType.MULTIPART)) {
            throw new WebServerException("Request body is in multipart format.  Use .getPartitionByName instead");
        }
        if (this.bodyType.equals(BodyType.UNRECOGNIZED)) {
            throw new WebServerException("Request body is not in a recognized key-value encoding.  Use .asString() to obtain the body data");
        }
        byte[] byteArray = bodyMap.get(key);
        if (byteArray == null) {
            return "";
        } else {
            return StringUtils.byteArrayToString(byteArray).trim();
        }

    }

    /**
     * Return the entire raw contents of the body of this
     * request, as a string. No processing involved other
     * than converting the bytes to a string.
     */
    public String asString() {
        if (this.equals(EMPTY)) {
            return "";
        }
        return StringUtils.byteArrayToString(raw).trim();
    }

    /**
     * Return the bytes of this request body by its key.  This method
     * presumes the data was sent URL-encoded.
     */
    public byte[] asBytes(String key) {
        if (this.equals(EMPTY)) {
            return new byte[0];
        }
        if (this.bodyType.equals(BodyType.MULTIPART)) {
            throw new WebServerException("Request body is in multipart format.  Use .getPartitionByName instead");
        }
        if (this.bodyType.equals(BodyType.UNRECOGNIZED)) {
            throw new WebServerException("Request body is not in a recognized key-value encoding.  Use .asBytes() to obtain the body data");
        }
        return bodyMap.get(key);
    }

    /**
     * Returns the raw bytes of this HTTP message's body. This method
     * presumes the data was sent URL-encoded.
     */
    public byte[] asBytes() {
        if (this.equals(EMPTY)) {
            return new byte[0];
        }
        return this.raw.clone();
    }

    /**
     * If the body is of type form/multipart, return the partitions
     * <p>
     *     For example:
     * </p>
     * <pre>
     * --i_am_a_boundary
     *  Content-Type: text/plain
     *  Content-Disposition: form-data; name="text1"
     *
     *  I am a value that is text
     *  --i_am_a_boundary
     *  Content-Type: application/octet-stream
     *  Content-Disposition: form-data; name="image_uploads"; filename="photo_preview.jpg"
     * </pre>
     */
    public List<Partition> getPartitionHeaders() {
        if (this.equals(EMPTY)) {
            return List.of();
        }
        if (this.bodyType.equals(BodyType.FORM_URL_ENCODED)) {
            throw new WebServerException("Request body encoded in form-urlencoded format. getPartitionHeaders is only used with multipart encoded data.");
        }
        if (this.bodyType.equals(BodyType.UNRECOGNIZED)) {
            throw new WebServerException("Request body encoded is not encoded in a recognized format. getPartitionHeaders is only used with multipart encoded data.");
        }
        return new ArrayList<>(partitions);
    }

    /**
     * A helper method for getting the partitions with a particular name set in its
     * content-disposition.  This returns a list of partitions because there is nothing
     * preventing the browser doing this, and in fact it will typically send partitions
     * with the same name when sending multiple files from one input.  (HTML5 provides the
     * ability to select multiple files on the input with type=file)
     */
    public List<Partition> getPartitionByName(String name) {
        if (this.equals(EMPTY)) {
            return List.of();
        }
        if (this.bodyType.equals(BodyType.FORM_URL_ENCODED)) {
            throw new WebServerException("Request body encoded in form-urlencoded format. use .asString(key) or asBytes(key)");
        }
        if (this.bodyType.equals(BodyType.UNRECOGNIZED)) {
            throw new WebServerException("Request body encoded is not encoded in a recognized format. use .asString() or asBytes()");
        }
        return getPartitionHeaders().stream().filter(x -> x.getContentDisposition().getName().equalsIgnoreCase(name)).toList();
    }

    /**
     * Returns the {@link BodyType}, which is necessary to distinguish
     * which methods to run for accessing data. For instance, if the body
     * is of type FORM_URL_ENCODED, you may use methods
     * like {@link #getKeys()}, {@link #asBytes(String)}, or {@link #asString(String)}
     * <br>
     * On the other hand, if the type is MULTIPART, you will need to use {@link #getPartitionHeaders()}
     * to get a list of the partitions.
     * <br>
     * If the body type is UNRECOGNIZED, you can use {@link #asBytes()} to get the body.
     * <br>
     * Don't forget, there is also an option to obtain the body's {@link java.io.InputStream} by
     * using {@link Request#getSocketWrapper()}, but that needs to be done before running {@link Request#getBody()}
     */
    public BodyType getBodyType() {
        return bodyType;
    }

    /**
     * Get all the keys for the key-value pairs in the body
     */
    public Set<String> getKeys() {
        if (this.equals(EMPTY)) {
            return Set.of();
        }
        return bodyMap.keySet();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Body body = (Body) o;
        return Objects.equals(bodyMap, body.bodyMap) && Arrays.equals(raw, body.raw) && Objects.equals(partitions, body.partitions) && bodyType == body.bodyType;
    }

    @Override
    public int hashCode() {
        int result = Objects.hash(bodyMap, partitions, bodyType);
        result = 31 * result + Arrays.hashCode(raw);
        return result;
    }

    @Override
    public String toString() {
        return "Body{" +
                "bodyMap=" + bodyMap +
                ", bodyType=" + bodyType +
                '}';
    }

}