SourceResponseHandler.java

/*
 *  Copyright (c) 2022, WSO2 Inc. (http://www.wso2.org) All Rights Reserved.
 *
 *  WSO2 Inc. licenses this file to you 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 org.apache.synapse.transport.netty.sender;

import io.netty.handler.codec.http.DefaultHttpResponse;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpVersion;
import org.apache.axiom.om.OMOutputFormat;
import org.apache.axis2.AxisFault;
import org.apache.axis2.Constants;
import org.apache.axis2.context.MessageContext;
import org.apache.axis2.transport.MessageFormatter;
import org.apache.axis2.transport.http.HTTPConstants;
import org.apache.axis2.util.MessageProcessorSelector;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.HttpStatus;
import org.apache.synapse.transport.netty.BridgeConstants;
import org.apache.synapse.transport.netty.config.SourceConfiguration;
import org.apache.synapse.transport.netty.util.CacheUtils;
import org.apache.synapse.transport.netty.util.HttpUtils;
import org.apache.synapse.transport.netty.util.MessageUtils;
import org.apache.synapse.transport.netty.util.RequestResponseUtils;
import org.apache.synapse.transport.passthru.util.PassThroughTransportUtils;
import org.wso2.caching.CachingConstants;
import org.wso2.transport.http.netty.contract.config.ChunkConfig;
import org.wso2.transport.http.netty.message.Http2PushPromise;
import org.wso2.transport.http.netty.message.HttpCarbonMessage;

import java.io.IOException;
import java.io.OutputStream;
import java.util.Objects;

/**
 * {@code SourceResponseHandler} have utilities for creating and preparing an outbound response to be sent
 * to the HTTP client.
 */
public class SourceResponseHandler {

    private static final Log LOG = LogFactory.getLog(SourceResponseHandler.class);

    /**
     * Creates outbound response to be sent back to the HTTP client using the axis2 message context and
     * the original client request.
     *
     * @param msgCtx        axis2 message context
     * @param clientRequest original HTTP client request
     * @return the outbound response HttpCarbonMessage
     * @throws AxisFault if something goes wrong when creating the outbound response
     */
    public static HttpCarbonMessage createOutboundResponseMsg(MessageContext msgCtx, HttpCarbonMessage clientRequest)
            throws AxisFault {

        HttpVersion version = new HttpVersion(BridgeConstants.HTTP_2_0, true);
        if (BridgeConstants.HTTP_1_1_VERSION.equals(((SourceConfiguration) msgCtx.getProperty(BridgeConstants.HTTP_SOURCE_CONFIGURATION)).getProtocol())) {
            version = HttpVersion.HTTP_1_1;
        }
        HttpCarbonMessage outboundResponseMsg = new HttpCarbonMessage(new DefaultHttpResponse(version,
                HttpResponseStatus.OK));
        try {
            handleMTOM(msgCtx);
            handleETAGCaching(clientRequest, outboundResponseMsg, msgCtx);
            if (isValidCacheResponse(msgCtx)) {
                return outboundResponseMsg;
            }
            prepareOutboundResponse(clientRequest, outboundResponseMsg, msgCtx);
        } catch (AxisFault e) {
            RequestResponseUtils.handleException("Error while creating the outbound response!", e);
        }
        return outboundResponseMsg;
    }

    /**
     * Retrieve the push promise received from the backend server.
     *
     * @param msgCtx axis2 message context
     * @return htttp2PushPromise Http2PushPromise
     * @throws AxisFault if push promise is not available
     */
    public static Http2PushPromise getPushPromise(MessageContext msgCtx) throws AxisFault {

        Http2PushPromise http2PushPromise = (Http2PushPromise) msgCtx.getProperty(BridgeConstants.PUSH_PROMISE);
        if (http2PushPromise == null) {
            RequestResponseUtils.handleException("Error while retrieving push promise.");
        }
        return http2PushPromise;
    }

