com.almende.eve.agent.google.GoogleCalendarAgent.java Source code

Java tutorial

Introduction

Here is the source code for com.almende.eve.agent.google.GoogleCalendarAgent.java

Source

/**
 * @file GoogleCalendarAgent.java
 * 
 * @brief
 *        The GoogleCalendarAgent can connect to a single Google Calendar, and
 *        get, create, update, and delete events. The agent uses Goolges RESTful
 *        API v3
 *        to access a Google Calendar, and does not use any specific Java
 *        libraries
 *        for that. See:
 *        https://developers.google.com/google-apps/calendar/v3/reference/
 * 
 *        To setup authorization for a calendar agent, the method
 *        setAuthorization
 *        must be executed with valid authorization tokens. The agent will store
 *        the tokens and refresh them automatically when needed.
 *        To retrieve valid access tokens from google, the servlet
 *        GoogleAuth.java
 *        can be used. This servlet is typically running at /auth/google.
 *        Authorization needs to be setup only once for an agent.
 * 
 *        The GoogleCalendarAgent contains the following core methods:
 *        - getEvents Get all events in a given time window
 *        - getEvent Get a specific event by its id
 *        - createEvent Create a new event
 *        - updateEvent Update an existing event
 *        - deleteEvent Delete an existing event
 *        - getBusy Get the busy intervals in given time window
 *        - clear Delete all stored information
 * 
 * @license
 *          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.
 * 
 *          Copyright  2012 Almende B.V.
 * 
 * @author Jos de Jong, <jos@almende.org>
 * @date 2012-07-03
 */

/**
 * 
 * DOCUMENTATION:
 * https://developers.google.com/google-apps/calendar/v3/reference/
 * https://developers.google.com/google-apps/calendar/v3/reference/events#
 * resource
 */
package com.almende.eve.agent.google;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import org.joda.time.DateTime;

import com.almende.eve.agent.Agent;
import com.almende.eve.agent.CalendarAgent;
import com.almende.eve.config.Config;
import com.almende.eve.entity.calendar.Authorization;
import com.almende.eve.rpc.annotation.Access;
import com.almende.eve.rpc.annotation.AccessType;
import com.almende.eve.rpc.annotation.Name;
import com.almende.eve.rpc.annotation.Optional;
import com.almende.eve.rpc.jsonrpc.JSONRPCException;
import com.almende.eve.rpc.jsonrpc.JSONRPCException.CODE;
import com.almende.eve.rpc.jsonrpc.jackson.JOM;
import com.almende.eve.state.State;
import com.almende.util.HttpUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;

/**
 * The Class GoogleCalendarAgent.
 */
@Access(AccessType.PUBLIC)
public class GoogleCalendarAgent extends Agent implements CalendarAgent {

    // note: config parameters google.client_id and google.client_secret
    // are loaded from the eve configuration
    private static final String OAUTH_URI = "https://accounts.google.com/o/oauth2";
    private static final String CALENDAR_URI = "https://www.googleapis.com/calendar/v3/calendars/";

    /**
     * Set access token and refresh token, used to authorize the calendar agent.
     * These tokens must be retrieved via Oauth 2.0 authorization.
     * 
     * @param access_token
     *            the access_token
     * @param token_type
     *            the token_type
     * @param expires_in
     *            the expires_in
     * @param refresh_token
     *            the refresh_token
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     */
    public void setAuthorization(@Name("access_token") final String access_token,
            @Name("token_type") final String token_type, @Name("expires_in") final Integer expires_in,
            @Name("refresh_token") final String refresh_token) throws IOException {
        final State state = getState();

        // retrieve user information
        final String url = "https://www.googleapis.com/oauth2/v1/userinfo";
        final Map<String, String> headers = new HashMap<String, String>();
        headers.put("Authorization", token_type + " " + access_token);
        final String resp = HttpUtil.get(url, headers);

        final ObjectNode info = JOM.getInstance().readValue(resp, ObjectNode.class);
        final String email = info.has("email") ? info.get("email").asText() : null;
        final String name = info.has("name") ? info.get("name").asText() : null;

        final DateTime expires_at = calculateExpiresAt(expires_in);
        final Authorization auth = new Authorization(access_token, token_type, expires_at, refresh_token);

        // store the tokens in the state
        state.put("auth", auth);
        state.put("email", email);
        state.put("name", name);
    }

