org.sakaiproject.nakamura.proxy.ICalProxyPostProcessor.java Source code

Java tutorial

Introduction

Here is the source code for org.sakaiproject.nakamura.proxy.ICalProxyPostProcessor.java

Source

/**
 * Licensed to the Sakai Foundation (SF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The SF 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.sakaiproject.nakamura.proxy;

import static com.google.common.base.Preconditions.*;

import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.util.Map;
import java.util.Set;

import javax.servlet.http.HttpServletResponse;

import net.fortuna.ical4j.data.CalendarBuilder;
import net.fortuna.ical4j.data.CalendarOutputter;
import net.fortuna.ical4j.data.ParserException;
import net.fortuna.ical4j.model.Calendar;
import net.fortuna.ical4j.model.Component;
import net.fortuna.ical4j.model.ComponentList;
import net.fortuna.ical4j.model.PropertyList;
import net.fortuna.ical4j.model.ValidationException;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.property.DateProperty;

import org.apache.commons.io.input.ProxyInputStream;
import org.apache.felix.scr.annotations.Activate;
import org.apache.felix.scr.annotations.Properties;
import org.apache.felix.scr.annotations.Property;
import org.apache.felix.scr.annotations.Service;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.request.RequestParameter;
import org.apache.sling.commons.json.JSONException;
import org.apache.sling.commons.json.io.JSONWriter;
import org.apache.sling.commons.osgi.PropertiesUtil;
import org.sakaiproject.nakamura.api.proxy.ProxyPostProcessor;
import org.sakaiproject.nakamura.api.proxy.ProxyResponse;
import org.sakaiproject.nakamura.util.DateUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Charsets;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;

/**
 * Will convert iCal to JSON.
 */
@Service(value = ProxyPostProcessor.class)
@org.apache.felix.scr.annotations.Component(label = "ProxyPostProcessor for iCal", description = "Post processor which converts iCal data to JSON.", immediate = true)
@Properties(value = { @Property(name = "service.vendor", value = "The Sakai foundation"),
        @Property(name = "service.description", value = "Post processor which converts iCal data to JSON."),
        @Property(name = ICalProxyPostProcessor.MAX_RESPONSE_BYTES_PROP, longValue = ICalProxyPostProcessor.DEFAULT_MAX_RESPONSE_BYTES, description = "The maximum size (in bytes) that a response from a remote "
                + "server can be.") })
public class ICalProxyPostProcessor implements ProxyPostProcessor {

    private static final Logger LOG = LoggerFactory.getLogger(ICalProxyPostProcessor.class);

    /*package*/ static final long DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
    /*package*/ static final String MAX_RESPONSE_BYTES_PROP = "sakai.proxy.ical.maxlength";

    /** The mime/content types we'll permit as responses from the remote server. */
    /*package*/ static final Set<String> ICAL_MIME_TYPES = ImmutableSet.of(
            // One can imagine it would be useful to accept text/plain so that people without
            // access to their server's mime types could still put up an .ics file. Or host on
            // a pastebin site.
            "text/plain",
            // Standard mime type
            "text/calendar",
            // vCalendar used to use this it seems...
            "text/x-vcalendar", "binary/octet-stream", "application/octet-stream");

    /** 
     * The key into the templateParams {@link Map} passed into the {@link #process} method.
     * Its value controls how the calendar is output. The default value is available as 
     * {@link #DEFAULT_RESPONSE_TYPE}. The permitted values are in 
     * {@link #OUTPUT_METHOD_NAMES}.
     */
    public static final String PARAM_RESPONSE_TYPE = "responsetype";
    /** The GET param name which enables calendar validation. */
    public static final String PARAM_VALIDATE = "validate";

    /** Message used to describe an iCalendar parser error. */
    private static final String ERR_ICAL_PARSE_FAILED = "Couldn't parse iCalendar document";
    /** Message used if an error occurs when outputting a parsed calendar.*/
    private static final String ERR_ICAL_OUTPUT_FAILED = "Error outputting calendar.";

    /** The supported ways of outputting calendars. */
    private static final Map<String, CalendarDumper> OUTPUT_METHDOS = ImmutableMap.<String, CalendarDumper>of(
            JsonCalendarDumper.NAME, JsonCalendarDumper.INSTANCE, ICalCalendarDumper.NAME,
            ICalCalendarDumper.INSTANCE);

