RequestLine.java

package com.renomad.minum.web;

import com.renomad.minum.logging.ILogger;
import com.renomad.minum.security.ForbiddenUseException;
import com.renomad.minum.utils.StringUtils;

import java.util.*;

import static com.renomad.minum.utils.Invariants.mustNotBeNull;

/**
 * This class holds data and methods for dealing with the
 * "start line" in an HTTP request.  For example,
 * GET /foo HTTP/1.1
 */
public final class RequestLine {

    private final Method method;
    private final PathDetails pathDetails;
    private final HttpVersion version;
    private final String rawValue;
    private final ILogger logger;
    static final int MAX_QUERY_STRING_KEYS_COUNT = 50;

    /**
     * @param method GET, POST, etc.
     * @param pathDetails See {@link PathDetails}
     * @param version the version of HTTP (1.0 or 1.1) we're receiving
     * @param rawValue the entire raw string of the start line
     */
    public RequestLine(
            Method method,
            PathDetails pathDetails,
            HttpVersion version,
            String rawValue,
            ILogger logger
    ) {
        this.method = method;
        this.pathDetails = pathDetails;
        this.version = version;
        this.rawValue = rawValue;
        this.logger = logger;
    }



    public static final RequestLine EMPTY = new RequestLine(Method.NONE, PathDetails.empty, HttpVersion.NONE, "", null);

    /**
     * Returns a map of the key-value pairs in the URL,
     * for example in {@code http://foo.com?name=alice} you
     * have a key of name and a value of alice.
     */
    public Map<String, String> queryString() {
        if (pathDetails == null || pathDetails.getQueryString().isEmpty()) {
            return Map.of();
        } else {
            return new HashMap<>(pathDetails.getQueryString());
        }

    }

    /**
     * These are the HTTP methods we handle.
     */
    public enum Method {
        GET,
        POST,
        PUT,
        DELETE,
        TRACE,
        PATCH,
        OPTIONS,
        HEAD,

        /**
         * Represents the null value of Method
         */
        NONE
    }

    /**
     * Given the string value of a Request Line (like GET /hello HTTP/1.1)
     * validate and extract the values for our use.
     */
    public RequestLine extractRequestLine(String value) {
        mustNotBeNull(value);
        if (value.isEmpty()) {
            return RequestLine.EMPTY;
        }
        RequestLineRawValues rawValues = requestLineTokenizer(value);
        if (rawValues == null) {
            return RequestLine.EMPTY;
        }
        Method myMethod = extractMethod(rawValues.method());
        if (myMethod.equals(Method.NONE)) {
            return RequestLine.EMPTY;
        }
        PathDetails pd = extractPathDetails(rawValues.path());
        HttpVersion httpVersion = getHttpVersion(rawValues.protocol());
        if (httpVersion.equals(HttpVersion.NONE)) {
            return RequestLine.EMPTY;
        }

        return new RequestLine(myMethod, pd, httpVersion, value, logger);
    }

    /**
     * Split the request line into three parts - a method (e.g. GET), a
     * path (e.g. "/" or "/helloworld/hi/foo?name=hello") and a protocol,
     * which is typically "HTTP/1.1" but might be "HTTP/1.0" in some cases
     * <br>
     * If we don't find exactly three parts, we will return null, which
     * is interpreted by the calling method to mean we didn't receive a
     * valid request line.
     * @param rawRequestLine the full string of the first line received
     *                       after the socket is connected to the client.
     */
    private RequestLineRawValues requestLineTokenizer(String rawRequestLine) {
        int firstSpace = rawRequestLine.indexOf(' ');
        if (firstSpace == -1) {
            return null;
        }
        int secondSpace = rawRequestLine.indexOf(' ', firstSpace + 1);
        if (secondSpace == -1) {
            return null;
        }
        int thirdSpace = rawRequestLine.indexOf(' ', secondSpace + 1);
        if (thirdSpace != -1) {
            return null;
        }
        String method = rawRequestLine.substring(0, firstSpace);
        String path = rawRequestLine.substring(firstSpace + 1, secondSpace);
        String protocol = rawRequestLine.substring(secondSpace + 1);
        return new RequestLineRawValues(method, path, protocol);
    }