    /**
     * Calculate the expiration time from a life time
     * 
     * @param expires_in
     *            Expiration time in seconds
     * @return
     */
    private DateTime calculateExpiresAt(final Integer expires_in) {
        DateTime expires_at = null;
        if (expires_in != null && expires_in != 0) {
            // calculate expiration time, and subtract 5 minutes for safety
            expires_at = DateTime.now().plusSeconds(expires_in).minusMinutes(5);
        }
        return expires_at;
    }

    /**
     * Refresh the access token using the refresh token the tokens in provided
     * authorization object will be updated
     * 
     * @param auth
     * @throws Exception
     */
    private void refreshAuthorization(final Authorization auth) throws Exception {
        final String refresh_token = (auth != null) ? auth.getRefreshToken() : null;
        if (refresh_token == null) {
            throw new Exception("No refresh token available");
        }

        final Config config = getAgentHost().getConfig();
        final String client_id = config.get("google", "client_id");
        final String client_secret = config.get("google", "client_secret");

        // retrieve new access_token using the refresh_token
        final Map<String, String> params = new HashMap<String, String>();
        params.put("client_id", client_id);
        params.put("client_secret", client_secret);
        params.put("refresh_token", refresh_token);
        params.put("grant_type", "refresh_token");
        final String resp = HttpUtil.postForm(OAUTH_URI + "/token", params);
        final ObjectNode json = JOM.getInstance().readValue(resp, ObjectNode.class);
        if (!json.has("access_token")) {
            // TODO: give more specific error message
            throw new Exception("Retrieving new access token failed");
        }

        // update authorization
        if (json.has("access_token")) {
            auth.setAccessToken(json.get("access_token").asText());
        }
        if (json.has("expires_in")) {
            final Integer expires_in = json.get("expires_in").asInt();
            final DateTime expires_at = calculateExpiresAt(expires_in);
            auth.setExpiresAt(expires_at);
        }
    }

    /**
     * Remove all stored data from this agent.
     */
    @Override
    public void onDelete() {
        final State state = getState();
        state.remove("auth");
        state.remove("email");
        state.remove("name");
        super.onDelete();
    }

    /**
     * Get the username associated with the calendar.
     * 
     * @return name
     */
    @Override
    public String getUsername() {
        return getState().get("name", String.class);
    }

    /**
     * Get the email associated with the calendar.
     * 
     * @return email
     */
    @Override
    public String getEmail() {
        return getState().get("email", String.class);
    }

    /**
     * Get ready-made HTTP request headers containing the authorization token
     * Example usage: HttpUtil.get(url, getAuthorizationHeaders());
     * 
     * @return
     * @throws Exception
     */
    private Map<String, String> getAuthorizationHeaders() throws Exception {
        final Authorization auth = getAuthorization();

        final String access_token = (auth != null) ? auth.getAccessToken() : null;
        if (access_token == null) {
            throw new Exception("No authorization token available");
        }
        final String token_type = (auth != null) ? auth.getTokenType() : null;
        if (token_type == null) {
            throw new Exception("No token type available");
        }

        final Map<String, String> headers = new HashMap<String, String>();
        headers.put("Authorization", token_type + " " + access_token);
        return headers;
    }