    /** The default way to output calendars. */
    public static final String DEFAULT_RESPONSE_TYPE = JsonCalendarDumper.NAME;

    /** The permitted values of {@link #PARAM_RESPONSE_TYPE}. */
    public static final Set<String> OUTPUT_METHOD_NAMES = OUTPUT_METHDOS.keySet();

    // Instance variables

    /** The size in bytes of the longest response we'll proxy. */
    private long maxResponseLength = -1;

    @Activate
    protected void activate(Map<?, ?> properties) {
        maxResponseLength = PropertiesUtil.toLong(properties.get(MAX_RESPONSE_BYTES_PROP),
                DEFAULT_MAX_RESPONSE_BYTES);
    }

    /**
     * {@inheritDoc}
     *
     * @see org.sakaiproject.nakamura.api.proxy.ProxyPostProcessor#getName()
     */
    public String getName() {
        return "iCal";
    }

    /**
     * {@inheritDoc}
     *
     * @see org.sakaiproject.nakamura.api.proxy.ProxyPostProcessor#process(org.apache.sling.api.SlingHttpServletResponse,
     *      org.sakaiproject.nakamura.api.proxy.ProxyResponse)
     */
    public void process(Map<String, Object> templateParams, SlingHttpServletResponse response,
            ProxyResponse proxyResponse) throws IOException {

        checkState(maxResponseLength > 0, "maxResponseLength not initialised, or invalid: %s", maxResponseLength);
        checkNotNull(response);
        checkNotNull(proxyResponse);
        if (templateParams == null)
            templateParams = ImmutableMap.of();

        try {
            validateResponseHeaders(proxyResponse);
            CalendarDumper dumper = getOutputMethod(castParams(templateParams));
            Calendar calendar = loadCalendar(proxyResponse);

            if (isValidationRequested(castParams(templateParams)) || dumper.requiresValidCalendar()) {
                validateCalendar(calendar);
            }

            dumper.dump(calendar, response);
        } catch (ResponseFailedException e) {
            LOG.info(e.getMessage());
            e.populateHttpResponse(response);
            return;
        } catch (ParserException e) {
            LOG.info(ERR_ICAL_PARSE_FAILED, e);
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, ERR_ICAL_PARSE_FAILED + ": " + e.getMessage());
            return;
        }
    }

    /**
     * Checks that the response headers are OK. {@code false} is returned if the headers are
     * invalid and the {@code response} arg will have been populated with a suitable error
     * code and message. 
     * 
     * @return {@code true} if the response is valid, {@code false} otherwise.
     */
    private void validateResponseHeaders(ProxyResponse proxyResponse) throws ResponseFailedException {

        // Ensure the response's Content-Type is one of the ones we permit
        String contentType = getFirstHeaderValue(proxyResponse, "Content-Type");

        if (contentType == null || !ICAL_MIME_TYPES.contains(contentType.toLowerCase())) {

            throw new ResponseFailedException(HttpServletResponse.SC_NOT_ACCEPTABLE,
                    String.format(
                            "Remote server responded with a Content-Type which is not "
                                    + "permitted. Got: %s, expected one of: %s",
                            contentType, Joiner.on(", ").join(ICAL_MIME_TYPES)));
        }
    }

    private static String getFirstHeaderValue(ProxyResponse response, String name) {
        Map<String, String[]> headers = response.getResponseHeaders();
        if (headers == null)
            return null;

        String[] values = headers.get(name);
        if (values == null || values.length == 0)
            return null;
        String value = values[0];
        int splitPos = value.indexOf(';');
        if (splitPos != -1)
            return value.substring(0, splitPos);
        return value;
    }

    /** Pulls the first value associated with the provided key from the params map. */
    private String getParam(Map<String, RequestParameter[]> params, String key, String defaultValue) {
        final RequestParameter[] values = params.get(key);
        if (values == null || values.length == 0 || values[0] == null)
            return defaultValue;
        else {
            String value = values[0].getString();
            if (value == null || value.trim().length() == 0)
                return defaultValue;
            return value;
        }
    }

    /**
     * Gets the {@link CalendarDumper} to use based on the query params of the request.
     * 
     * @param params The map of params passed to {@link #process}.
     * @return An appropriate dumper.
     * @throws ResponseFailedException If the response type param is unrecognised.
     */
    private CalendarDumper getOutputMethod(Map<String, RequestParameter[]> params) throws ResponseFailedException {

        String methodName = getParam(params, PARAM_RESPONSE_TYPE, DEFAULT_RESPONSE_TYPE);

        CalendarDumper method = OUTPUT_METHDOS.get(methodName);
        if (method != null)
            return method;

        throw new ResponseFailedException(HttpServletResponse.SC_BAD_REQUEST,
                String.format("Unknown %s: \"%s\", expected one of: %s", PARAM_RESPONSE_TYPE, methodName,
                        Joiner.on(", ").join(OUTPUT_METHOD_NAMES)));
    }

    private boolean isValidationRequested(Map<String, RequestParameter[]> params) {

        String validate = getParam(params, PARAM_VALIDATE, "false").toLowerCase();
        return "true".equals(validate);
    }

    /** 
     * Parse the response as an iCalendar feed, throwing an IOException if the response
     * is too long.
     * @throws ResponseFailedException 
     * */
    private Calendar loadCalendar(ProxyResponse response)
            throws IOException, ParserException, ResponseFailedException {

        // We won't bother checking the Content-Length header directly as it may not be 
        // present, we'll just count the number of bytes read from the input stream.
        LengthLimitingInputStream input = new LengthLimitingInputStream(response.getResponseBodyAsInputStream(),
                this.maxResponseLength);

        try {
            return new CalendarBuilder().build(input);
        }
        // The LengthLimitingInputStream throws a StreamLengthException when a read() pushes
        // the number of read bytes over the limit
        catch (StreamLengthException e) {
            throw new ResponseFailedException(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE,
                    "The remote server's response was too long: " + e.getMessage());
        }
    }

    private void validateCalendar(Calendar calendar) throws ResponseFailedException {
        try {
            calendar.validate(true);
        } catch (ValidationException e) {
            throw new ResponseFailedException(HttpServletResponse.SC_NOT_ACCEPTABLE,
                    "Invalid Calendar Received: " + e.getMessage());
        }
    }

    /** 
     * The templateParams arg to {@link #process} seems to always have 
     * {@code RequestParameter[]} values, so this casts it appropriately.
     */
    @SuppressWarnings("unchecked")
    private static Map<String, RequestParameter[]> castParams(Map<?, ?> params) {
        return ((Map<String, RequestParameter[]>) params);
    }

    /** Represents a means of outputting a {@link Calendar} as an HTTP response. */
    private interface CalendarDumper {
        /**
         * Outputs a representation of the calendar to the destination HTTP response object.
         * @param calendar The calendar to output.
         * @param destination The HTTP response to write to.
         */
        void dump(Calendar calendar, SlingHttpServletResponse destination) throws IOException;

        boolean requiresValidCalendar();
    }

    /** 
     * A {@link CalendarDumper} which outputs the calendar as JSON document.
     * 
     * <p>Note: The output from this dumper is the same as the JSON produced by the original 
     * implementation of {@link ICalProxyPostProcessor}.
     */
    private static final class JsonCalendarDumper implements CalendarDumper {

        static final JsonCalendarDumper INSTANCE = new JsonCalendarDumper();
        static final String NAME = "json";

        private JsonCalendarDumper() {
        }

        private void setupResponse(SlingHttpServletResponse response) {
            response.setCharacterEncoding(Charsets.UTF_8.name());
            response.setContentType("application/json");
        }

        public void dump(Calendar calendar, SlingHttpServletResponse response) throws IOException {

            setupResponse(response);

            // Build JSON response in memory to allow an error to be sent if something goes 
            // wrong.
            StringWriter writer = new StringWriter();
            JSONWriter json = new JSONWriter(writer);

            try {
                handleCalendar(json, calendar);
            } catch (JSONException e) {
                // A JSONException being thrown indicates a programmer error...
                throw new RuntimeException("Error converting calendar to JSON.", e);
            }

            response.getWriter().write(writer.toString());
        }

        private static void handleCalendar(JSONWriter json, Calendar calendar) throws JSONException {
            json.object();
            json.key("vcalendar").object();
            json.key("vevents").array();

            ComponentList list = calendar.getComponents(Component.VEVENT);
            for (int i = 0, size = list.size(); i < size; ++i) {
                VEvent event = (VEvent) list.get(i);
                handleComponent(json, event);
            }

            json.endArray().endObject().endObject();
        }

        private static void handleComponent(JSONWriter json, VEvent event) throws JSONException {
            json.object();
            PropertyList pList = event.getProperties();

            for (int i = 0, size = pList.size(); i < size; ++i) {
                net.fortuna.ical4j.model.Property p = (net.fortuna.ical4j.model.Property) pList.get(i);
                json.key(p.getName());
                // Check if it is a date
                String value = p.getValue();
                if (p instanceof DateProperty) {
                    DateProperty start = (DateProperty) p;
                    value = DateUtils.iso8601(start.getDate());
                }

                json.value(value);
            }
            json.endObject();
        }

        @Override
        public boolean requiresValidCalendar() {
            // We don't really care if the calendar is technically invalid when outputting JSON.
            return false;
        }
    }

    private static final class ICalCalendarDumper implements CalendarDumper {

        static final String NAME = "ical";
        static final ICalCalendarDumper INSTANCE = new ICalCalendarDumper();

        private ICalCalendarDumper() {
        }

        @Override
        public void dump(Calendar calendar, SlingHttpServletResponse response) throws IOException {
            response.setCharacterEncoding(Charsets.UTF_8.name());
            response.setContentType("text/calendar");
            try {
                new CalendarOutputter().output(calendar, response.getWriter());
                response.flushBuffer();
            } catch (ValidationException e) {
                // This should never happen because the calendar will already have been validated.
                LOG.error(ERR_ICAL_OUTPUT_FAILED, e);
                throw new RuntimeException(ERR_ICAL_OUTPUT_FAILED, e);
            }
        }

        @Override
        public boolean requiresValidCalendar() {
            // CalendarOutputter() is rather picky about validity and seems to barf if the 
            // structure of the calendar does not meet the iCal rfc to the letter...
            return true;
        }
    }

    /**
     * An exception which is raised to abort the normal HTTP response and respond with an
     * error instead.
     */
    @SuppressWarnings("serial")
    private class ResponseFailedException extends Exception {

        private final int status;

        public ResponseFailedException(int status, String message) {
            super(message);
            this.status = status;
        }

        public void populateHttpResponse(SlingHttpServletResponse response) throws IOException {
            response.sendError(status, getMessage());
        }
    }

    /**
     * An InputStream decorator which immediately throws a {@link StreamLengthException}
     * when more bytes than permitted are read from the stream.
     * 
     * <p>It should be noted that the intent is to blow up in a loud way when the limit is 
     * reached, rather than silently claiming the stream ended as Google Guava's 
     * LimitInputStream and commons IO's BoundedInputStreams do.
     */
    private static final class LengthLimitingInputStream extends ProxyInputStream {

        private final long maxLength;
        private long bytesRead;

        public LengthLimitingInputStream(InputStream in, long maxLength) {
            super(in);
            this.maxLength = maxLength;
            this.bytesRead = 0;
        }

        @Override
        public int read() throws IOException {
            return postReadHook(super.read());
        }

        @Override
        public int read(byte[] bts) throws IOException {
            return postReadHook(super.read(bts));
        }

        @Override
        public int read(byte[] bts, int st, int end) throws IOException {
            return postReadHook(super.read(bts, st, end));
        }

        @Override
        public long skip(long ln) throws IOException {
            long count = super.skip(ln);
            if (count > 0) {
                this.bytesRead += count;
                postRead();
            }
            return count;
        }

        /** Helper to increment bytesRead and call {@link #postRead()}*/
        private int postReadHook(int count) throws IOException {
            if (count > 0) {
                bytesRead += count;
                postRead();
            }
            return count;
        }

        private long getByteCount() {
            return bytesRead;
        }

        private void postRead() throws IOException {
            if (getByteCount() > this.maxLength) {
                throw new StreamLengthException(this.maxLength, getByteCount());
            }
        }

        @Override
        public synchronized void mark(int idx) {
            throw new RuntimeException("Not supported");
        }

        @Override
        public synchronized void reset() throws IOException {
            throw new RuntimeException("Not supported.");
        }

        @Override
        public boolean markSupported() {
            return false;
        }
    }

    /** Signals that too many bytes have been passed through a stream. */
    private static class StreamLengthException extends IOException {
        public StreamLengthException(long maxLength, long actualLength) {
            super(String.format("An attempt was made to pass more bytes than permitted "
                    + "through a stream. Max bytes: %s, processed count: %s", maxLength, actualLength));
        }
    }
}