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 java.util.regex.Matcher;
import java.util.regex.Pattern;
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;
}
/**
* This is our regex for looking at a client's request
* and determining what to send them. For example,
* if they send GET /sample.html HTTP/1.1, we send them sample.html
* <p>
* On the other hand if it's not a well-formed request, or
* if we don't have that file, we reply with an error page
* </p>
*/
static final String REQUEST_LINE_PATTERN = "^([A-Z]{3,8})" + // an HTTP method, like GET, HEAD, POST, or OPTIONS
" /?(.*)" + // the request target - may or may not start with a slash.
" HTTP/(1.1|1.0)$"; // the HTTP version, defining structure of the remaining message
static final Pattern startLineRegex = Pattern.compile(REQUEST_LINE_PATTERN);
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 new HashMap<>();
} else {
return new HashMap<>(pathDetails.getQueryString());
}
}
/**
* These are the HTTP methods we handle.
* @see #REQUEST_LINE_PATTERN
*/
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);
Matcher m = RequestLine.startLineRegex.matcher(value);
// run the regex
var doesMatch = m.matches();
if (!doesMatch) {
return RequestLine.EMPTY;
}
Method myMethod = extractMethod(m.group(1));
PathDetails pd = extractPathDetails(m.group(2));
HttpVersion httpVersion = getHttpVersion(m.group(3));
return new RequestLine(myMethod, pd, httpVersion, value, logger);
}
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;
int locationOfQueryBegin = path.indexOf("?");
if (locationOfQueryBegin > 0) {
// in this case, we found a question mark, suggesting that a query string exists
String rawQueryString = path.substring(locationOfQueryBegin + 1);
String isolatedPath = path.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(path, 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 value = StringUtils.decode(currentKeyValue.substring(equalSignLocation + 1));
queryStrings.put(key, value);
}
return queryStrings;
}
/**
* Extract the HTTP version from the start line
*/
private HttpVersion getHttpVersion(String version) {
if (version.equals("1.1")) {
return HttpVersion.ONE_DOT_ONE;
} else {
return HttpVersion.ONE_DOT_ZERO;
}
}
/**
* 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 +
'}';
}
}