    /**
     * Retrieve authorization tokens
     * 
     * @return
     * @throws Exception
     */
    private Authorization getAuthorization() throws Exception {
        final Authorization auth = getState().get("auth", Authorization.class);

        // check if access_token is expired
        final DateTime expires_at = (auth != null) ? auth.getExpiresAt() : null;
        if (expires_at != null && expires_at.isBeforeNow()) {
            refreshAuthorization(auth);
            getState().put("auth", auth);
        }

        return auth;
    }

    /**
     * Get the calendar agents version.
     * 
     * @return the version
     */
    @Override
    public String getVersion() {
        return "0.5";
    }

    /**
     * Get the calendar agents description.
     * 
     * @return the description
     */
    @Override
    public String getDescription() {
        return "This agent gives access to a Google Calendar. "
                + "It allows to search events, find free timeslots, " + "and add, edit, or remove events.";
    }

    /**
     * Convert the event from a Eve event to a Google event
     * 
     * @param event
     */
    private void toGoogleEvent(final ObjectNode event) {
        if (event.has("agent") && event.get("agent").isTextual()) {
            // move agent url from event.agent to extendedProperties
            final String agent = event.get("agent").asText();
            event.with("extendedProperties").with("shared").put("agent", agent);

            // TODO: change location into a string
        }
    }

    /**
     * Convert the event from a Google event to a Eve event
     * 
     * @param event
     */
    private void toEveEvent(final ObjectNode event) {
        final ObjectNode extendedProperties = (ObjectNode) event.get("extendedProperties");
        if (extendedProperties != null) {
            final ObjectNode shared = (ObjectNode) extendedProperties.get("shared");
            if (shared != null && shared.has("agent") && shared.get("agent").isTextual()) {
                // move agent url from extended properties to event.agent
                final String agent = shared.get("agent").asText();
                event.put("agent", agent);

                /*
                 * TODO: remove agent from extended properties
                 * shared.remove("agent"); if (shared.size() == 0) {
                 * extendedProperties.remove("shared"); if
                 * (extendedProperties.size() == 0) {
                 * event.remove("extendedProperties"); } }
                 */

                // TODO: replace string location with Location object
            }
        }
    }

    /**
     * Retrieve a list with all calendars in this google calendar.
     * 
     * @return the calendar list
     * @throws Exception
     *             the exception
     */
    @Override
    public ArrayNode getCalendarList() throws Exception {
        final String url = CALENDAR_URI + "users/me/calendarList";
        final String resp = HttpUtil.get(url, getAuthorizationHeaders());
        final ObjectNode calendars = JOM.getInstance().readValue(resp, ObjectNode.class);

        // check for errors
        if (calendars.has("error")) {
            final ObjectNode error = (ObjectNode) calendars.get("error");
            throw new JSONRPCException(error);
        }

        // get items from response
        ArrayNode items = null;
        if (calendars.has("items")) {
            items = (ArrayNode) calendars.get("items");
        } else {
            items = JOM.createArrayNode();
        }

        return items;
    }

    /**
     * Get todays events. A convenience method for easy testing
     * 
     * @param calendarId
     *            the calendar id
     * @return the events today
     * @throws Exception
     *             the exception
     */
    public ArrayNode getEventsToday(@Optional @Name("calendarId") final String calendarId) throws Exception {
        final DateTime now = DateTime.now();
        final DateTime timeMin = now.minusMillis(now.getMillisOfDay());
        final DateTime timeMax = timeMin.plusDays(1);

        return getEvents(timeMin.toString(), timeMax.toString(), calendarId);
    }

