Driver.java

/* 
 * 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 org.esigate;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;

import org.apache.commons.io.output.StringBuilderWriter;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.http.HttpEntity;
import org.apache.http.HttpStatus;
import org.apache.http.ProtocolException;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicHttpResponse;
import org.apache.http.util.EntityUtils;
import org.esigate.RequestExecutor.RequestExecutorBuilder;
import org.esigate.api.RedirectStrategy2;
import org.esigate.events.EventManager;
import org.esigate.events.impl.ProxyEvent;
import org.esigate.events.impl.RenderEvent;
import org.esigate.extension.ExtensionFactory;
import org.esigate.http.BasicCloseableHttpResponse;
import org.esigate.http.ContentTypeHelper;
import org.esigate.http.HeaderManager;
import org.esigate.http.HttpClientRequestExecutor;
import org.esigate.http.HttpResponseUtils;
import org.esigate.http.IncomingRequest;
import org.esigate.http.OutgoingRequest;
import org.esigate.http.ResourceUtils;
import org.esigate.impl.DriverRequest;
import org.esigate.impl.FragmentRedirectStrategy;
import org.esigate.impl.UrlRewriter;
import org.esigate.vars.VariablesResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Main class used to retrieve data from a provider application using HTTP requests. Data can be retrieved as binary
 * streams or as String for text data. To improve performance, the Driver uses a cache that can be configured depending
 * on the needs.
 * 
 * @author Francois-Xavier Bonnet
 * @author Nicolas Richeton
 * @author Sylvain Sicard
 */
public final class Driver {
    private static final String CACHE_RESPONSE_PREFIX = "response_";
    private static final Logger LOG = LoggerFactory.getLogger(Driver.class);
    private static final int MAX_REDIRECTS = 50;
    private DriverConfiguration config;
    private EventManager eventManager;
    private RequestExecutor requestExecutor;
    private ContentTypeHelper contentTypeHelper;
    private UrlRewriter urlRewriter;
    private HeaderManager headerManager;
    private final RedirectStrategy2 redirectStrategy = new FragmentRedirectStrategy();

    public static class DriverBuilder {
        private Driver driver = new Driver();
        private String name;
        private Properties properties;
        private RequestExecutorBuilder requestExecutorBuilder;

        public Driver build() {
            if (name == null) {
                throw new ConfigurationException("name is mandatory");
            }
            if (properties == null) {
                throw new ConfigurationException("properties is mandatory");
            }
            if (requestExecutorBuilder == null) {
                requestExecutorBuilder = HttpClientRequestExecutor.builder();
            }
            driver.eventManager = new EventManager(name);
            driver.config = new DriverConfiguration(name, properties);
            driver.contentTypeHelper = new ContentTypeHelper(properties);
            // Load extensions.
            ExtensionFactory.getExtensions(properties, Parameters.EXTENSIONS, driver);
            UrlRewriter urlRewriter = new UrlRewriter();
            driver.requestExecutor =
                    requestExecutorBuilder.setDriver(driver).setEventManager(driver.eventManager)
                            .setProperties(properties).setContentTypeHelper(driver.contentTypeHelper).build();
            driver.urlRewriter = urlRewriter;
            driver.headerManager = new HeaderManager(urlRewriter);

            return driver;
        }

        public DriverBuilder setName(String n) {
            this.name = n;
            return this;
        }

        public DriverBuilder setProperties(Properties p) {
            this.properties = p;
            return this;
        }

        public DriverBuilder setRequestExecutorBuilder(RequestExecutorBuilder builder) {
            this.requestExecutorBuilder = builder;
            return this;
        }

    }

    protected Driver() {
    }

    public static DriverBuilder builder() {
        return new DriverBuilder();
    }

    /**
     * Get current event manager for this driver instance.
     * 
     * @return event manager.
     */
    public EventManager getEventManager() {
        return this.eventManager;
    }

