org.apereo.portal.soffit.connector.SoffitConnectorController.java Source code

Java tutorial

Introduction

Here is the source code for org.apereo.portal.soffit.connector.SoffitConnectorController.java

Source

/**
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo 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 the following location:
 *
 *   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.apereo.portal.soffit.connector;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import javax.annotation.PostConstruct;
import javax.portlet.PortletPreferences;
import javax.portlet.RenderRequest;
import javax.portlet.RenderResponse;

import net.sf.ehcache.Cache;
import net.sf.ehcache.Element;
import org.apache.commons.io.IOUtils;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.util.EntityUtils;
import org.apereo.portal.soffit.Headers;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactoryUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.portlet.bind.annotation.RenderMapping;

/**
 * @since 5.0
 */
@Controller
@RequestMapping(value = { "VIEW", "EDIT", "HELP" })
public class SoffitConnectorController implements ApplicationContextAware {

    /**
     * Preferences that begin with this String will not be shared with the remote soffit.
     */
    public static final String CONNECTOR_PREFERENCE_PREFIX = SoffitConnectorController.class.getName();

    private static final String SERVICE_URL_PREFERENCE = CONNECTOR_PREFERENCE_PREFIX + ".serviceUrl";

    private static final int TIMEOUT_SECONDS = 10;

    @Value("${org.apereo.portlet.soffit.connector.SoffitConnectorController.maxConnectionsPerRoute:20}")
    private Integer maxConnectionsPerRoute;

    @Value("${org.apereo.portlet.soffit.connector.SoffitConnectorController.maxConnectionsTotal:50}")
    private Integer maxConnectionsTotal;

    private final RequestConfig requestConfig = RequestConfig.custom().setSocketTimeout(TIMEOUT_SECONDS * 1000)
            .setConnectTimeout(TIMEOUT_SECONDS * 1000).build();

    private final HttpClientBuilder httpClientBuilder = HttpClientBuilder.create()
            .setDefaultRequestConfig(requestConfig).setConnectionManagerShared(true); // Prevents the client from shutting down the pool

    private ApplicationContext applicationContext;
    private List<IHeaderProvider> headerProviders;

    @Autowired
    @Qualifier(value = "org.apereo.portlet.soffit.connector.SoffitConnectorController.RESPONSE_CACHE")
    private Cache responseCache;

    private final Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    @PostConstruct
    public void init() {
        PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager();
        poolingHttpClientConnectionManager.setDefaultMaxPerRoute(maxConnectionsPerRoute);
        poolingHttpClientConnectionManager.setMaxTotal(maxConnectionsTotal);
        httpClientBuilder.setConnectionManager(poolingHttpClientConnectionManager);

        final Map<String, IHeaderProvider> beans = BeanFactoryUtils
                .beansOfTypeIncludingAncestors(applicationContext, IHeaderProvider.class);
        final List<IHeaderProvider> values = new ArrayList<>(beans.values());
        headerProviders = Collections.unmodifiableList(values);
    }