    /**
     * Get all events in given interval.
     * 
     * @param timeMin
     *            start of the interval
     * @param timeMax
     *            end of the interval
     * @param calendarId
     *            optional calendar id. If not provided, the default calendar is
     *            used
     * @return the events
     * @throws Exception
     *             the exception
     */
    @Override
    public ArrayNode getEvents(@Optional @Name("timeMin") final String timeMin,
            @Optional @Name("timeMax") final String timeMax, @Optional @Name("calendarId") String calendarId)
            throws Exception {
        // initialize optional parameters
        if (calendarId == null) {
            calendarId = getState().get("email", String.class);
        }

        // built url with query parameters
        String url = CALENDAR_URI + calendarId + "/events";
        final Map<String, String> params = new HashMap<String, String>();
        if (timeMin != null) {
            params.put("timeMin", new DateTime(timeMin).toString());
        }
        if (timeMax != null) {
            params.put("timeMax", new DateTime(timeMax).toString());
        }
        // Set singleEvents=true to expand recurring events into instances
        params.put("singleEvents", "true");
        url = HttpUtil.appendQueryParams(url, params);

        // perform GET request
        final Map<String, String> headers = getAuthorizationHeaders();
        final String resp = HttpUtil.get(url, headers);
        final ObjectMapper mapper = JOM.getInstance();
        final ObjectNode json = mapper.readValue(resp, ObjectNode.class);

        // check for errors
        if (json.has("error")) {
            final ObjectNode error = (ObjectNode) json.get("error");
            throw new JSONRPCException(error);
        }

        // get items from the response
        ArrayNode items = null;
        if (json.has("items")) {
            items = (ArrayNode) json.get("items");

            // convert from Google to Eve event
            for (int i = 0; i < items.size(); i++) {
                final ObjectNode item = (ObjectNode) items.get(i);
                toEveEvent(item);
            }
        } else {
            items = JOM.createArrayNode();
        }

        return items;
    }

    /**
     * Get busy intervals of today. A convenience method for easy testing
     * 
     * @param calendarId
     *            optional calendar id. If not provided, the default calendar is
     *            used
     * @param timeZone
     *            Time zone used in the response. Optional. The default is UTC.
     * @return the busy today
     * @throws Exception
     *             the exception
     */
    public ArrayNode getBusyToday(@Optional @Name("calendarId") final String calendarId,
            @Optional @Name("timeZone") final String timeZone) throws Exception {
        final DateTime now = DateTime.now();
        final DateTime timeMin = now.minusMillis(now.getMillisOfDay());
        final DateTime timeMax = timeMin.plusDays(1);

        return getBusy(timeMin.toString(), timeMax.toString(), calendarId, timeZone);
    }

    /**
     * Get all busy event timeslots in given interval.
     * 
     * @param timeMin
     *            start of the interval
     * @param timeMax
     *            end of the interval
     * @param calendarId
     *            optional calendar id. If not provided, the default calendar is
     *            used
     * @param timeZone
     *            Time zone used in the response. Optional. The default is UTC.
     * @return the busy
     * @throws Exception
     *             the exception
     */
    @Override
    public ArrayNode getBusy(@Name("timeMin") final String timeMin, @Name("timeMax") final String timeMax,
            @Optional @Name("calendarId") String calendarId, @Optional @Name("timeZone") final String timeZone)
            throws Exception {
        // initialize optional parameters
        if (calendarId == null) {
            calendarId = getState().get("email", String.class);
        }

        // build request body
        final ObjectNode request = new ObjectNode(JsonNodeFactory.instance);
        request.put("timeMin", new DateTime(timeMin).toString());
        request.put("timeMax", new DateTime(timeMax).toString());

        if (timeZone != null) {
            request.put("timeZone", timeZone);
        }

        final ArrayNode node = request.putArray("items");
        node.addObject().put("id", calendarId);

        final String url = "https://www.googleapis.com/calendar/v3/freeBusy";

        // perform POST request
        final ObjectMapper mapper = JOM.getInstance();
        final String body = mapper.writeValueAsString(request);
        final Map<String, String> headers = getAuthorizationHeaders();
        headers.put("Content-Type", "application/json");
        final String resp = HttpUtil.post(url, body, headers);
        final ObjectNode response = mapper.readValue(resp, ObjectNode.class);

        // check for errors
        if (response.has("error")) {
            final ObjectNode error = (ObjectNode) response.get("error");
            throw new JSONRPCException(error);
        }

        // get items from the response
        ArrayNode items = null;
        if (response.has("calendars")) {
            final JsonNode calendars = response.get("calendars");
            if (calendars.has(calendarId)) {
                final JsonNode calendar = calendars.get(calendarId);
                if (calendar.has("busy")) {
                    items = (ArrayNode) calendar.get("busy");
                }
            }
        }

        if (items == null) {
            items = JOM.createArrayNode();
        }

        return items;
    }