    /**
     * Perform rendering on a single url content, and append result to "writer". Automatically follows redirects
     * 
     * @param pageUrl
     *            Address of the page containing the template
     * @param incomingRequest
     *            originating request object
     * @param renderers
     *            the renderers to use in order to transform the output
     * @return The resulting response
     * @throws IOException
     *             If an IOException occurs while writing to the writer
     * @throws HttpErrorPage
     *             If an Exception occurs while retrieving the template
     */
    public CloseableHttpResponse render(String pageUrl, IncomingRequest incomingRequest, Renderer... renderers)
            throws IOException, HttpErrorPage {
        DriverRequest driverRequest = new DriverRequest(incomingRequest, this, pageUrl);

        // Replace ESI variables in URL
        // TODO: should be performed in the ESI extension
        String resultingPageUrl = VariablesResolver.replaceAllVariables(pageUrl, driverRequest);

        String targetUrl = ResourceUtils.getHttpUrlWithQueryString(resultingPageUrl, driverRequest, false);

        String currentValue;
        CloseableHttpResponse response;

        // Retrieve URL
        // Get from cache to prevent multiple request to the same url if
        // multiple fragments are used.

        String cacheKey = CACHE_RESPONSE_PREFIX + targetUrl;
        Pair<String, CloseableHttpResponse> cachedValue = incomingRequest.getAttribute(cacheKey);

        // content and response were not in cache
        if (cachedValue == null) {
            OutgoingRequest outgoingRequest = requestExecutor.createOutgoingRequest(driverRequest, targetUrl, false);
            headerManager.copyHeaders(driverRequest, outgoingRequest);
            response = requestExecutor.execute(outgoingRequest);
            int redirects = MAX_REDIRECTS;
            try {
                while (redirects > 0
                        && this.redirectStrategy.isRedirected(outgoingRequest, response, outgoingRequest.getContext())) {

                    // Must consume the entity
                    EntityUtils.consumeQuietly(response.getEntity());

                    redirects--;

                    // Perform new request
                    outgoingRequest =
                            this.requestExecutor.createOutgoingRequest(
                                    driverRequest,
                                    this.redirectStrategy.getLocationURI(outgoingRequest, response,
                                            outgoingRequest.getContext()).toString(), false);
                    this.headerManager.copyHeaders(driverRequest, outgoingRequest);
                    response = requestExecutor.execute(outgoingRequest);
                }
            } catch (ProtocolException e) {
                throw new HttpErrorPage(HttpStatus.SC_BAD_GATEWAY, "Invalid response from server", e);
            }
            response = this.headerManager.copyHeaders(outgoingRequest, incomingRequest, response);
            currentValue = HttpResponseUtils.toString(response, this.eventManager);
            // Cache
            cachedValue = new ImmutablePair<>(currentValue, response);
            incomingRequest.setAttribute(cacheKey, cachedValue);
        }
        currentValue = cachedValue.getKey();
        response = cachedValue.getValue();

        logAction("render", pageUrl, renderers);

        // Apply renderers
        currentValue = performRendering(pageUrl, driverRequest, response, currentValue, renderers);

        response.setEntity(new StringEntity(currentValue, HttpResponseUtils.getContentType(response)));

        return response;
    }

    /**
     * Log current provider, page and renderers that will be applied.
     * <p>
     * This methods log at the INFO level.
     * <p>
     * You should only call this method if INFO level is enabled.
     * 
     * <pre>
     * if (LOG.isInfoEnabled()) {
     *     logAction(pageUrl, renderers);
     * }
     * </pre>
     * 
     * @param action
     *            Action name (eg. "proxy" or "render")
     * @param onUrl
     *            current page url.
     * @param renderers
     *            array of renderers
     * 
     */
    private void logAction(String action, String onUrl, Renderer[] renderers) {
        if (LOG.isInfoEnabled()) {
            List<String> rendererNames = new ArrayList<>(renderers.length);
            for (Renderer renderer : renderers) {
                rendererNames.add(renderer.getClass().getName());
            }
            LOG.info("{} provider={} page= {} renderers={}", action, this.config.getInstanceName(), onUrl,
                    rendererNames);
        }
    }

    /**
     * Retrieves a resource from the provider application and transforms it using the Renderer passed as a parameter.
     * 
     * @param relUrl
     *            the relative URL to the resource
     * @param incomingRequest
     *            the request
     * @param renderers
     *            the renderers to use to transform the output
     * @return The resulting response.
     * @throws IOException
     *             If an IOException occurs while writing to the response
     * @throws HttpErrorPage
     *             If the page contains incorrect tags
     */
    public CloseableHttpResponse proxy(String relUrl, IncomingRequest incomingRequest, Renderer... renderers)
            throws IOException, HttpErrorPage {
        DriverRequest driverRequest = new DriverRequest(incomingRequest, this, relUrl);
        driverRequest.setCharacterEncoding(this.config.getUriEncoding());

        // This is used to ensure EVENT_PROXY_POST is called once and only once.
        // there are 3 different cases
        // - Success -> the main code
        // - Error page -> the HttpErrorPage exception
        // - Unexpected error -> Other Exceptions
        boolean postProxyPerformed = false;

        // Create Proxy event
        ProxyEvent e = new ProxyEvent(incomingRequest);

        // Event pre-proxy
        this.eventManager.fire(EventManager.EVENT_PROXY_PRE, e);
        // Return immediately if exit is requested by extension
        if (e.isExit()) {
            return e.getResponse();
        }

        logAction("proxy", relUrl, renderers);

        String url = ResourceUtils.getHttpUrlWithQueryString(relUrl, driverRequest, true);
        OutgoingRequest outgoingRequest = requestExecutor.createOutgoingRequest(driverRequest, url, true);
        headerManager.copyHeaders(driverRequest, outgoingRequest);

        try {
            CloseableHttpResponse response = requestExecutor.execute(outgoingRequest);

            response = headerManager.copyHeaders(outgoingRequest, incomingRequest, response);

            e.setResponse(response);

            // Perform rendering
            e.setResponse(performRendering(relUrl, driverRequest, e.getResponse(), renderers));

            // Event post-proxy
            // This must be done before calling sendResponse to ensure response
            // can still be changed.
            postProxyPerformed = true;
            this.eventManager.fire(EventManager.EVENT_PROXY_POST, e);

            // Send request to the client.
            return e.getResponse();

        } catch (HttpErrorPage errorPage) {
            e.setErrorPage(errorPage);

            // On error returned by the proxy request, perform rendering on the
            // error page.
            CloseableHttpResponse response = e.getErrorPage().getHttpResponse();
            response = headerManager.copyHeaders(outgoingRequest, incomingRequest, response);
            e.setErrorPage(new HttpErrorPage(performRendering(relUrl, driverRequest, response, renderers)));

            // Event post-proxy
            // This must be done before throwing exception to ensure response
            // can still be changed.
            postProxyPerformed = true;
            this.eventManager.fire(EventManager.EVENT_PROXY_POST, e);

            throw e.getErrorPage();
        } finally {
            if (!postProxyPerformed) {
                this.eventManager.fire(EventManager.EVENT_PROXY_POST, e);
            }
        }
    }

