io.fusionauth.http.server.HTTPRequest Maven / Gradle / Ivy
Show all versions of java-http Show documentation
/*
* Copyright (c) 2022, FusionAuth, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
* either express or implied. See the License for the specific
* language governing permissions and limitations under the License.
*/
package io.fusionauth.http.server;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Locale.LanguageRange;
import java.util.Map;
import java.util.Objects;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.stream.Collectors;
import io.fusionauth.http.Buildable;
import io.fusionauth.http.Cookie;
import io.fusionauth.http.FileInfo;
import io.fusionauth.http.HTTPMethod;
import io.fusionauth.http.HTTPValues.ContentTypes;
import io.fusionauth.http.HTTPValues.Headers;
import io.fusionauth.http.HTTPValues.TransferEncodings;
import io.fusionauth.http.body.BodyException;
import io.fusionauth.http.io.MultipartStream;
import io.fusionauth.http.util.HTTPTools;
import io.fusionauth.http.util.HTTPTools.HeaderValue;
import io.fusionauth.http.util.WeightedString;
/**
* An HTTP request that is received by the HTTP server. This contains all the relevant information from the request including any file
* uploads and the InputStream that the server can read from to handle the HTTP body.
*
* This is mutable because the server is not trying to enforce that the request is always the same as the one it received. There are many
* cases where requests values are mutated, removed, or replaced. Rather than using a janky delegate or wrapper, this is simply mutable.
*
* @author Brian Pontarelli
*/
@SuppressWarnings("unused")
public class HTTPRequest implements Buildable {
private final List acceptEncodings = new LinkedList<>();
private final Map attributes = new HashMap<>();
private final Map cookies = new HashMap<>();
private final List files = new LinkedList<>();
private final Map> headers = new HashMap<>();
private final List locales = new LinkedList<>();
private final int multipartBufferSize;
private final Map> urlParameters = new HashMap<>();
private byte[] bodyBytes;
private Map> combinedParameters;
private Long contentLength;
private String contentType;
private String contextPath;
private Charset encoding = StandardCharsets.UTF_8;
private Map> formData;
private String host;
private InputStream inputStream;
private String ipAddress;
private HTTPMethod method;
private boolean multipart;
private String multipartBoundary;
private String path = "/";
private int port = -1;
private String protocol;
private String queryString;
private String scheme;
public HTTPRequest() {
this.contextPath = "";
this.multipartBufferSize = 1024;
}
public HTTPRequest(String contextPath, int multipartBufferSize, String scheme, int port, String ipAddress) {
Objects.requireNonNull(contextPath);
Objects.requireNonNull(scheme);
this.contextPath = contextPath;
this.multipartBufferSize = multipartBufferSize;
this.scheme = scheme;
this.port = port;
this.ipAddress = ipAddress;
}
public void addAcceptEncoding(String encoding) {
this.acceptEncodings.add(encoding);
}
public void addAcceptEncodings(List encodings) {
this.acceptEncodings.addAll(encodings);
}
public void addCookies(Cookie... cookies) {
for (Cookie cookie : cookies) {
this.cookies.put(cookie.name, cookie);
}
}
public void addCookies(Collection cookies) {
if (cookies == null) {
return;
}
for (Cookie cookie : cookies) {
this.cookies.put(cookie.name, cookie);
}
}
public void addHeader(String name, String value) {
name = name.toLowerCase();
headers.computeIfAbsent(name, key -> new ArrayList<>()).add(value);
decodeHeader(name, value);
}
public void addHeaders(String name, String... values) {
name = name.toLowerCase();
headers.computeIfAbsent(name, key -> new ArrayList<>()).addAll(List.of(values));
for (String value : values) {
decodeHeader(name, value);
}
}
public void addHeaders(String name, Collection values) {
name = name.toLowerCase();
headers.computeIfAbsent(name, key -> new ArrayList<>()).addAll(values);
for (String value : values) {
decodeHeader(name, value);
}
}
public void addHeaders(Map> params) {
params.forEach(this::addHeaders);
}
public void addLocales(Locale... locales) {
this.locales.addAll(Arrays.asList(locales));
}
public void addLocales(Collection locales) {
this.locales.addAll(locales);
}
public void addURLParameter(String name, String value) {
urlParameters.computeIfAbsent(name, key -> new ArrayList<>()).add(value);
combinedParameters = null;
}
public void addURLParameters(String name, String... values) {
urlParameters.computeIfAbsent(name, key -> new ArrayList<>()).addAll(List.of(values));
combinedParameters = null;
}
public void addURLParameters(String name, Collection values) {
urlParameters.computeIfAbsent(name, key -> new ArrayList<>()).addAll(values);
combinedParameters = null;
}
public void addURLParameters(Map> params) {
params.forEach(this::addURLParameters);
combinedParameters = null;
}
public void deleteCookie(String name) {
cookies.remove(name);
}
public List getAcceptEncodings() {
return acceptEncodings;
}
public void setAcceptEncodings(List encodings) {
this.acceptEncodings.clear();
this.acceptEncodings.addAll(encodings);
}
/**
* Retrieves a request attribute.
*
* @param name The name of the attribute.
* @return The attribute or null if it doesn't exist.
*/
public Object getAttribute(String name) {
return attributes.get(name);
}
/**
* Retrieves all the request attributes. This returns the direct Map so changes to the Map will affect all attributes.
*
* @return The attribute Map.
*/
public Map getAttributes() {
return attributes;
}
public String getBaseURL() {
// Setting the wrong value in the X-Forwarded-Proto header seems to be a common issue that causes an exception during URI.create.
// Assuming request.getScheme() is not the problem, and it is related to the proxy configuration.
String scheme = getScheme().toLowerCase();
if (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https")) {
throw new IllegalArgumentException("The request scheme is invalid. Only http or https are valid schemes. The X-Forwarded-Proto header has a value of [" + getHeader(Headers.XForwardedProto) + "], this is likely an issue in your proxy configuration.");
}
String serverName = getHost().toLowerCase();
int serverPort = getPort();
// Ignore port 80 for http
if (getScheme().equalsIgnoreCase("http") && serverPort == 80) {
serverPort = -1;
}
String uri = scheme + "://" + serverName;
if (serverPort > 0) {
if ((scheme.equalsIgnoreCase("http") && serverPort != 80) || (scheme.equalsIgnoreCase("https") && serverPort != 443)) {
uri += ":" + serverPort;
}
}
return uri;
}
public byte[] getBodyBytes() throws BodyException {
if (bodyBytes == null && inputStream != null) {
try {
bodyBytes = inputStream.readAllBytes();
} catch (IOException e) {
throw new BodyException("Unable to read the HTTP request body bytes", e);
}
} else {
bodyBytes = new byte[0];
}
return bodyBytes;
}
public Charset getCharacterEncoding() {
return encoding;
}
public void setCharacterEncoding(Charset encoding) {
this.encoding = encoding;
}
public Long getContentLength() {
return contentLength;
}
public void setContentLength(Long contentLength) {
this.contentLength = contentLength;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public String getContextPath() {
return contextPath;
}
public void setContextPath(String contextPath) {
this.contextPath = contextPath;
}
public Cookie getCookie(String name) {
return cookies.get(name);
}
public List getCookies() {
return new ArrayList<>(cookies.values());
}
public Instant getDateHeader(String name) {
String header = getHeader(name);
return header != null ? ZonedDateTime.parse(header, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant() : null;
}
/**
* Processes the HTTP request body completely by calling {@link #getFormData()}. If the {@code Content-Type} header is multipart, then the
* processing of the body will extract the files.
*
* @return The files, if any.
*/
public List getFiles() {
getFormData();
return files;
}
/**
* Processes the HTTP request body completely if the {@code Content-Type} header is equal to {@link ContentTypes#Form}. If this method is
* called multiple times, the body is only processed the first time. This is not thread-safe, so you need to ensure you protect against
* multiple threads calling this method concurrently.
*
* If the {@code Content-Type} is not {@link ContentTypes#Form}, this will always return an empty Map.
*
* If the InputStream is not ready or complete, this will block until all the bytes are read from the client.
*
* @return The Form data body.
*/
public Map> getFormData() {
if (formData == null) {
formData = new HashMap<>();
String contentType = getContentType();
if (contentType != null && contentType.equalsIgnoreCase(ContentTypes.Form)) {
byte[] body = getBodyBytes();
HTTPTools.parseEncodedData(body, 0, body.length, formData);
} else if (isMultipart()) {
MultipartStream stream = new MultipartStream(inputStream, getMultipartBoundary().getBytes(), multipartBufferSize);
try {
stream.process(formData, files);
} catch (IOException e) {
throw new BodyException("Invalid multipart body.", e);
}
}
}
return formData;
}
public String getHeader(String name) {
List values = getHeaders(name);
return values != null && values.size() > 0 ? values.get(0) : null;
}
public List getHeaders(String name) {
return headers.get(name.toLowerCase());
}
public Map> getHeaders() {
return headers;
}
public void setHeaders(Map> parameters) {
this.headers.clear();
parameters.forEach(this::setHeaders);
}
public String getHost() {
String xHost = getHeader(Headers.XForwardedHost);
return xHost == null ? host : xHost;
}
public void setHost(String host) {
this.host = host;
}
public String getIPAddress() {
String xIPAddress = getHeader(Headers.XForwardedFor);
if (xIPAddress == null || xIPAddress.trim().length() == 0) {
return ipAddress;
}
String[] ips = xIPAddress.split(",");
if (ips.length < 1) {
return xIPAddress.trim();
}
return ips[0].trim();
}
public void setIPAddress(String ipAddress) {
this.ipAddress = ipAddress;
}
public InputStream getInputStream() {
return inputStream;
}
public void setInputStream(InputStream inputStream) {
this.inputStream = inputStream;
combinedParameters = null;
formData = null;
}
public Locale getLocale() {
return locales.size() > 0 ? locales.get(0) : Locale.getDefault();
}
public List getLocales() {
return locales;
}
public HTTPMethod getMethod() {
return method;
}
public void setMethod(HTTPMethod method) {
this.method = method;
}
public String getMultipartBoundary() {
return multipartBoundary;
}
/**
* Calls {@link #getParameters()} to combine everything and then returns the first parameter value for the given name.
*
* @param name The name of the parameter
* @return The parameter values or null if the parameter doesn't exist.
*/
public String getParameter(String name) {
List values = getParameters().get(name);
if (values != null && values.size() > 0) {
return values.get(0);
}
return null;
}
/**
* Combines the URL parameters and the form data that might exist in the body of the HTTP request. The Map returned is not linked back to
* the URL parameters or form data. Changing it will not impact either of those Maps. If this method is called multiple times, the merging
* of all the data is only done the first time and then cached. This is not thread-safe, so you need to ensure you protect against
* multiple threads calling this method concurrently.
*
* @return The combined parameters.
*/
public Map> getParameters() {
if (combinedParameters == null) {
combinedParameters = new HashMap<>();
getURLParameters().forEach((name, values) -> combinedParameters.put(name, new LinkedList<>(values)));
getFormData().forEach((name, value) -> combinedParameters.merge(name, value, (first, second) -> {
first.addAll(second);
return first;
}));
}
return combinedParameters;
}
/**
* Calls {@link #getParameters()} to combine everything and then returns the parameters for the given name.
*
* @param name The name of the parameter
* @return The parameter values or null if the parameter doesn't exist.
*/
public List getParameters(String name) {
return getParameters().get(name);
}
public String getPath() {
return path;
}
public void setPath(String path) {
urlParameters.clear();
// Parse the parameters
byte[] chars = path.getBytes(StandardCharsets.UTF_8);
int questionMark = path.indexOf('?');
if (questionMark > 0 && questionMark != chars.length - 1) {
queryString = new String(chars, questionMark + 1, chars.length - questionMark - 1);
HTTPTools.parseEncodedData(chars, questionMark + 1, chars.length, urlParameters);
}
// Only save the path portion
this.path = questionMark > 0 ? new String(chars, 0, questionMark) : path;
}
public int getPort() {
String xPort = getHeader(Headers.XForwardedPort);
return xPort == null ? port : Integer.parseInt(xPort);
}
public void setPort(int port) {
this.port = port;
}
public String getProtocol() {
return protocol;
}
public void setProtocol(String protocol) {
this.protocol = protocol;
}
public String getQueryString() {
return queryString;
}
public String getScheme() {
String xScheme = getHeader(Headers.XForwardedProto);
return xScheme == null ? scheme : xScheme;
}
public void setScheme(String scheme) {
this.scheme = scheme;
}
public String getTransferEncoding() {
return getHeader(Headers.TransferEncoding);
}
public String getURLParameter(String name) {
List values = urlParameters.get(name);
return (values != null && values.size() > 0) ? values.get(0) : null;
}
public List getURLParameters(String name) {
return urlParameters.get(name);
}
public Map> getURLParameters() {
return urlParameters;
}
public void setURLParameters(Map> parameters) {
this.urlParameters.clear();
this.urlParameters.putAll(parameters);
}
/**
* @return True if the request can reasonably be assumed to have a body. This uses the fact that the request is chunked or that
* {@code Content-Length} header was provided.
*/
public boolean hasBody() {
Long contentLength = getContentLength();
return isChunked() || (contentLength != null && contentLength > 0);
}
public boolean isChunked() {
return getTransferEncoding() != null && getTransferEncoding().equalsIgnoreCase(TransferEncodings.Chunked);
}
public boolean isMultipart() {
return multipart;
}
/**
* Removes a request attribute.
*
* @param name The name of the attribute.
* @return The attribute if it exists.
*/
public Object removeAttribute(String name) {
return attributes.remove(name);
}
public void removeHeader(String name) {
headers.remove(name.toLowerCase());
}
public void removeHeader(String name, String... values) {
List actual = headers.get(name.toLowerCase());
if (actual != null) {
actual.removeAll(List.of(values));
}
}
/**
* Sets a request attribute.
*
* @param name The name to store the attribute under.
* @param value The attribute value.
*/
public void setAttribute(String name, Object value) {
attributes.put(name, value);
}
public void setHeader(String name, String value) {
name = name.toLowerCase();
this.headers.put(name, new ArrayList<>(List.of(value)));
decodeHeader(name, value);
}
public void setHeaders(String name, String... values) {
name = name.toLowerCase();
this.headers.put(name, new ArrayList<>(List.of(values)));
for (String value : values) {
decodeHeader(name, value);
}
}
public void setHeaders(String name, Collection values) {
name = name.toLowerCase();
this.headers.put(name, new ArrayList<>(values));
for (String value : values) {
decodeHeader(name, value);
}
}
public void setURLParameter(String name, String value) {
setURLParameters(name, value);
}
public void setURLParameters(String name, String... values) {
setURLParameters(name, new ArrayList<>(List.of(values)));
}
public void setURLParameters(String name, Collection values) {
List list = new ArrayList<>();
this.urlParameters.put(name, list);
values.stream()
.filter(Objects::nonNull)
.forEach(list::add);
combinedParameters = null;
}
private void decodeHeader(String name, String value) {
switch (name) {
case Headers.AcceptEncodingLower:
SortedSet weightedStrings = new TreeSet<>();
String[] parts = value.split(",");
int index = 0;
for (String part : parts) {
part = part.trim();
if (part.isEmpty()) {
continue;
}
HeaderValue parsed = HTTPTools.parseHeaderValue(part);
String weightText = parsed.parameters().get("q");
double weight = 1;
if (weightText != null) {
weight = Double.parseDouble(weightText);
}
WeightedString ws = new WeightedString(parsed.value(), weight, index);
weightedStrings.add(ws);
index++;
}
// Transfer the Strings in weighted-position order
setAcceptEncodings(
weightedStrings.stream()
.map(WeightedString::value)
.toList()
);
break;
case Headers.AcceptLanguageLower:
addLocales(LanguageRange.parse(value) // Default to English
.stream()
.sorted(Comparator.comparing(LanguageRange::getWeight).reversed())
.map(LanguageRange::getRange)
.map(Locale::forLanguageTag)
.collect(Collectors.toList()));
break;
case Headers.ContentTypeLower:
this.encoding = null;
this.multipart = false;
HeaderValue headerValue = HTTPTools.parseHeaderValue(value);
this.contentType = headerValue.value();
if (headerValue.value().startsWith(ContentTypes.MultipartPrefix)) {
this.multipart = true;
this.multipartBoundary = headerValue.parameters().get(ContentTypes.BoundaryParameter);
}
String charset = headerValue.parameters().get(ContentTypes.CharsetParameter);
if (charset != null) {
this.encoding = Charset.forName(charset);
}
break;
case Headers.ContentLengthLower:
if (value == null || value.isBlank()) {
contentLength = null;
} else {
try {
contentLength = Long.parseLong(value);
} catch (NumberFormatException e) {
contentLength = null;
}
}
break;
case Headers.CookieLower:
addCookies(Cookie.fromRequestHeader(value));
break;
case Headers.HostLower:
int colon = value.indexOf(':');
if (colon > 0) {
this.host = value.substring(0, colon);
} else {
this.host = value;
}
break;
}
}
}