    @RenderMapping
    public void invokeService(final RenderRequest req, final RenderResponse res) {

        final PortletPreferences prefs = req.getPreferences();
        final String serviceUrl = prefs.getValue(SERVICE_URL_PREFERENCE, null);
        if (serviceUrl == null) {
            throw new IllegalStateException("Missing portlet prefernce value for " + SERVICE_URL_PREFERENCE);
        }

        // First look in cache for an existing response that applies to this request
        ResponseWrapper responseValue = fetchContentFromCacheIfAvailable(req, serviceUrl);
        if (responseValue != null) {
            logger.debug("Response value obtained from cache for serviceUrl '{}'", serviceUrl);
        } else {

            logger.debug("No applicable response in cache;  invoking serviceUrl '{}'", serviceUrl);

            final HttpGet getMethod = new HttpGet(serviceUrl);
            try (final CloseableHttpClient httpClient = httpClientBuilder.build()) {

                // Send the data model as encrypted JWT HTTP headers
                for (IHeaderProvider headerProvider : headerProviders) {
                    final Header header = headerProvider.createHeader(req, res);
                    getMethod.addHeader(header);
                }

                // Send the request
                final HttpResponse httpResponse = httpClient.execute(getMethod);
                final int statusCode = httpResponse.getStatusLine().getStatusCode();
                logger.debug("HTTP response code for url '{}' was '{}'", serviceUrl, statusCode);

                if (statusCode == HttpStatus.SC_OK) {
                    responseValue = extractResponseAndCacheIfAppropriate(httpResponse, req, serviceUrl);
                } else {
                    logger.error("Failed to get content from remote service '{}';  HttpStatus={}", serviceUrl,
                            statusCode);
                    res.getWriter().write("FAILED!  statusCode=" + statusCode); // TODO:  Better message
                }

                // Ensures that the entity content is fully consumed and the content stream, if exists, is closed.
                EntityUtils.consume(httpResponse.getEntity());

            } catch (IOException e) {
                logger.error("Failed to invoke serviceUrl '{}'", serviceUrl, e);
            }

        }

        if (responseValue != null) {
            // Whether by cache or by fresh HTTP request, we have a response we can show...
            try {
                res.getPortletOutputStream().write(responseValue.getBytes());
            } catch (IOException e) {
                logger.error("Failed to write the response for serviceUrl '{}'", serviceUrl, e);
            }
        }

    }

    /*
     * Implementation
     */

    private ResponseWrapper fetchContentFromCacheIfAvailable(final RenderRequest req, final String serviceUrl) {

        ResponseWrapper rslt = null; // default

        final List<CacheTuple> cacheKeysToTry = new ArrayList<>();
        // Don't use private-scope caching for anonymous users
        if (req.getRemoteUser() != null) {
            cacheKeysToTry.add(
                    // Private-scope cache key
                    new CacheTuple(serviceUrl, req.getPortletMode().toString(), req.getWindowState().toString(),
                            req.getRemoteUser()));
        }
        cacheKeysToTry.add(
                // Public-scope cache key
                new CacheTuple(serviceUrl, req.getPortletMode().toString(), req.getWindowState().toString()));

        for (CacheTuple key : cacheKeysToTry) {
            final Element cacheElement = this.responseCache.get(key);
            if (cacheElement != null) {
                rslt = (ResponseWrapper) cacheElement.getObjectValue();
                break;
            }
        }

        return rslt;

    }

    private ResponseWrapper extractResponseAndCacheIfAppropriate(final HttpResponse httpResponse,
            final RenderRequest req, final String serviceUrl) {

        // Extract
        final HttpEntity entity = httpResponse.getEntity();
        ResponseWrapper rslt;
        try {
            rslt = new ResponseWrapper(IOUtils.toByteArray(entity.getContent()));
        } catch (UnsupportedOperationException | IOException e) {
            throw new RuntimeException("Failed to read the response", e);
        }

        // Cache the response if indicated by the remote service
        final Header cacheControlHeader = httpResponse.getFirstHeader(Headers.CACHE_CONTROL.getName());
        if (cacheControlHeader != null) {
            final String cacheControlValue = cacheControlHeader.getValue();
            logger.debug("Soffit with serviceUrl='{}' specified cache-control header value='{}'", serviceUrl,
                    cacheControlValue);
            if (cacheControlHeader != null) {
                switch (cacheControlValue) {
                case Headers.CACHE_CONTROL_NOCACHE:
                    /*
                     * This value means we can use validation caching based on
                     * Last-Modified or ETag.  Those things aren't implemented
                     * yet, so fall through to the handling for 'no-store'.
                     */
                case Headers.CACHE_CONTROL_NOSTORE:
                    /*
                     * The value 'no-store' is the default.
                     */
                    logger.debug("Not caching response due to CacheControl directive of '{}'", cacheControlValue);
                    break;
                default:
                    /*
                     * Looks like we're using the expiration cache feature.
                     */
                    CacheTuple cacheTuple = null;
                    // TODO:  Need to find a polished utility that parses a cache-control header, or write one
                    final String[] tokens = cacheControlValue.split(",");
                    // At present, we expect all valid values to be in the form '[public|private], max-age=300'
                    if (tokens.length == 2) {
                        final String maxAge = tokens[1].trim().substring("max-age=".length());
                        int timeToLive = Integer.parseInt(maxAge);
                        if ("private".equals(tokens[0].trim())) {
                            cacheTuple = new CacheTuple(serviceUrl, req.getPortletMode().toString(),
                                    req.getWindowState().toString(), req.getRemoteUser());
                        } else if ("public".equals(tokens[0].trim())) {
                            cacheTuple = new CacheTuple(serviceUrl, req.getPortletMode().toString(),
                                    req.getWindowState().toString());
                        }
                        logger.debug("Produced cacheTuple='{}' for cacheControlValue='{}'", cacheTuple,
                                cacheControlValue);
                        if (cacheTuple != null) {
                            final Element element = new Element(cacheTuple, rslt);
                            element.setTimeToLive(timeToLive);
                            responseCache.put(element);
                        } else {
                            logger.warn("The remote soffit specified cacheControlValue='{}', "
                                    + "but SoffitConnectorController failed to generate a cacheTuple");
                        }
                    }
                    break;
                }
            }

        }

        return rslt;

    }