    private static void handleMTOM(MessageContext msgCtx) throws AxisFault {

        if (isMTOMEnabled(msgCtx)) {
            try {
                MessageUtils.buildMessage(msgCtx);
            } catch (IOException e) {
                RequestResponseUtils.handleException("IO Error occurred while building the message", e);
            }
        }
    }

    private static void prepareOutboundResponse(HttpCarbonMessage inboundRequestMsg,
                                                HttpCarbonMessage outboundResponseMsg, MessageContext msgContext) {

        setOutboundResponseProperties(outboundResponseMsg, msgContext);
        setOutboundResponseHeaders(inboundRequestMsg, outboundResponseMsg, msgContext);
    }

    private static void setOutboundResponseProperties(HttpCarbonMessage outboundResponseMsg,
                                                      MessageContext msgContext) {

        setHttpVersion(outboundResponseMsg, msgContext);

        int statusCode = determineHttpStatusCode(msgContext);
        outboundResponseMsg.setHttpStatusCode(statusCode);

        setReasonPhrase(outboundResponseMsg, msgContext, statusCode);
    }

    private static void setOutboundResponseHeaders(HttpCarbonMessage inboundRequestMsg,
                                                   HttpCarbonMessage outboundResponseMsg, MessageContext msgContext) {

        HttpUtils.removeUnwantedHeadersFromInternalTransportHeadersMap(msgContext,
                (SourceConfiguration) msgContext.getProperty(BridgeConstants.HTTP_SOURCE_CONFIGURATION));
        HttpUtils.addTransportHeadersToTransportMessage(outboundResponseMsg.getHeaders(), msgContext);
        setChunkingHeader(inboundRequestMsg, outboundResponseMsg, msgContext);
        setContentTypeHeader(outboundResponseMsg, msgContext);
    }

    private static void setContentTypeHeader(HttpCarbonMessage outboundResponseMsg, MessageContext msgContext) {

        if (!canOutboundResponseHaveContentTypeHeader(msgContext)) {
            outboundResponseMsg.removeHeader(BridgeConstants.CONTENT_TYPE_HEADER);
            return;
        }

        if (shouldOverwriteOutboundResponseContentTypeHeader(msgContext)) {
            try {
                String contentType = getContentTypeForOutboundResponse(msgContext);
                outboundResponseMsg.setHeader(BridgeConstants.CONTENT_TYPE_HEADER, contentType);
            } catch (AxisFault axisFault) {
                LOG.error("Error occurred while setting the Content-Type header. Hence, not overwriting the "
                        + "outbound response Content-Type Header");
            }
        }
    }

    private static boolean shouldOverwriteOutboundResponseContentTypeHeader(MessageContext msgContext) {

        // the below null check is to decide whether the last call is an http request or not.
        // If it is an http and if the request has been built, we need to overwrite the Content-Type header.
        // Or else, if the http request was a pass through one, we do not need to modify the
        // Content-Type header.
        if (RequestResponseUtils.isHttpCarbonMessagePresent(msgContext)) {
            return !isPassThroughMessage(msgContext);
        }
        return true;
    }

    public static boolean canOutboundResponseHaveContentTypeHeader(MessageContext msgContext) {
        //TODO: verify this -> if we do not have a content to be sent in the source response, can we just
        // skip setting the content-type header?
        return true;
    }