    private Method extractMethod(String methodString) {
        try {
            return Method.valueOf(methodString.toUpperCase(Locale.ROOT));
        } catch (Exception ex) {
            logger.logDebug(() -> "Unable to convert method to enum: " + methodString);
            return Method.NONE;
        }
    }

    private PathDetails extractPathDetails(String path) {
        PathDetails pd;
        // the request line will have a forward slash at the beginning of
        // the path.  Remove that here.
        String adjustedPath = path.substring(1);
        int locationOfQueryBegin = adjustedPath.indexOf("?");
        if (locationOfQueryBegin > 0) {
            // in this case, we found a question mark, suggesting that a query string exists
            String rawQueryString = adjustedPath.substring(locationOfQueryBegin + 1);
            String isolatedPath = adjustedPath.substring(0, locationOfQueryBegin);
            Map<String, String> queryString = extractMapFromQueryString(rawQueryString);
            pd = new PathDetails(isolatedPath, rawQueryString, queryString);
        } else {
            // in this case, no question mark was found, thus no query string
            pd = new PathDetails(adjustedPath, null, null);
        }
        return pd;
    }


    /**
     * Given a string containing the combined key-values in
     * a query string (e.g. foo=bar&name=alice), split that
     * into a map of the key to value (e.g. foo to bar, and name to alice)
     */
    Map<String, String> extractMapFromQueryString(String rawQueryString) {
        Map<String, String> queryStrings = new HashMap<>();
        StringTokenizer tokenizer = new StringTokenizer(rawQueryString, "&");
        // we'll only take less than MAX_QUERY_STRING_KEYS_COUNT
        for (int i = 0; tokenizer.hasMoreTokens(); i++) {
            if (i >= MAX_QUERY_STRING_KEYS_COUNT) throw new ForbiddenUseException("User tried providing too many query string keys.  max: " + MAX_QUERY_STRING_KEYS_COUNT);
            // this should give us a key and value joined with an equal sign, e.g. foo=bar
            String currentKeyValue = tokenizer.nextToken();
            int equalSignLocation = currentKeyValue.indexOf("=");
            if (equalSignLocation <= 0) return Map.of();
            String key = currentKeyValue.substring(0, equalSignLocation);
            String rawValue = currentKeyValue.substring(equalSignLocation + 1);
            try {
                String value = StringUtils.decode(rawValue);
                queryStrings.put(key, value);
            } catch (IllegalArgumentException ex) {
                logger.logDebug(() -> "Query string parsing failed for key: (%s) value: (%s).  Skipping to next key-value pair. error message: %s".formatted(key, rawValue, ex.getMessage()));
            }
        }
        return queryStrings;
    }

    /**
     * Extract the HTTP version from the start line
     */
    private HttpVersion getHttpVersion(String version) {
        if (version.equals("HTTP/1.1")) {
            return HttpVersion.ONE_DOT_ONE;
        } else if (version.equals("HTTP/1.0")) {
            return HttpVersion.ONE_DOT_ZERO;
        } else {
            return HttpVersion.NONE;
        }
    }

    /**
     * Return the method of this request-line.  For example, GET, PUT, POST...
     */
    public Method getMethod() {
        return method;
    }

    /**
     * This returns an object which contains essential information about the path
     * in the request line.  For example, if the request line is "GET /sample?foo=bar HTTP/1.1",
     * this would hold data for the path ("sample") and the query string ("foo=bar")
     */
    public PathDetails getPathDetails() {
        return pathDetails;
    }

    /**
     * Gets the HTTP version, either 1.0 or 1.1
     */
    public HttpVersion getVersion() {
        return this.version;
    }

    /**
     * Get the string value of this request line, such as "GET /sample.html HTTP/1.1"
     */
    public String getRawValue() {
        return rawValue;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        RequestLine that = (RequestLine) o;
        return method == that.method && Objects.equals(pathDetails, that.pathDetails) && version == that.version && Objects.equals(rawValue, that.rawValue) && Objects.equals(logger, that.logger);
    }

    @Override
    public int hashCode() {
        return Objects.hash(method, pathDetails, version, rawValue, logger);
    }

    @Override
    public String toString() {
        return "RequestLine{" +
                "method=" + method +
                ", pathDetails=" + pathDetails +
                ", version=" + version +
                ", rawValue='" + rawValue + '\'' +
                ", logger=" + logger +
                '}';
    }
}