// Copyright (c) 2003-present, Jodd Team (http://jodd.org) // All rights reserved. // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: // // 1. Redistributions of source code must retain the above copyright notice, // this list of conditions and the following disclaimer. // // 2. Redistributions in binary form must reproduce the above copyright // notice, this list of conditions and the following disclaimer in the // documentation and/or other materials provided with the distribution. // // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE // ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE // LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR // CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF // SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS // INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN // CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE // POSSIBILITY OF SUCH DAMAGE. package jodd.http; import jodd.util.Base64; import jodd.util.StringBand; import jodd.util.StringPool; import jodd.util.StringUtil; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.util.Map; import static jodd.util.StringPool.CRLF; import static jodd.util.StringPool.SPACE; /** * HTTP request. */ public class HttpRequest extends HttpBase<HttpRequest> { private static final int DEFAULT_PORT = -1; private static final String METHOD_CONNECT = "CONNECT"; private static final String METHOD_GET = "GET"; private static final String METHOD_POST = "POST"; private static final String METHOD_PUT = "PUT"; private static final String METHOD_PATCH = "PATCH"; private static final String METHOD_DELETE = "DELETE"; private static final String METHOD_HEAD = "HEAD"; private static final String METHOD_TRACE = "TRACE"; private static final String METHOD_OPTIONS = "OPTIONS"; protected String protocol = "http"; protected String host = "localhost"; protected int port = DEFAULT_PORT; protected String method = "GET"; protected String path = StringPool.SLASH; protected HttpMultiMap<String> query; // ---------------------------------------------------------------- init public HttpRequest() { initRequest(); } /** * Prepares request on creation. By default, it just * adds "Connection: Close" header. */ protected void initRequest() { connectionKeepAlive(false); } // ---------------------------------------------------------------- properties /** * Returns request host name. */ public String host() { return host; } /** * Sets request host name. */ public HttpRequest host(String host) { this.host = host; return this; } /** * Returns used protocol. By default it's "http". */ public String protocol() { return protocol; } /** * Defines protocol. */ public HttpRequest protocol(String protocol) { this.protocol = protocol; return this; } /** * Returns request port number. When port is not * explicitly defined, returns default port for * current protocol. */ public int port() { if (port == DEFAULT_PORT) { if (protocol == null) { return 80; } if (protocol.equalsIgnoreCase("https")) { return 443; } return 80; } return port; } /** * Sets request port number. */ public HttpRequest port(int port) { this.port = port; return this; } // ---------------------------------------------------------------- set /** * Sets the destination (method, host, port... ) at once. */ public HttpRequest set(String destination) { destination = destination.trim(); // http method, optional int ndx = destination.indexOf(' '); if (ndx != -1) { String method = destination.substring(0, ndx).toUpperCase(); switch (method) { case METHOD_CONNECT: case METHOD_DELETE: case METHOD_GET: case METHOD_HEAD: case METHOD_OPTIONS: case METHOD_PATCH: case METHOD_POST: case METHOD_PUT: case METHOD_TRACE: this.method = method; destination = destination.substring(ndx + 1); break; default: } } // protocol ndx = destination.indexOf("://"); if (ndx != -1) { protocol = destination.substring(0, ndx); destination = destination.substring(ndx + 3); } // host ndx = destination.indexOf('/'); if (ndx == -1) { ndx = destination.length(); } if (ndx != 0) { host = destination.substring(0, ndx); destination = destination.substring(ndx); // port ndx = host.indexOf(':'); if (ndx == -1) { port = DEFAULT_PORT; } else { port = Integer.parseInt(host.substring(ndx + 1)); host = host.substring(0, ndx); } } // path + query path(destination); return this; } // ---------------------------------------------------------------- static factories /** * Generic request builder, usually used when method is a variable. * Otherwise, use one of the other static request builder methods. */ public static HttpRequest create(String method, String destination) { return new HttpRequest() .method(method.toUpperCase()) .set(destination); } /** * Builds a CONNECT request. */ public static HttpRequest connect(String destination) { return new HttpRequest() .method(METHOD_CONNECT) .set(destination); } /** * Builds a GET request. */ public static HttpRequest get(String destination) { return new HttpRequest() .method(METHOD_GET) .set(destination); } /** * Builds a POST request. */ public static HttpRequest post(String destination) { return new HttpRequest() .method(METHOD_POST) .set(destination); } /** * Builds a PUT request. */ public static HttpRequest put(String destination) { return new HttpRequest() .method(METHOD_PUT) .set(destination); } /** * Builds a PATCH request. */ public static HttpRequest patch(String destination) { return new HttpRequest() .method(METHOD_PATCH) .set(destination); } /** * Builds a DELETE request. */ public static HttpRequest delete(String destination) { return new HttpRequest() .method(METHOD_DELETE) .set(destination); } /** * Builds a HEAD request. */ public static HttpRequest head(String destination) { return new HttpRequest() .method(METHOD_HEAD) .set(destination); } /** * Builds a TRACE request. */ public static HttpRequest trace(String destination) { return new HttpRequest() .method(METHOD_TRACE) .set(destination); } /** * Builds an OPTIONS request. */ public static HttpRequest options(String destination) { return new HttpRequest() .method(METHOD_OPTIONS) .set(destination); } // ---------------------------------------------------------------- request /** * Returns request method. */ public String method() { return method; } /** * Specifies request method. It will be converted into uppercase. * Does not validate if method is one of the HTTP methods. */ public HttpRequest method(String method) { this.method = method.toUpperCase(); return this; } /** * Returns request path, without the query. */ public String path() { return path; } /** * Sets request path. Query string is allowed. * Adds a slash if path doesn't start with one. * Query will be stripped out from the path. * Previous query is discarded. * @see #query() */ public HttpRequest path(String path) { // this must be the only place that sets the path if (!path.startsWith(StringPool.SLASH)) { path = StringPool.SLASH + path; } int ndx = path.indexOf('?'); if (ndx != -1) { String queryString = path.substring(ndx + 1); path = path.substring(0, ndx); query = HttpUtil.parseQuery(queryString, true); } else { query = HttpMultiMap.newCaseInsensitveMap(); } this.path = path; return this; } /** * Forces multipart requests. When set to <code>false</code>, * it will be {@link #isFormMultipart() detected} if request * should be multipart. By setting this to <code>true</code> * we are forcing usage of multipart request. */ public HttpRequest multipart(boolean multipart) { this.multipart = multipart; return this; } // ---------------------------------------------------------------- cookies /** * Sets cookies to the request. */ public HttpRequest cookies(Cookie... cookies) { if (cookies.length == 0) { return this; } StringBuilder cookieString = new StringBuilder(); boolean first = true; for (Cookie cookie : cookies) { Integer maxAge = cookie.getMaxAge(); if (maxAge != null && maxAge.intValue() == 0) { continue; } if (!first) { cookieString.append("; "); } first = false; cookieString.append(cookie.getName()); cookieString.append('='); cookieString.append(cookie.getValue()); } header("cookie", cookieString.toString(), true); return this; } // ---------------------------------------------------------------- query /** * Adds query parameter. */ public HttpRequest query(String name, String value) { query.add(name, value); return this; } /** * Adds many query parameters at once. Although it accepts objects, * each value will be converted to string. */ public HttpRequest query(String name1, Object value1, Object... parameters) { query(name1, value1 == null ? null : value1.toString()); for (int i = 0; i < parameters.length; i += 2) { String name = parameters[i].toString(); String value = parameters[i + 1].toString(); query.add(name, value == null ? null : value); } return this; } /** * Adds all parameters from the provided map. */ public HttpRequest query(Map<String, String> queryMap) { for (Map.Entry<String, String> entry : queryMap.entrySet()) { query.add(entry.getKey(), entry.getValue()); } return this; } /** * Returns backend map of query parameters. */ public HttpMultiMap<String> query() { return query; } /** * Clears all query parameters. */ public HttpRequest clearQueries() { query.clear(); return this; } /** * Removes query parameters for given name. */ public HttpRequest removeQuery(String name) { query.remove(name); return this; } // ---------------------------------------------------------------- queryString /** * @see #queryString(String, boolean) */ public HttpRequest queryString(String queryString) { return queryString(queryString, true); } /** * Sets query from provided query string. Previous query values * are discarded. */ public HttpRequest queryString(String queryString, boolean decode) { this.query = HttpUtil.parseQuery(queryString, decode); return this; } /** * Generates query string. All values are URL encoded. */ public String queryString() { if (query == null) { return StringPool.EMPTY; } return HttpUtil.buildQuery(query, queryEncoding); } // ---------------------------------------------------------------- query encoding protected String queryEncoding = JoddHttp.defaultQueryEncoding; /** * Defines encoding for query parameters. Default value is * copied from {@link JoddHttp#defaultQueryEncoding}. */ public HttpRequest queryEncoding(String encoding) { this.queryEncoding = encoding; return this; } // ---------------------------------------------------------------- full path /** * Returns full URL path. * Simply concatenates {@link #protocol(String) protocol}, {@link #host(String) host}, * {@link #port(int) port}, {@link #path(String) path} and {@link #queryString(String) query string}. */ public String url() { StringBuilder url = new StringBuilder(); url.append(hostUrl()); if (path != null) { url.append(path); } String queryString = queryString(); if (StringUtil.isNotBlank(queryString)) { url.append('?'); url.append(queryString); } return url.toString(); } /** * Returns just host url, without path and query. */ public String hostUrl() { StringBand url = new StringBand(8); if (protocol != null) { url.append(protocol); url.append("://"); } if (host != null) { url.append(host); } if (port != DEFAULT_PORT) { url.append(':'); url.append(port); } return url.toString(); } // ---------------------------------------------------------------- auth /** * Enables basic authentication by adding required header. */ public HttpRequest basicAuthentication(String username, String password) { if (username != null && password != null) { String data = username.concat(StringPool.COLON).concat(password); String base64 = Base64.encodeToString(data); header("Authorization", "Basic " + base64, true); } return this; } // ---------------------------------------------------------------- https private boolean trustAllCertificates; private boolean verifyHttpsHost = true; /** * Trusts all certificates, use with caution. */ public HttpRequest trustAllCerts(boolean trust) { trustAllCertificates = trust; return this; } /** * Returns a flag if to trusts all certificates. */ public boolean trustAllCertificates() { return trustAllCertificates; } /** * Verifies HTTPS hosts. */ public HttpRequest verifyHttpsHost(boolean verifyHttpsHost) { this.verifyHttpsHost = verifyHttpsHost; return this; } /** * Returns a flag if to verify https hosts. */ public boolean verifyHttpsHost() { return verifyHttpsHost; } // ---------------------------------------------------------------- misc /** * Sets 'Host' header from current host and port. */ public HttpRequest setHostHeader() { String hostPort = this.host; if (port != DEFAULT_PORT) { hostPort += StringPool.COLON + port; } header(HEADER_HOST, hostPort, true); return this; } // ---------------------------------------------------------------- monitor /** * Registers {@link jodd.http.HttpProgressListener listener} that will * monitor upload progress. Be aware that the whole size of the * request is being monitored, not only the files content. */ public HttpRequest monitor(HttpProgressListener httpProgressListener) { this.httpProgressListener = httpProgressListener; return this; } // ---------------------------------------------------------------- connection properties protected int timeout = -1; protected int connectTimeout = -1; /** * Defines the socket timeout (SO_TIMEOUT) in milliseconds, which is the timeout for waiting for data or, * put differently, a maximum period inactivity between two consecutive data packets). * After establishing the connection, the client socket waits for response after sending * the request. This is the elapsed time since the client has sent request to the * server before server responds. Please note that this is not same as HTTP Error 408 which * the server sends to the client. In other words its maximum period inactivity between * two consecutive data packets arriving at client side after connection is established. * A timeout value of zero is interpreted as an infinite timeout. * @see jodd.http.HttpConnection#setTimeout(int) */ public HttpRequest timeout(int milliseconds) { this.timeout = milliseconds; return this; } /** * Returns read timeout (SO_TIMEOUT) in milliseconds. Negative value * means that default value is used. * @see #timeout(int) */ public int timeout() { return timeout; } /** * Defines the socket timeout (SO_TIMEOUT) in milliseconds, which is the timeout * for waiting for data or, put differently, a maximum period inactivity between * two consecutive data packets). A timeout value of zero is interpreted as * an infinite timeout. */ public HttpRequest connectionTimeout(int milliseconds) { this.connectTimeout = milliseconds; return this; } /** * Returns socket connection timeout. Negative value means that default * value is used. * @see #connectionTimeout(int) */ public int connectionTimeout() { return connectTimeout; } // ---------------------------------------------------------------- send protected HttpConnection httpConnection; protected HttpConnectionProvider httpConnectionProvider; /** * Uses custom connection provider when {@link #open() opening} the * connection. */ public HttpRequest withConnectionProvider(HttpConnectionProvider httpConnectionProvider) { this.httpConnectionProvider = httpConnectionProvider; return this; } /** * Returns http connection provider that was used for creating * current http connection. If <code>null</code>, default * connection provider will be used. */ public HttpConnectionProvider connectionProvider() { return httpConnectionProvider; } /** * Returns {@link HttpConnection} that is going to be * used for sending this request. Value is available * ONLY after calling {@link #open()} and before {@link #send()}. */ public HttpConnection connection() { return httpConnection; } /** * Opens a new {@link HttpConnection connection} using either * provided or {@link JoddHttp#httpConnectionProvider default} connection * provider. */ public HttpRequest open() { if (httpConnectionProvider == null) { return open(JoddHttp.httpConnectionProvider); } return open(httpConnectionProvider); } /** * Opens a new {@link jodd.http.HttpConnection connection} * using given {@link jodd.http.HttpConnectionProvider}. */ public HttpRequest open(HttpConnectionProvider httpConnectionProvider) { if (this.httpConnection != null) { throw new HttpException("Connection already opened"); } try { this.httpConnectionProvider = httpConnectionProvider; this.httpConnection = httpConnectionProvider.createHttpConnection(this); } catch (IOException ioex) { throw new HttpException("Can't connect to: " + url(), ioex); } return this; } /** * Assignees provided {@link jodd.http.HttpConnection} for communication. * It does not actually opens it until the {@link #send() sending}. */ public HttpRequest open(HttpConnection httpConnection) { if (this.httpConnection != null) { throw new HttpException("Connection already opened"); } this.httpConnection = httpConnection; this.httpConnectionProvider = null; return this; } /** * Continues using the same keep-alive connection. * Don't use any variant of <code>open()</code> when * continuing the communication! * First it checks if "Connection" header exist in the response * and if it is equal to "Keep-Alive" value. Then it * checks the "Keep-Alive" headers "max" parameter. * If its value is positive, then the existing {@link jodd.http.HttpConnection} * from the request will be reused. If max value is 1, * connection will be sent with "Connection: Close" header, indicating * its the last request. When new connection is created, the * same {@link jodd.http.HttpConnectionProvider} that was used for * creating initial connection is used for opening the new connection. * * @param doContinue set it to <code>false</code> to indicate the last connection */ public HttpRequest keepAlive(HttpResponse httpResponse, boolean doContinue) { boolean keepAlive = httpResponse.isConnectionPersistent(); if (keepAlive) { HttpConnection previousConnection = httpResponse.getHttpRequest().httpConnection; if (previousConnection != null) { // keep using the connection! this.httpConnection = previousConnection; this.httpConnectionProvider = httpResponse.getHttpRequest().connectionProvider(); } //keepAlive = true; (already set) } else { // close previous connection httpResponse.close(); // force keep-alive on new request keepAlive = true; } // if we don't want to continue with this persistent session, mark this connection as closed if (!doContinue) { keepAlive = false; } connectionKeepAlive(keepAlive); // if connection is not opened, open it using previous connection provider if (httpConnection == null) { open(httpResponse.getHttpRequest().connectionProvider()); } return this; } /** * {@link #open() Opens connection} if not already open, sends request, * reads response and closes the request. If keep-alive mode is enabled * connection will not be closed. */ public HttpResponse send() { return _send(); } private HttpResponse _send() { if (httpConnection == null) { open(); } // sends data HttpResponse httpResponse; try { OutputStream outputStream = httpConnection.getOutputStream(); sendTo(outputStream); InputStream inputStream = httpConnection.getInputStream(); httpResponse = HttpResponse.readFrom(inputStream); httpResponse.assignHttpRequest(this); } catch (IOException ioex) { throw new HttpException(ioex); } boolean keepAlive = httpResponse.isConnectionPersistent(); if (!keepAlive) { // closes connection if keep alive is false, or if counter reached 0 httpConnection.close(); httpConnection = null; } return httpResponse; } // ---------------------------------------------------------------- buffer /** * Prepares the request buffer. */ @Override protected Buffer buffer(boolean fullRequest) { // INITIALIZATION // host port if (header(HEADER_HOST) == null) { setHostHeader(); } // form Buffer formBuffer = formBuffer(); // query string String queryString = queryString(); // user-agent if (header("User-Agent") == null) { header("User-Agent", JoddHttp.defaultUserAgent); } // POST method requires Content-Type to be set if (method.equals("POST") && (contentLength() == null)) { contentLength(0); } // BUILD OUT Buffer request = new Buffer(); request.append(method) .append(SPACE) .append(path); if (query != null && !query.isEmpty()) { request.append('?'); request.append(queryString); } request.append(SPACE) .append(httpVersion) .append(CRLF); populateHeaderAndBody(request, formBuffer, fullRequest); return request; } // ---------------------------------------------------------------- parse /** * Parses input stream and creates new <code>HttpRequest</code> object. * Assumes input stream is in ISO_8859_1 encoding. */ public static HttpRequest readFrom(InputStream in) { return readFrom(in, StringPool.ISO_8859_1); } public static HttpRequest readFrom(InputStream in, String encoding) { BufferedReader reader; try { reader = new BufferedReader(new InputStreamReader(in, encoding)); } catch (UnsupportedEncodingException uneex) { return null; } HttpRequest httpRequest = new HttpRequest(); String line; try { line = reader.readLine(); } catch (IOException ioex) { throw new HttpException(ioex); } if (!StringUtil.isBlank(line)) { String[] s = StringUtil.splitc(line, ' '); httpRequest.method(s[0]); httpRequest.path(s[1]); httpRequest.httpVersion(s[2]); httpRequest.readHeaders(reader); httpRequest.readBody(reader); } return httpRequest; } }