    /*
     * Nested Types
     */

    private static final class CacheTuple {
        private final String serviceUrl;
        private final String mode;
        private final String windowState;
        private final String username;
        private final boolean publicScope;

        /**
         * Creates a CacheTuple for a public-scope soffit response.
         */
        public CacheTuple(String serviceUrl, String mode, String windowState) {
            this.serviceUrl = serviceUrl;
            this.mode = mode;
            this.windowState = windowState;
            this.username = null;
            this.publicScope = true;
        }

        /**
         * Creates a CacheTuple for a private-scope soffit response.
         */
        public CacheTuple(String serviceUrl, String mode, String windowState, String username) {
            this.serviceUrl = serviceUrl;
            this.mode = mode;
            this.windowState = windowState;
            this.username = username;
            this.publicScope = false;
        }

        @Override
        public int hashCode() {
            final int prime = 31;
            int result = 1;
            result = prime * result + ((mode == null) ? 0 : mode.hashCode());
            result = prime * result + (publicScope ? 1231 : 1237);
            result = prime * result + ((serviceUrl == null) ? 0 : serviceUrl.hashCode());
            result = prime * result + ((username == null) ? 0 : username.hashCode());
            result = prime * result + ((windowState == null) ? 0 : windowState.hashCode());
            return result;
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj)
                return true;
            if (obj == null)
                return false;
            if (getClass() != obj.getClass())
                return false;
            CacheTuple other = (CacheTuple) obj;
            if (mode == null) {
                if (other.mode != null)
                    return false;
            } else if (!mode.equals(other.mode))
                return false;
            if (publicScope != other.publicScope)
                return false;
            if (serviceUrl == null) {
                if (other.serviceUrl != null)
                    return false;
            } else if (!serviceUrl.equals(other.serviceUrl))
                return false;
            if (username == null) {
                if (other.username != null)
                    return false;
            } else if (!username.equals(other.username))
                return false;
            if (windowState == null) {
                if (other.windowState != null)
                    return false;
            } else if (!windowState.equals(other.windowState))
                return false;
            return true;
        }

        @Override
        public String toString() {
            return "CacheTuple [serviceUrl=" + serviceUrl + ", mode=" + mode + ", windowState=" + windowState
                    + ", username=" + username + ", publicScope=" + publicScope + "]";
        }

    }

    public static final class ResponseWrapper {
        private final byte[] bytes;

        public ResponseWrapper(byte[] bytes) {
            this.bytes = bytes;
        }

        public byte[] getBytes() {
            return bytes;
        }
    }

}