    public static String getContentTypeForOutboundResponse(MessageContext msgContext) throws AxisFault {
        //This is to support MTOM in response path for requests sent without a SOAPAction. The reason is
        //axis2 selects application/xml formatter as the formatter for formatting the ESB to client response
        //when there is no SOAPAction.
        if (msgContext.isPropertyTrue(Constants.Configuration.ENABLE_MTOM)
                || msgContext.isPropertyTrue(Constants.Configuration.ENABLE_SWA)) {
            Object contentType = msgContext.getProperty(Constants.Configuration.CONTENT_TYPE);
            // The following condition will allow us to set the content-type as multipart/related if and only if
            // the content type is null or not starts with multipart/related. We cannot blindly set the content-type
            // as multipart/related as it would replace the multipart content-type with MIME boundary and the cause the
            // issue of response dropping the MIME boundary.
            if (Objects.isNull(contentType)
                    || !((String) contentType).trim().startsWith(HTTPConstants.MEDIA_TYPE_MULTIPART_RELATED)) {
                msgContext.setProperty(Constants.Configuration.CONTENT_TYPE,
                        HTTPConstants.MEDIA_TYPE_MULTIPART_RELATED);
            }
            msgContext.setProperty(Constants.Configuration.MESSAGE_TYPE,
                    HTTPConstants.MEDIA_TYPE_MULTIPART_RELATED);
        }

        Object contentTypeInMsgCtx = msgContext.getProperty(Constants.Configuration.CONTENT_TYPE);
        OMOutputFormat format = PassThroughTransportUtils.getOMOutputFormat(msgContext);

        // If ContentType header is set in the axis2 message context, use it.
        if (contentTypeInMsgCtx != null) {
            String contentTypeValueInMsgCtx = contentTypeInMsgCtx.toString();
            // Skip multipart/related as it should be taken from formatter.
            if (!(contentTypeValueInMsgCtx.contains(HTTPConstants.MEDIA_TYPE_MULTIPART_RELATED)
                    || contentTypeValueInMsgCtx.contains(HTTPConstants.MEDIA_TYPE_MULTIPART_FORM_DATA))) {

                // adding charset only if charset is not available,
                if (!contentTypeValueInMsgCtx.contains(HTTPConstants.CHAR_SET_ENCODING)
                        && msgContext.isPropertyTrue(BridgeConstants.SET_CHARACTER_ENCODING, true)) {
                    String encoding = format.getCharSetEncoding();
                    if (encoding != null) {
                        contentTypeValueInMsgCtx += "; charset=" + encoding;
                    }
                }
                return contentTypeValueInMsgCtx;
            }
        }

        // If ContentType is not set from msg context, get the formatter ContentType
        MessageFormatter formatter = null;
        try {
            formatter = MessageProcessorSelector.getMessageFormatter(msgContext);
        } catch (AxisFault e) {
            RequestResponseUtils.handleException("Cannot find a suitable MessageFormatter.", e);
        }
        return formatter.getContentType(msgContext, format, msgContext.getSoapAction());
    }

    /**
     * The pass through (when message body is not built) status of the message.
     *
     * @return true if it is a pass through.
     */
    public static boolean isPassThroughMessage(MessageContext msgContext) {

        boolean builderInvoked = Boolean.TRUE.equals(
                msgContext.getProperty(BridgeConstants.MESSAGE_BUILDER_INVOKED));
        return !builderInvoked;
    }

    private static void setHttpVersion(HttpCarbonMessage outboundResponseMsg, MessageContext msgContext) {

        String version = determineHttpVersion(msgContext);
        outboundResponseMsg.setHttpVersion(version);
    }

    private static void setReasonPhrase(HttpCarbonMessage outboundResponseMsg, MessageContext msgContext,
                                        int statusCode) {

        String reasonPhrase = determineResponseReasonPhrase(msgContext, statusCode);
        // Whenever the reason phrase is null, the transport-http will infer the correct reason phrase based on the
        // provided status code.
        outboundResponseMsg.setProperty(org.wso2.transport.http.netty.contract.Constants.HTTP_REASON_PHRASE,
                reasonPhrase);
    }