    /**
     * Get a single event by id.
     * 
     * @param eventId
     *            Id of the event
     * @param calendarId
     *            Optional calendar id. the primary calendar is used by default
     * @return the event
     * @throws Exception
     *             the exception
     */
    @Override
    public ObjectNode getEvent(@Name("eventId") final String eventId,
            @Optional @Name("calendarId") String calendarId) throws Exception {
        // initialize optional parameters
        if (calendarId == null) {
            calendarId = getState().get("email", String.class);
        }

        // built url
        final String url = CALENDAR_URI + calendarId + "/events/" + eventId;

        // perform GET request
        final Map<String, String> headers = getAuthorizationHeaders();
        final String resp = HttpUtil.get(url, headers);
        final ObjectMapper mapper = JOM.getInstance();
        final ObjectNode event = mapper.readValue(resp, ObjectNode.class);

        // convert from Google to Eve event
        toEveEvent(event);

        // check for errors
        if (event.has("error")) {
            final ObjectNode error = (ObjectNode) event.get("error");
            final Integer code = error.has("code") ? error.get("code").asInt() : null;
            if (code != null && (code.equals(404) || code.equals(410))) {
                throw new JSONRPCException(CODE.NOT_FOUND);
            }

            throw new JSONRPCException(error);
        }

        // check if canceled. If so, return null
        // TODO: be able to retrieve canceled events?
        if (event.has("status") && event.get("status").asText().equals("cancelled")) {
            throw new JSONRPCException(CODE.NOT_FOUND);
        }

        return event;
    }

    /**
     * Create an event.
     * 
     * @param event
     *            JSON structure containing the calendar event
     * @param calendarId
     *            Optional calendar id. the primary calendar is used by default
     * @return createdEvent JSON structure with the created event
     * @throws Exception
     *             the exception
     */
    @Override
    public ObjectNode createEvent(@Name("event") final ObjectNode event,
            @Optional @Name("calendarId") String calendarId) throws Exception {
        // initialize optional parameters
        if (calendarId == null) {
            calendarId = getState().get("email", String.class);
        }

        // built url
        final String url = CALENDAR_URI + calendarId + "/events";

        // convert from Google to Eve event
        toGoogleEvent(event);

        // perform POST request
        final ObjectMapper mapper = JOM.getInstance();
        final String body = mapper.writeValueAsString(event);
        final Map<String, String> headers = getAuthorizationHeaders();
        headers.put("Content-Type", "application/json");
        final String resp = HttpUtil.post(url, body, headers);
        final ObjectNode createdEvent = mapper.readValue(resp, ObjectNode.class);

        // convert from Google to Eve event
        toEveEvent(event);

        // check for errors
        if (createdEvent.has("error")) {
            final ObjectNode error = (ObjectNode) createdEvent.get("error");
            throw new JSONRPCException(error);
        }

        return createdEvent;
    }

