/**
* 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:
*
* <p>http://www.apache.org/licenses/LICENSE-2.0
*
* <p>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.jasig.portlet.calendar.adapter;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Set;
import javax.portlet.PortletRequest;
import net.fortuna.ical4j.model.component.VEvent;
import net.sf.ehcache.Cache;
import net.sf.ehcache.Element;
import org.apache.commons.httpclient.Credentials;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpException;
import org.apache.commons.httpclient.HttpStatus;
import org.apache.commons.httpclient.auth.AuthScope;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.input.BOMInputStream;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jasig.portlet.calendar.CalendarConfiguration;
import org.jasig.portlet.calendar.caching.DefaultCacheKeyGeneratorImpl;
import org.jasig.portlet.calendar.caching.ICacheKeyGenerator;
import org.jasig.portlet.calendar.credentials.DefaultCredentialsExtractorImpl;
import org.jasig.portlet.calendar.credentials.ICredentialsExtractor;
import org.jasig.portlet.calendar.processor.ICalendarContentProcessorImpl;
import org.jasig.portlet.calendar.processor.IContentProcessor;
import org.jasig.portlet.calendar.url.DefaultUrlCreatorImpl;
import org.jasig.portlet.calendar.url.IUrlCreator;
import org.joda.time.Interval;
import org.springframework.beans.factory.annotation.Required;
/**
* Implementation of {@link ICalendarAdapter} that uses Commons HttpClient for retrieving {@link
* CalendarEventSet}s.
*
* <p>This bean requires an EhCache {@link Cache} be provided. This bean also depends on instances
* of 3 different interfaces (default implementation listed in parenthesis):
*
* <ul>
* <li>{@link IUrlCreator} (default configuration: {@link DefaultUrlCreatorImpl})
* <li>{@link ICredentialsExtractor} (default: {@link DefaultCredentialsExtractorImpl})
* <li>{@link IContentProcessor} (default: {@link ICalendarContentProcessorImpl})
* </ul>
*
* By specifying alternate implementations for these interfaces, multiple instances of this class
* can be configured to consume {@link CalendarEventSet}s from a variety of different end points,
* for example an RSS feed behind basic auth, a CalendarKey implementation behind a shared secret,
* or behind CAS.
*
* @author Nicholas Blair, nblair@doit.wisc.edu
*/
public final class ConfigurableHttpCalendarAdapter<T> extends AbstractCalendarAdapter
implements ICalendarAdapter {
protected final Log log = LogFactory.getLog(this.getClass());
private Cache cache;
private IUrlCreator urlCreator = new DefaultUrlCreatorImpl();
private ICredentialsExtractor credentialsExtractor = new DefaultCredentialsExtractorImpl();
private IContentProcessor contentProcessor = new ICalendarContentProcessorImpl();
private ICacheKeyGenerator cacheKeyGenerator = new DefaultCacheKeyGeneratorImpl();
private String cacheKeyPrefix = "default";
/** @param cache the cache to set */
@Required
public void setCache(Cache cache) {
this.cache = cache;
}
/** @param urlCreator the urlCreator to set */
public void setUrlCreator(IUrlCreator urlCreator) {
this.urlCreator = urlCreator;
}
/** @param credentialsExtractor the credentialsExtractor to set */
public void setCredentialsExtractor(ICredentialsExtractor credentialsExtractor) {
this.credentialsExtractor = credentialsExtractor;
}
/** @param contentProcessor the contentProcessor to set */
public void setContentProcessor(IContentProcessor contentProcessor) {
this.contentProcessor = contentProcessor;
}
/** @param cacheKeyPrefix the cacheKeyPrefix to set */
public void setCacheKeyPrefix(String cacheKeyPrefix) {
this.cacheKeyPrefix = cacheKeyPrefix;
}
public void setCacheKeyGenerator(ICacheKeyGenerator cacheKeyGenerator) {
this.cacheKeyGenerator = cacheKeyGenerator;
}
/**
* Workflow for this implementation:
*
* <ol>
* <li>consult the configured {@link IUrlCreator} for the url to request
* <li>consult the cache to see if the fetch via HTTP is necessary (if not return the cached
* events)
* <li>if the fetch is necessary, consult the {@link ICredentialsExtractor} for necessary {@link
* Credentials}
* <li>Invoke retrieveCalendarHttp
* <li>Pass the returned {@link InputStream} into the configured {@link IContentProcessor}
* <li>Return the {@link CalendarEventSet}s
* </ol>
*
* (non-Javadoc)
*
* @see
* org.jasig.portlet.calendar.adapter.ICalendarAdapter#getEvents(org.jasig.portlet.calendar.CalendarConfiguration,
* org.joda.time.Interval, javax.portlet.PortletRequest)
*/
public CalendarEventSet getEvents(
CalendarConfiguration calendarConfiguration, Interval interval, PortletRequest request)
throws CalendarException {
/*
* Some HTTP iCal providers, such as Google, don't allow you to specify
* the interval in the RESTful call so you get the whole calendar. To
* avoid receiving the entire calendar every time you need a specific
* interval, break up the caching into two stages.
*
* Stage 1 caches the entire calendar (or partial if the REST call supports intervals).
*
* Stage 2 filters the cached calendar down to the requested interval and
* caches the calendar events for that interval.
*/
// Stage 1: Try to get the cached calendar.
String url = this.urlCreator.constructUrl(calendarConfiguration, interval, request);
log.debug("generated url: " + url);
String intermediateCacheKey =
cacheKeyGenerator.getKey(
calendarConfiguration, interval, request, cacheKeyPrefix.concat(".").concat(url));
T calendar;
Element cachedCalendar = this.cache.get(intermediateCacheKey);
if (cachedCalendar == null) {
Credentials credentials = credentialsExtractor.getCredentials(request);
// read in the data
InputStream stream = retrieveCalendarHttp(url, credentials);
// run the stream through the processor
try {
calendar = (T) contentProcessor.getIntermediateCalendar(interval, stream);
} catch (CalendarException e) {
log.error(
"Calendar parsing exception: "
+ e.getCause().getMessage()
+ " from calendar at "
+ url);
throw e;
}
// save the VEvents to the cache
cachedCalendar = new Element(intermediateCacheKey, calendar);
this.cache.put(cachedCalendar);
if (log.isDebugEnabled()) {
log.debug("Storing calendar cache, key:" + intermediateCacheKey);
}
} else {
if (log.isDebugEnabled()) {
log.debug("Retrieving calendar from cache, key:" + intermediateCacheKey);
}
calendar = (T) cachedCalendar.getObjectValue();
}
// The cache key for retrieving a calendar over HTTP may not include
// the interval, so we need to add the current interval to the existing
// cache key. This might result in the interval being contained in the
// key twice, but that won't hurt anything.
String processorCacheKey = getIntervalSpecificCacheKey(intermediateCacheKey, interval);
// Stage 2: Get the calendar event set for the requested interval from cache
// or generate it from the calendar from stage 1.
CalendarEventSet eventSet;
Element cachedElement = this.cache.get(processorCacheKey);
if (cachedElement == null) {
Set<VEvent> events = contentProcessor.getEvents(interval, calendar);
log.debug("contentProcessor found " + events.size() + " events");
// Save the calendar event set to the cache. Calculate how long
// this event set should survive. We don't want this event set to
// survive beyond the expiration of the calendar from stage 1 or you
// have the potential of having two sets of events with different
// overlapping intervals displaying different data, assuming getting
// the calendar in stage 1 returns the whole calendar and not just
// the portion of the calendar within the desired interval.
//
// For instance this inconsistency in calendar event sets can happen
// when you get the calendar and display a week, then
// near the expiration of the calendar from stage 1 get events for a
// month that contains the week. If you then display the week again
// after the calendar (stage 1) has expired, you could get a changed
// calendar and generate different calendar events than what you'd see
// in the month view until the stage-2-month calendar event set expires
// and builds a calendar event set based on the same data as the week
// was generated with.
int timeToLiveInSeconds = -1;
long currentTime = System.currentTimeMillis();
if (cachedCalendar.getExpirationTime() > currentTime) {
long timeToLiveInMilliseconds = cachedCalendar.getExpirationTime() - currentTime;
timeToLiveInSeconds = (int) timeToLiveInMilliseconds / 1000;
}
eventSet =
insertCalendarEventSetIntoCache(
this.cache,
processorCacheKey,
events,
timeToLiveInSeconds > 0 ? timeToLiveInSeconds : -1);
} else {
if (log.isDebugEnabled()) {
log.debug("Retrieving calendar event set from cache, key:" + processorCacheKey);
}
eventSet = (CalendarEventSet) cachedElement.getObjectValue();
}
return eventSet;
}
protected String getIntervalSpecificCacheKey(String baseKey, Interval interval) {
StringBuffer buf = new StringBuffer();
buf.append(baseKey);
buf.append(interval.toString());
return buf.toString();
}
/**
* Uses Commons HttpClient to retrieve the specified url (optionally with the provided {@link
* Credentials}. The response body is returned as an {@link InputStream}.
*
* @param url URL of the calendar to be retrieved
* @param credentials {@link Credentials} to use with the request, if necessary (null is ok if
* credentials not required)
* @return the body of the http response as a stream
* @throws CalendarException wraps all potential {@link Exception} types
*/
protected InputStream retrieveCalendarHttp(String url, Credentials credentials)
throws CalendarException {
final HttpClient client = new HttpClient();
if (null != credentials) {
client.getState().setCredentials(AuthScope.ANY, credentials);
}
GetMethod get = null;
try {
if (log.isDebugEnabled()) {
log.debug("Retrieving calendar " + url);
}
get = new GetMethod(url);
final int rc = client.executeMethod(get);
if (rc == HttpStatus.SC_OK) {
// return the response body
log.debug("request completed successfully");
final InputStream responseBody = get.getResponseBodyAsStream();
/*
* CAP-207: Some HTTP endpoints seem to provide XML documents
* that begin with a "Byte Order Mark" which causes a parsing
* failure. This BOMInputStream strips the BOM is present.
*/
final BOMInputStream bomIn = new BOMInputStream(responseBody);
final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
IOUtils.copyLarge(bomIn, buffer);
return new ByteArrayInputStream(buffer.toByteArray());
} else {
log.warn("HttpStatus for " + url + ": " + rc);
throw new CalendarException(
"Non-successful status code retrieving url '" + url + "'; status code: " + rc);
}
} catch (HttpException e) {
log.warn("Error fetching iCalendar feed", e);
throw new CalendarException("Error fetching iCalendar feed", e);
} catch (IOException e) {
log.warn("Error fetching iCalendar feed", e);
throw new CalendarException("Error fetching iCalendar feed", e);
} finally {
if (get != null) {
get.releaseConnection();
}
}
}
}