    private static void setChunkingHeader(HttpCarbonMessage inboundRequestMsg, HttpCarbonMessage outboundResponseMsg,
                                          MessageContext msgContext) {

        boolean canHaveContentLengthOrTransferEncodingHeader =
                checkContentLengthAndTransferEncodingHeaderAllowance(inboundRequestMsg.getHttpMethod(),
                        outboundResponseMsg.getHttpStatusCode());
        if (canHaveContentLengthOrTransferEncodingHeader) {
            if (disableChunking(msgContext)) {
                outboundResponseMsg.setProperty(BridgeConstants.CHUNKING_CONFIG, ChunkConfig.NEVER);
            } else {
                outboundResponseMsg.setProperty(BridgeConstants.CHUNKING_CONFIG, ChunkConfig.ALWAYS);
            }
        }
    }

    private static boolean checkContentLengthAndTransferEncodingHeaderAllowance(String httpMethod, int statusCode) {
        // According to RFC 7230 - HTTP/1.1 Message Syntax and Routing - Message Body Length, the following logic
        // was implemented.
        if (BridgeConstants.HTTP_HEAD.equalsIgnoreCase(httpMethod)) {
            // Any response to a HEAD request
            return false;
        } else if (BridgeConstants.HTTP_CONNECT.equals(httpMethod)) {
            // Any 2xx (Successful) response to a CONNECT request
            return (statusCode / 100 != 2);
        }

        // Any response with a 1xx (Informational), 204 (No Content), or 304 (Not Modified) status code
        return statusCode >= HttpStatus.SC_OK
                && statusCode != HttpStatus.SC_NO_CONTENT
                && statusCode != HttpStatus.SC_NOT_MODIFIED
                && statusCode != HttpStatus.SC_RESET_CONTENT;
    }

    private static boolean disableChunking(MessageContext msgContext) {

        if (msgContext.isPropertyTrue(BridgeConstants.FORCE_HTTP_CONTENT_LENGTH)) {
            return true;
        } else {
            String disableChunking = (String) msgContext.getProperty(BridgeConstants.DISABLE_CHUNKING);
            return Constants.VALUE_TRUE.equals(disableChunking)
                    || Constants.VALUE_TRUE.equals(msgContext.getProperty(BridgeConstants.FORCE_HTTP_1_0));
        }
    }

    private static String determineHttpVersion(MessageContext msgContext) {

        if (msgContext.isPropertyTrue(BridgeConstants.FORCE_HTTP_1_0)) {
            return BridgeConstants.HTTP_1_0_VERSION;
        } else if (BridgeConstants.HTTP_2_0_VERSION.equals(((HttpCarbonMessage) msgContext.getProperty(BridgeConstants.HTTP_CARBON_MESSAGE)).getHttpVersion())) {
            return BridgeConstants.HTTP_2_0_VERSION;
        }
        return BridgeConstants.HTTP_1_1_VERSION;
    }

    /**
     * Determine the Http Status Code depending on the message type processed <br>
     * (normal response versus fault response) as well as Axis2 message context properties set
     * via Synapse configuration or MessageBuilders.
     *
     * @param msgContext the Axis2 message context
     * @return the HTTP status code to set in the HTTP response object
     * @see BridgeConstants#SC_ACCEPTED
     * @see BridgeConstants#ERROR_CODE
     */
    private static int determineHttpStatusCode(MessageContext msgContext) {

        int httpStatus = HttpStatus.SC_OK;

        Integer errorCode = (Integer) msgContext.getProperty(BridgeConstants.ERROR_CODE);
        if (errorCode != null) {
            return HttpStatus.SC_BAD_GATEWAY;
        }

        // if this is a dummy message to handle http 202 case with non-blocking IO
        // set the status code to 202
        if (msgContext.isPropertyTrue(BridgeConstants.SC_ACCEPTED)) {
            return HttpStatus.SC_ACCEPTED;
        } else {
            Object statusCode = msgContext.getProperty(BridgeConstants.HTTP_SC);
            if (statusCode != null) {
                try {
                    httpStatus = Integer.parseInt(statusCode.toString());
                    return httpStatus;
                } catch (NumberFormatException e) {
                    LOG.warn("Unable to set the HTTP status code from the property "
                            + BridgeConstants.HTTP_SC + " with value: " + statusCode);
                }
            }

            // Is this a fault message?
            boolean handleFault = HttpUtils.isFaultMessage(msgContext);
            boolean faultsAsHttp200 = HttpUtils.sendFaultAsHTTP200(msgContext);

            // Set HTTP status code to 500 if this is a fault case and we shall not use HTTP 200
            if (handleFault && !faultsAsHttp200) {
                httpStatus = HttpStatus.SC_INTERNAL_SERVER_ERROR;
            }
        }
        return httpStatus;
    }

