com.seleritycorp.common.base.http.server.HttpRequest.java Source code

Java tutorial

Introduction

Here is the source code for com.seleritycorp.common.base.http.server.HttpRequest.java

Source

/*
 * Copyright (C) 2016-2018 Selerity, Inc. (support@seleritycorp.com)
 *
 * 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 com.seleritycorp.common.base.http.server;

import static com.seleritycorp.common.base.http.common.ContentType.APPLICATION_JSON;
import static com.seleritycorp.common.base.http.common.ContentType.TEXT_HTML;
import static com.seleritycorp.common.base.http.common.ContentType.TEXT_PLAIN;

import com.google.gson.JsonObject;
import com.google.inject.assistedinject.Assisted;

import com.seleritycorp.common.base.config.ApplicationConfig;
import com.seleritycorp.common.base.config.Config;
import com.seleritycorp.common.base.escape.Escaper;
import com.seleritycorp.common.base.http.common.ContentType;
import com.seleritycorp.common.base.logging.Level;
import com.seleritycorp.common.base.logging.Log;
import com.seleritycorp.common.base.logging.LogFactory;
import com.seleritycorp.common.base.time.TimeUtils;
import com.seleritycorp.common.base.uuid.UuidGenerator;

import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.server.Request;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.UUID;

import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * Http Request enhanced by tooling for common work flows.
 */
public class HttpRequest {
    private static final Log log = LogFactory.getLog(HttpRequest.class);

    public interface Factory {
        /**
         * Create a HttpRequest.
         * 
         * @param target Target URL for the handle request parameters
         * @param request Base Jetty request for the handle request parameters
         * @param httpServletRequest Http request for the handle request parameters
         * @param httpServletResponse Response for the handle request parameters
         * @return The created HttpRequest
         */
        HttpRequest create(String target, Request request, HttpServletRequest httpServletRequest,
                HttpServletResponse httpServletResponse);
    }

    private final String target;
    private final Request request;
    private final HttpServletRequest httpServletRequest;
    private final HttpServletResponse httpServletResponse;
    private final ForwardedForResolver forwardedForResolver;
    private final ContentTypeNegotiator contentTypeNegotiator;
    private final UuidGenerator uuidGenerator;
    private final Escaper escaper;
    private final TimeUtils timeUtils;
    private final String serverId;
    private final String supportEmailAddress;

    /**
     * Create a HttpRequest.
     * 
     * @param target Target URL for the handle request parameters
     * @param request Base Jetty request for the handle request parameters
     * @param httpServletRequest Http request for the handle request parameters
     * @param httpServletResponse Response for the handle request parameters
     * @param forwardedForResolver The resolver for IP addresses
     * @param contentTypeNegotiator The negotiator for ContentType handling.
     * @param uuidGenerator The generator for incident ids.
     * @param escaper Escaping for formatting output.
     * @param timeUtils utils for formatting times.
     * @param config The application's main configuration.
     */
    @Inject
    public HttpRequest(@Assisted String target, @Assisted Request request,
            @Assisted HttpServletRequest httpServletRequest, @Assisted HttpServletResponse httpServletResponse,
            ForwardedForResolver forwardedForResolver, ContentTypeNegotiator contentTypeNegotiator,
            UuidGenerator uuidGenerator, Escaper escaper, TimeUtils timeUtils, @ApplicationConfig Config config) {
        this.target = target;
        this.request = request;
        this.httpServletRequest = httpServletRequest;
        this.httpServletResponse = httpServletResponse;
        this.forwardedForResolver = forwardedForResolver;
        this.contentTypeNegotiator = contentTypeNegotiator;
        this.uuidGenerator = uuidGenerator;
        this.escaper = escaper;
        this.timeUtils = timeUtils;
        this.serverId = config.get("server.id", "n/a");
        this.supportEmailAddress = config.get("server.support.email", "support@selerityinc.com");
    }

    // -- Raw request objects ---------------------------------------------------

    /**
     * Gets the target URL of the handle request.
     * 
     * @return the target.
     */
    public String getTarget() {
        return target;
    }

    // -- Information extraction helpers ----------------------------------------

    /**
     * Gets the request's body as string.
     *
     * <p>This method may replace the request's line-breaks with line-breaks used on the current
     * system (E.g.: Replacing "\r\n" by "\n").
     *
     * @return The string representation of the request's body
     * @throws java.io.UnsupportedEncodingException if the character encoding is not supported.
     * @throws IllegalStateException if the request was read already.
     * @throws IOException if an input or output exception occurred
     */
    public String getRequestBodyAsString() throws IOException {
        try (BufferedReader reader = httpServletRequest.getReader()) {
            return IOUtils.toString(reader);
        }
    }