    /**
     * Quick create an event.
     * 
     * @param start
     *            the start
     * @param end
     *            the end
     * @param summary
     *            the summary
     * @param location
     *            the location
     * @param calendarId
     *            the calendar id
     * @return the object node
     * @throws Exception
     *             the exception
     */
    public ObjectNode createEventQuick(@Optional @Name("start") String start, @Optional @Name("end") String end,
            @Optional @Name("summary") final String summary, @Optional @Name("location") final String location,
            @Optional @Name("calendarId") final String calendarId) throws Exception {
        final ObjectNode event = JOM.createObjectNode();

        if (start == null) {
            // set start to current time, rounded to hours
            DateTime startDate = DateTime.now();
            startDate = startDate.plusHours(1);
            startDate = startDate.minusMinutes(startDate.getMinuteOfHour());
            startDate = startDate.minusSeconds(startDate.getSecondOfMinute());
            startDate = startDate.minusMillis(startDate.getMillisOfSecond());
            start = startDate.toString();
        }
        final ObjectNode startObj = JOM.createObjectNode();
        startObj.put("dateTime", start);
        event.put("start", startObj);
        if (end == null) {
            // set end to start +1 hour
            final DateTime startDate = new DateTime(start);
            final DateTime endDate = startDate.plusHours(1);
            end = endDate.toString();
        }
        final ObjectNode endObj = JOM.createObjectNode();
        endObj.put("dateTime", end);
        event.put("end", endObj);
        if (summary != null) {
            event.put("summary", summary);
        }
        if (location != null) {
            event.put("location", location);
        }

        return createEvent(event, calendarId);
    }

    /**
     * Update an existing event.
     * 
     * @param event
     *            JSON structure containing the calendar event (event must have
     *            an id)
     * @param calendarId
     *            Optional calendar id. the primary calendar is used by default
     * @return updatedEvent JSON structure with the updated event
     * @throws Exception
     *             the exception
     */
    @Override
    public ObjectNode updateEvent(@Name("event") final ObjectNode event,
            @Optional @Name("calendarId") String calendarId) throws Exception {
        // initialize optional parameters
        if (calendarId == null) {
            calendarId = getState().get("email", String.class);
        }

        // convert from Eve to Google event
        toGoogleEvent(event);

        // read id from event
        final String id = event.get("id").asText();
        if (id == null) {
            throw new Exception("Parameter 'id' missing in event");
        }

        // built url
        final String url = CALENDAR_URI + calendarId + "/events/" + id;

        // perform POST request
        final ObjectMapper mapper = JOM.getInstance();
        final String body = mapper.writeValueAsString(event);
        final Map<String, String> headers = getAuthorizationHeaders();
        headers.put("Content-Type", "application/json");
        final String resp = HttpUtil.put(url, body, headers);
        final ObjectNode updatedEvent = mapper.readValue(resp, ObjectNode.class);

        // check for errors
        if (updatedEvent.has("error")) {
            final ObjectNode error = (ObjectNode) updatedEvent.get("error");
            throw new JSONRPCException(error);
        }

        // convert from Google to Eve event
        toEveEvent(event);

        return updatedEvent;
    }

    /**
     * Delete an existing event.
     * 
     * @param eventId
     *            id of the event to be deleted
     * @param calendarId
     *            Optional calendar id. the primary calendar is used by default
     * @throws Exception
     *             the exception
     */
    @Override
    public void deleteEvent(@Name("eventId") final String eventId, @Optional @Name("calendarId") String calendarId)
            throws Exception {
        // initialize optional parameters
        if (calendarId == null) {
            calendarId = getState().get("email", String.class);
        }

        // built url
        final String url = CALENDAR_URI + calendarId + "/events/" + eventId;

        // perform POST request
        final Map<String, String> headers = getAuthorizationHeaders();
        final String resp = HttpUtil.delete(url, headers);
        if (!resp.isEmpty()) {
            final ObjectNode node = JOM.getInstance().readValue(resp, ObjectNode.class);

            // check error code
            if (node.has("error")) {
                final ObjectNode error = (ObjectNode) node.get("error");
                final Integer code = error.has("code") ? error.get("code").asInt() : null;
                if (code != null && (code.equals(404) || code.equals(410))) {
                    throw new JSONRPCException(CODE.NOT_FOUND);
                }

                throw new JSONRPCException(error);
            } else {
                throw new Exception(resp);
            }
        }
    }
}