    /**
     * Determine the Http Status Message depending on the message type processed <br>
     * (normal response versus fault response) as well as Axis2 message context properties set
     * via Synapse configuration or MessageBuilders.
     *
     * @param msgContext the Axis2 message context
     * @return the HTTP status message string or null
     * @see BridgeConstants#HTTP_SC_DESC
     * @see BridgeConstants#HTTP_STATUS_CODE_SENT_FROM_BACKEND
     * @see BridgeConstants#HTTP_REASON_PHRASE_SENT_FROM_BACKEND
     */
    private static String determineResponseReasonPhrase(MessageContext msgContext, int statusCode) {

        String statusLine = null;
        Object statusLineProperty = msgContext.getProperty(BridgeConstants.HTTP_SC_DESC);
        if (statusLineProperty != null) {
            statusLine = (String) statusLineProperty;
        }

        Object httpReasonPhraseFromBackend =
                msgContext.getProperty(BridgeConstants.HTTP_REASON_PHRASE_SENT_FROM_BACKEND);
        Object httpStatusCodeFromBackend = msgContext.getProperty(BridgeConstants.HTTP_STATUS_CODE_SENT_FROM_BACKEND);

        if (Objects.isNull(httpStatusCodeFromBackend) || Objects.isNull(httpReasonPhraseFromBackend)) {
            return statusLine;
        }

        if (statusCode != (Integer) httpStatusCodeFromBackend && httpReasonPhraseFromBackend.equals(statusLine)) {
            // make the statusLine null so that the proper status code will be by the Netty server.
            statusLine = null;
        }
        return statusLine;
    }

    private static void handleETAGCaching(HttpCarbonMessage inboundRequestMsg, HttpCarbonMessage outboundResponseMsg,
                                          MessageContext msgCtx) throws AxisFault {

        if (isEtagEnabled(msgCtx)) {
            try {
                MessageUtils.buildMessage(msgCtx);
            } catch (IOException e) {
                RequestResponseUtils.handleException("IO Error occurred while building the message", e);
            }
            String hash = CachingConstants.DEFAULT_XML_IDENTIFIER.getDigest(msgCtx);
            outboundResponseMsg.setHeader(BridgeConstants.ETAG_HEADER, "\"" + hash + "\"");
        }

        if (CacheUtils.isValidCachedResponse(outboundResponseMsg, inboundRequestMsg)) {
            // A 304 Not Modified message is an HTTP response status code indicating that the requested resource
            // has not been modified since the previous transmission, so there is no need to retransmit the
            // requested resource to the client. In effect, a 304 Not Modified response code acts as an
            // implicit redirection to a cached version of the requested resource.
            outboundResponseMsg.setHttpStatusCode(HttpResponseStatus.NOT_MODIFIED.code());
            outboundResponseMsg.setProperty(org.wso2.transport.http.netty.contract.Constants.HTTP_REASON_PHRASE,
                    HttpResponseStatus.NOT_MODIFIED.reasonPhrase());
            setHttpVersion(outboundResponseMsg, msgCtx);
            outboundResponseMsg.removeHeader(HttpHeaderNames.CONTENT_LENGTH.toString());
            outboundResponseMsg.removeHeader(HttpHeaderNames.CONTENT_TYPE.toString());
            msgCtx.setProperty(BridgeConstants.VALID_CACHED_RESPONSE, true);
        }
    }