    /**
     * Checks if a request is a GET request.
     * 
     * @return true, if it is a GET request. false otherwise.
     */
    public boolean isMethodGet() {
        return HttpGet.METHOD_NAME.equals(httpServletRequest.getMethod());
    }

    /**
     * Checks if a request is a POST request.
     * 
     * @return true, if it is a POST request. false otherwise.
     */
    public boolean isMethodPost() {
        return HttpPost.METHOD_NAME.equals(httpServletRequest.getMethod());
    }

    /**
     * Checks if a request has been marked handlend.
     * 
     * @return true, if the request has been marked handled. false otherwise.
     */
    public boolean hasBeenHandled() {
        return request.isHandled();
    }

    /**
     * Resolves a remote address using X-Forwarded-For headers
     * 
     * <p>If requests pass through proxies, they are expected to set for which IP they proxied.
     * Not all proxies do that, and one cannot trust external proxies. But our internal proxies
     * do and we can trust them to not set bogus headers. So we use this information to determine
     * from which IP a request originates.
     *  
     * @return the IP address we attribute a request to.
     */
    public String getResolvedRemoteAddr() {
        String remoteAddr = httpServletRequest.getRemoteAddr();
        String forwardedFor = httpServletRequest.getHeader("X-Forwarded-For");
        return forwardedForResolver.resolve(remoteAddr, forwardedFor);
    }

    /**
     * Gets the most suitable ContentType for the response.
     *
     * <p>The client's preference is read from the request's headers, and it gets compared against
     * the candidates offered by the server. The best match will get returned.
     *
     * @param fallback The fallback value to use if there is no match between the client's
     *     preferences and the server's offerings.
     * @param candidates The offerings from the server.
     * @return The best match between the client's preferences and the server's offerings. If there
     *     is no match at all, the fallback value will get returned.
     */
    public ContentType getMostSuitableResponseContentType(ContentType fallback, ContentType... candidates) {
        String acceptHeader = httpServletRequest.getHeader("Accept");
        ContentType negotiated = contentTypeNegotiator.negotiate(acceptHeader, fallback, candidates);
        return negotiated;
    }

    // -- Response helpers  -----------------------------------------------------

    /**
     * Sends a response with status code to a request and marks it as handled.
     *  
     * @param status The status code to send
     * @param contentType The ContentType for the response.
     * @param response The response text
     * @throws java.io.UnsupportedEncodingException if the character encoding is unusable.
     * @throws IllegalStateException if a response was sent already.
     * @throws IOException if an input/output error occurs
     */
    private void respond(int status, ContentType contentType, String response) throws IOException {
        httpServletResponse.setStatus(status);
        httpServletResponse.setHeader("Server", serverId);

        if (response != null) {
            if (contentType != null) {
                httpServletResponse.setContentType(contentType.toString());
            }
            try (PrintWriter writer = httpServletResponse.getWriter()) {
                writer.print(response);
            }
        }
        setHandled();
    }

    private UUID logRequest(int status, ErrorCode errorCode, String clientExplanation) {
        final UUID incidentId = uuidGenerator.generate();
        log.structuredInfo("http-server-incident", 2, "incidentId", incidentId, "remoteAddress",
                httpServletRequest.getRemoteAddr(), "resolvedRemoteAddress", getResolvedRemoteAddr(), "method",
                httpServletRequest.getMethod(), "target", getTarget(), "status", status, "errorCode",
                (errorCode != null) ? errorCode.getIdentifier() : null, "clientExplanation", clientExplanation,
                "devExplanation", null, "throwable", null);
        return incidentId;
    }

    private UUID respondGenericIssue(int status, ErrorCode errorCode, String clientExplanation) throws IOException {
        return respondGenericIssue(status, errorCode, clientExplanation, Level.OFF, null, null);
    }