    /**
     * Performs rendering on an HttpResponse.
     * <p>
     * Rendering is only performed if page can be parsed.
     * 
     * @param pageUrl
     *            The remove url from which the body was retrieved.
     * @param originalRequest
     *            The request received by esigate.
     * @param response
     *            The response which will be rendered.
     * @param renderers
     *            list of renderers to apply.
     * @return The rendered response, or the original response if if was not parsed.
     * @throws HttpErrorPage
     * @throws IOException
     */
    private CloseableHttpResponse performRendering(String pageUrl, DriverRequest originalRequest,
            CloseableHttpResponse response, Renderer[] renderers) throws HttpErrorPage, IOException {

        if (!contentTypeHelper.isTextContentType(response)) {
            LOG.debug("'{}' is binary on no transformation to apply: was forwarded without modification.", pageUrl);
            return response;
        }

        LOG.debug("'{}' is text : will apply renderers.", pageUrl);

        // Get response body
        String currentValue = HttpResponseUtils.toString(response, this.eventManager);

        // Perform rendering
        currentValue = performRendering(pageUrl, originalRequest, response, currentValue, renderers);

        // Generate the new response.
        HttpEntity transformedHttpEntity = new StringEntity(currentValue, ContentType.get(response.getEntity()));
        CloseableHttpResponse transformedResponse =
                BasicCloseableHttpResponse.adapt(new BasicHttpResponse(response.getStatusLine()));
        transformedResponse.setHeaders(response.getAllHeaders());
        transformedResponse.setEntity(transformedHttpEntity);
        return transformedResponse;

    }

    /**
     * Performs rendering (apply a render list) on an http response body (as a String).
     * 
     * @param pageUrl
     *            The remove url from which the body was retrieved.
     * @param originalRequest
     *            The request received by esigate.
     * @param response
     *            The Http Reponse.
     * @param body
     *            The body of the Http Response which will be rendered.
     * @param renderers
     *            list of renderers to apply.
     * @return The rendered response body.
     * @throws HttpErrorPage
     * @throws IOException
     */
    private String performRendering(String pageUrl, DriverRequest originalRequest, CloseableHttpResponse response,
            String body, Renderer[] renderers) throws IOException, HttpErrorPage {
        // Start rendering
        RenderEvent renderEvent = new RenderEvent(pageUrl, originalRequest, response);
        // Create renderer list from parameters.
        renderEvent.getRenderers().addAll(Arrays.asList(renderers));

        String currentBody = body;

        this.eventManager.fire(EventManager.EVENT_RENDER_PRE, renderEvent);
        for (Renderer renderer : renderEvent.getRenderers()) {
            StringBuilderWriter stringWriter = new StringBuilderWriter(Parameters.DEFAULT_BUFFER_SIZE);
            renderer.render(originalRequest, currentBody, stringWriter);
            stringWriter.close();
            currentBody = stringWriter.toString();
        }
        this.eventManager.fire(EventManager.EVENT_RENDER_POST, renderEvent);

        return currentBody;
    }

    /**
     * Get current driver configuration.
     * <p>
     * This method is not intended to get a WRITE access to the configuration.
     * <p>
     * This may be supported in future versions (testing is needed). For the time being, changing configuration settings
     * after getting access through this method is <b>UNSUPPORTED</b> and <b>SHOULD NOT</b> be used.
     * 
     * @return current configuration
     */
    public DriverConfiguration getConfiguration() {
        return this.config;
    }

    public RequestExecutor getRequestExecutor() {
        return requestExecutor;
    }

    @Override
    public String toString() {
        return "driver:" + config.getInstanceName();
    }

    public ContentTypeHelper getContentTypeHelper() {
        return contentTypeHelper;
    }

    public UrlRewriter getUrlRewriter() {
        return urlRewriter;
    }

}