    /**
     * Writes the response headers and the response body to the client.
     *
     * @param msgCtx              axis2 message context
     * @param inboundRequestMsg   inbound request carbon message
     * @param outboundResponseMsg outbound response carbon message
     * @throws AxisFault if something goes wrong when sending out the response
     */
    public static void sendResponse(MessageContext msgCtx, HttpCarbonMessage inboundRequestMsg,
                                    HttpCarbonMessage outboundResponseMsg) throws AxisFault {

        HttpUtils.sendOutboundResponse(inboundRequestMsg, outboundResponseMsg);
        serializeData(msgCtx, outboundResponseMsg);
    }

    /**
     * Send the server push promise to the client.
     *
     * @param http2PushPromise Http2PushPromise
     * @param clientRequest    HttpCarbonMessage
     * @throws AxisFault if error occurred while pushing responses.
     */
    public static void pushPromise(Http2PushPromise http2PushPromise, HttpCarbonMessage clientRequest) throws AxisFault {

        HttpUtils.pushPromise(http2PushPromise, clientRequest);
    }

    /**
     * Send the server pushes to the client.
     *
     * @param msgCtx           axis2 message context
     * @param http2PushPromise Http2PushPromise
     * @param outboundPushMsg  HttpCarbonMessage
     * @param clientRequest    HttpCarbonMessage
     * @throws AxisFault if error occurred while pushing responses.
     */
    public static void pushResponse(MessageContext msgCtx, Http2PushPromise http2PushPromise,
                                    HttpCarbonMessage outboundPushMsg, HttpCarbonMessage clientRequest) throws AxisFault {

        HttpUtils.pushResponse(http2PushPromise, outboundPushMsg, clientRequest);
        serializeData(msgCtx, outboundPushMsg);
    }

    private static void serializeData(MessageContext msgCtx, HttpCarbonMessage responseMsg)
            throws AxisFault {

        if (hasNoResponseBodyToSend(msgCtx)) {
            OutputStream messageOutputStream = HttpUtils.getHttpMessageDataStreamer(responseMsg).getOutputStream();
            HttpUtils.writeEmptyBody(messageOutputStream);
        } else {
            if (RequestResponseUtils.shouldInvokeFormatterToWriteBody(msgCtx)) {
                OutputStream messageOutputStream = HttpUtils.getHttpMessageDataStreamer(responseMsg).getOutputStream();
                MessageFormatter messageFormatter = MessageUtils.getMessageFormatter(msgCtx);
                HttpUtils.serializeDataUsingMessageFormatter(msgCtx, messageFormatter, messageOutputStream);
            } else {
                HttpCarbonMessage inboundCarbonMessage =
                        (HttpCarbonMessage) msgCtx.getProperty(BridgeConstants.HTTP_CARBON_MESSAGE);
                HttpUtils.copyContentFromInboundHttpCarbonMessage(inboundCarbonMessage, responseMsg);
            }
        }
    }

    private static boolean hasNoResponseBodyToSend(MessageContext msgCtx) {

        return msgCtx.isPropertyTrue(BridgeConstants.NO_ENTITY_BODY)
                || msgCtx.isPropertyTrue(BridgeConstants.VALID_CACHED_RESPONSE);
    }

    private static boolean isEtagEnabled(MessageContext msgCtx) {

        return msgCtx.isPropertyTrue(BridgeConstants.HTTP_ETAG_ENABLED);
    }

    private static boolean isMTOMEnabled(MessageContext msgCtx) {

        return Objects.nonNull(msgCtx.getProperty(org.apache.axis2.Constants.Configuration.ENABLE_MTOM));
    }

    private static boolean isValidCacheResponse(MessageContext msgCtx) {

        return msgCtx.isPropertyTrue(BridgeConstants.VALID_CACHED_RESPONSE);
    }
}