    private UUID respondGenericIssue(int status, ErrorCode errorCode, String clientExplanation, Level logLevel,
            String logMessage, Throwable logException) throws IOException {
        final UUID incidentId = logRequest(status, errorCode, clientExplanation);

        if (logLevel != Level.OFF) {
            logMessage = "(IncidentId: " + incidentId.toString() + ") " + logMessage;
            if (logException == null) {
                log.log(logLevel, logMessage);
            } else {
                log.log(logLevel, logMessage, logException);
            }
        }

        ContentType responseContentType = getMostSuitableResponseContentType(TEXT_PLAIN, TEXT_HTML,
                APPLICATION_JSON);
        String errorCodeIdentifier = (errorCode != null) ? errorCode.getIdentifier() : null;
        String msg = "";
        if (TEXT_PLAIN.equals(responseContentType)) {
            msg += "An error occurred for your request to " + getTarget() + "\n";
            msg += "\n";
            msg += clientExplanation + "\n";
            msg += "\n";
            msg += "Error code: " + errorCodeIdentifier + "\n";
            msg += "Explanation: " + clientExplanation + "\n";
            msg += "Incident id: " + incidentId + "\n";
            msg += "Server id: " + serverId + "\n";
            msg += "Server timestamp: " + timeUtils.formatTimeNanos() + "\n";
            msg += "\n";
            msg += "If the above is unexpected or you have questions, please let us know at " + supportEmailAddress
                    + " and attach the above data.\n";
        } else if (TEXT_HTML.equals(responseContentType)) {
            msg += "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"\n";
            msg += "  \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">\n";
            msg += "<html xmlns=\"http://www.w3.org/1999/xhtml\" lang=\"en\" xml:lang=\"en\">\n";
            msg += "  <head>\n";
            msg += "    <title>Error while accessing " + escaper.html(getTarget()) + "</title>\n";
            msg += "    <meta http-equiv=\"Content-Type\" content=\"text/html;charset=UTF-8\" />\n";
            msg += "    <style type=\"text/css\">\n";
            msg += "      table, tr, th, td {\n";
            msg += "        border: 1px solid black;\n";
            msg += "      }\n";
            msg += "      th, td {\n";
            msg += "        padding: 0.2em 0.5em 0.2em 0.5em;\n";
            msg += "      }\n";
            msg += "      table {\n";
            msg += "        border-collapse: collapse;\n";
            msg += "      }\n";
            msg += "      th {\n";
            msg += "        text-align: left;\n";
            msg += "      }\n";
            msg += "    </style>\n";
            msg += "  </head>\n";
            msg += "  <body>\n";
            msg += "    <p>An error occurred for your request to " + escaper.html(getTarget()) + "</p>\n";
            msg += "    <p>" + escaper.html(clientExplanation) + "</p>\n";
            msg += "    <table>\n";
            msg += "      <tr><th>Error code</th><td>" + escaper.html(errorCodeIdentifier) + "</td></tr>\n";
            msg += "      <tr><th style=\"text-align:left;\">Explanation</th><td>" + escaper.html(clientExplanation)
                    + "</td></tr>\n";
            msg += "      <tr><th style=\"text-align:left;\">Incident id</th><td>"
                    + escaper.html(incidentId.toString()) + "</td></tr>\n";
            msg += "      <tr><th style=\"text-align:left;\">Server id</th><td>" + escaper.html(serverId)
                    + "</td></tr>\n";
            msg += "      <tr><th style=\"text-align:left;\">Server time</th><td>" + timeUtils.formatTimeNanos()
                    + "</td></tr>\n";
            msg += "    </table>\n";
            msg += "    <p>If the above is unexpected or you have questions, please let us know at " + "<a href=\""
                    + escaper.html(supportEmailAddress) + "\">" + escaper.html(supportEmailAddress)
                    + "</a> and attach the above data.</p>\n";
            msg += "  </body>\n";
            msg += "</html>\n";
        } else if (APPLICATION_JSON.equals(responseContentType)) {
            JsonObject object = new JsonObject();
            object.addProperty("errorCode", errorCodeIdentifier);
            object.addProperty("explanation", clientExplanation);
            object.addProperty("target", getTarget());
            object.addProperty("incidentId", incidentId.toString());
            object.addProperty("serverId", serverId);
            object.addProperty("serverTimestamp", timeUtils.formatTimeNanos());
            object.addProperty("support", "If the above is unexpected or you have questions, please let "
                    + "us know at " + supportEmailAddress + " and attach this JSON blob.");
            msg = object.toString();
        } else {
            // This branch should never be reached. It's only hear for extra safety. 
            msg = "Unkonwn ContentType " + responseContentType;
        }
        respond(status, responseContentType, msg);
        return incidentId;
    }

    /**
     * Sends a '200 OK' response with a plain text response.
     *  
     * @param response The text response to send
     * @throws java.io.UnsupportedEncodingException if the character encoding is unusable.
     * @throws IllegalStateException if a response was sent already.
     * @throws IOException if an input/output error occurs
     */
    public void respondOkText(String response) throws IOException {
        respond(HttpStatus.OK_200, ContentType.TEXT_PLAIN, response);
    }

    /**
     * Sends a '204 No Content' response to a request and marks it as handled.
     *  
     * @throws java.io.UnsupportedEncodingException if the character encoding is unusable.
     * @throws IllegalStateException if a response was sent already.
     * @throws IOException if an input/output error occurs
     */
    public void respondNoContent() throws IOException {
        respond(HttpStatus.NO_CONTENT_204, null, null);
    }

    /**
     * Sends a '400 Bad Request' response to a request and marks it as handled.
     *  
     * @param errorCode The error describing why the client request is considered bad.
     * @param clientExplanation An explanation of the issue to show to the client/user.
     * @return The issue id associated with this response.
     * @throws java.io.UnsupportedEncodingException if the character encoding is unusable.
     * @throws IllegalStateException if a response was sent already.
     * @throws IOException if an input/output error occurs
     */
    public UUID respondBadRequest(ErrorCode errorCode, String clientExplanation) throws IOException {
        if (clientExplanation == null) {
            clientExplanation = errorCode.getDefaultReason();
        }
        final int status = HttpStatus.BAD_REQUEST_400;
        return respondGenericIssue(status, errorCode, clientExplanation);
    }

    /**
     * Sends a '400 Bad Request' response to a request and marks it as handled.
     *  
     * @param errorCode The error describing why the client request is considered bad.
     * @return The issue id associated with this response.
     * @throws java.io.UnsupportedEncodingException if the character encoding is unusable.
     * @throws IllegalStateException if a response was sent already.
     * @throws IOException if an input/output error occurs
     */
    public UUID respondBadRequest(ErrorCode errorCode) throws IOException {
        return respondBadRequest(errorCode, null);
    }

    /**
     * Sends a '403 Forbidden' response to a request and marks it as handled.
     *  
     * @return The issue id associated with this response.
     * @throws java.io.UnsupportedEncodingException if the character encoding is unusable.
     * @throws IllegalStateException if a response was sent already.
     * @throws IOException if an input/output error occurs
     */
    public UUID respondForbidden() throws IOException {
        ErrorCode errorCode = BasicErrorCode.E_FORBIDDEN;
        String clientExplanation = errorCode.getDefaultReason();
        final int status = HttpStatus.FORBIDDEN_403;
        return respondGenericIssue(status, errorCode, clientExplanation);
    }

    /**
     * Sends a '404 Not Found' response to a request and marks it as handled.
     *  
     * @return The issue id associated with this response.
     * @throws java.io.UnsupportedEncodingException if the character encoding is unusable.
     * @throws IllegalStateException if a response was sent already.
     * @throws IOException if an input/output error occurs
     */
    public UUID respondNotFound() throws IOException {
        ErrorCode errorCode = BasicErrorCode.E_NOT_FOUND;
        String clientExplanation = errorCode.getDefaultReason();
        final int status = HttpStatus.NOT_FOUND_404;
        return respondGenericIssue(status, errorCode, clientExplanation);
    }

    /**
     * Sends a '500 Internal Server Error' response to a request and marks it as handled.
     *  
     * @param logMessage The message to log at error level. This message is only meant for internal
     *     consumption and will not be visible to the client. The client will see a generic error
     *     message.
     * @param logException The exception to log. If null, no exception will get logged.
     * @return The issue id associated with this response.
     * @throws java.io.UnsupportedEncodingException if the character encoding is unusable.
     * @throws IllegalStateException if a response was sent already.
     * @throws IOException if an input/output error occurs
     */
    public UUID respondInternalServerError(String logMessage, Throwable logException) throws IOException {
        ErrorCode errorCode = BasicErrorCode.E_INTERNAL_SERVER_ERROR;
        String clientExplanation = errorCode.getDefaultReason();
        final int status = HttpStatus.INTERNAL_SERVER_ERROR_500;
        return respondGenericIssue(status, errorCode, clientExplanation, Level.ERROR, logMessage, logException);
    }

    /**
     * Marks a request as handled.
     */
    public void setHandled() {
        request.setHandled(true);
    }
}