diff options
author | Jesse Morgan <jesse@jesterpm.net> | 2016-04-09 14:22:20 -0700 |
---|---|---|
committer | Jesse Morgan <jesse@jesterpm.net> | 2016-04-09 15:48:01 -0700 |
commit | 3102d8bce3426d9cf41aeaf201c360d342677770 (patch) | |
tree | 38c4f1e8828f9af9c4b77a173bee0d312b321698 /src/main/java | |
parent | bbf907e51dfcf157bdee24dead1d531122aa25db (diff) |
Switching from Ivy+Ant to Maven.
Diffstat (limited to 'src/main/java')
123 files changed, 12215 insertions, 0 deletions
diff --git a/src/main/java/com/p4square/f1oauth/Attribute.java b/src/main/java/com/p4square/f1oauth/Attribute.java new file mode 100644 index 0000000..64f2507 --- /dev/null +++ b/src/main/java/com/p4square/f1oauth/Attribute.java @@ -0,0 +1,90 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.f1oauth; + +import java.util.Date; + +/** + * F1 Attribute Data. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class Attribute { + private final String mAttributeName; + private String mId; + private Date mStartDate; + private Date mEndDate; + private String mComment; + + /** + * @param name The attribute name. + */ + public Attribute(final String name) { + mAttributeName = name; + } + + /** + * @return the Attribute name. + */ + public String getAttributeName() { + return mAttributeName; + } + + /** + * @return the id of this specific attribute instance. + */ + public String getId() { + return mId; + } + + /** + * Set the attribute id to id. + */ + public void setId(final String id) { + mId = id; + } + + /** + * @return the start date for the attribute. + */ + public Date getStartDate() { + return mStartDate; + } + + /** + * Set the start date for the attribute. + */ + public void setStartDate(final Date date) { + mStartDate = date; + } + + /** + * @return the end date for the attribute. + */ + public Date getEndDate() { + return mEndDate; + } + + /** + * Set the end date for the attribute. + */ + public void setEndDate(final Date date) { + mEndDate = date; + } + + /** + * @return The comment on the Attribute. + */ + public String getComment() { + return mComment; + } + + /** + * Set the comment on the attribute. + */ + public void setComment(final String comment) { + mComment = comment; + } +} diff --git a/src/main/java/com/p4square/f1oauth/F1API.java b/src/main/java/com/p4square/f1oauth/F1API.java new file mode 100644 index 0000000..a525c3f --- /dev/null +++ b/src/main/java/com/p4square/f1oauth/F1API.java @@ -0,0 +1,56 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.f1oauth; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import com.p4square.restlet.oauth.OAuthException; +import com.p4square.restlet.oauth.OAuthUser; + +/** + * F1 API methods which require an authenticated user. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public interface F1API { + /** + * Fetch information about a user. + * + * @param user The user to fetch information about. + * @return An F1User object. + */ + F1User getF1User(OAuthUser user) throws OAuthException, IOException; + + /** + * Fetch a list of all attributes ids and names. + * + * @return A Map of attribute name to attribute id. + */ + Map<String, String> getAttributeList() throws F1Exception; + + /** + * Add an attribute to the user. + * + * @param user The user to add the attribute to. + * @param attributeName The attribute to add. + * @param attribute The attribute to add. + */ + boolean addAttribute(String userId, Attribute attribute) throws F1Exception; + + /** + * Return attributes assigned to user. + * + * A user may be assigned multiple attributes with the same name, thus even if + * attributeName is specified, multiple attributes may be returned. + * + * @param userId The user to query. + * @param attributeName A specific attribute to return, null for all. + * @return A list of Attributes + */ + List<Attribute> getAttribute(String userId, String attributeName) throws F1Exception; + +} diff --git a/src/main/java/com/p4square/f1oauth/F1Access.java b/src/main/java/com/p4square/f1oauth/F1Access.java new file mode 100644 index 0000000..c3307f1 --- /dev/null +++ b/src/main/java/com/p4square/f1oauth/F1Access.java @@ -0,0 +1,594 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.f1oauth; + +import java.io.IOException; +import java.net.URLEncoder; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.codahale.metrics.Counter; +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; + +import org.apache.log4j.Logger; + +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.ChallengeResponse; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.MediaType; +import org.restlet.data.Method; +import org.restlet.data.Status; +import org.restlet.engine.util.Base64; +import org.restlet.ext.jackson.JacksonRepresentation; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; + +import com.p4square.restlet.oauth.OAuthException; +import com.p4square.restlet.oauth.OAuthHelper; +import com.p4square.restlet.oauth.OAuthUser; +import com.p4square.restlet.oauth.Token; + +/** + * F1 API Access. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class F1Access { + public enum UserType { + WEBLINK, PORTAL; + } + + private static final Logger LOG = Logger.getLogger(F1Access.class); + + private static final String VERSION_STRING = "/v1/"; + private static final String REQUESTTOKEN_URL = "Tokens/RequestToken"; + private static final String AUTHORIZATION_URL = "Login"; + private static final String ACCESSTOKEN_URL= "Tokens/AccessToken"; + private static final String TRUSTED_ACCESSTOKEN_URL = "/AccessToken"; + + private static final SimpleDateFormat DATE_FORMAT = + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); + + private final String mBaseUrl; + private final String mMethod; + + private final OAuthHelper mOAuthHelper; + + private final Map<String, String> mAttributeIdByName; + + private MetricRegistry mMetricRegistry; + + /** + */ + public F1Access(Context context, String consumerKey, String consumerSecret, + String baseUrl, String churchCode, UserType userType) { + + switch (userType) { + case WEBLINK: + mMethod = "WeblinkUser"; + break; + case PORTAL: + mMethod = "PortalUser"; + break; + default: + throw new IllegalArgumentException("Unknown UserType"); + } + + mBaseUrl = "https://" + churchCode + "." + baseUrl + VERSION_STRING; + + // Create the OAuthHelper. This implicitly registers the helper to + // handle outgoing requests which need OAuth authentication. + mOAuthHelper = new OAuthHelper(context, consumerKey, consumerSecret) { + @Override + protected String getRequestTokenUrl() { + return mBaseUrl + REQUESTTOKEN_URL; + } + + @Override + public String getLoginUrl(Token requestToken, String callback) { + String loginUrl = mBaseUrl + mMethod + AUTHORIZATION_URL + + "?oauth_token=" + URLEncoder.encode(requestToken.getToken()); + + if (callback != null) { + loginUrl += "&oauth_callback=" + URLEncoder.encode(callback); + } + + return loginUrl; + } + + @Override + protected String getAccessTokenUrl() { + return mBaseUrl + ACCESSTOKEN_URL; + } + }; + + mAttributeIdByName = new HashMap<>(); + } + + /** + * Set the MetricRegistry to get metrics recorded. + */ + public void setMetricRegistry(MetricRegistry metrics) { + mMetricRegistry = metrics; + } + + /** + * Request an AccessToken for a particular username and password. + * + * This is an F1 extension to OAuth: + * http://developer.fellowshipone.com/docs/v1/Util/AuthDocs.help#2creds + */ + public OAuthUser getAccessToken(String username, String password) throws OAuthException { + Timer.Context timer = getTimer("F1Access.getAccessToken.time"); + boolean success = true; + + try { + Request request = new Request(Method.POST, + mBaseUrl + mMethod + TRUSTED_ACCESSTOKEN_URL); + request.setChallengeResponse(new ChallengeResponse(ChallengeScheme.HTTP_OAUTH)); + + String base64String = Base64.encode((username + " " + password).getBytes(), false); + request.setEntity(new StringRepresentation(base64String)); + + return mOAuthHelper.processAccessTokenRequest(request); + + } catch (Exception e) { + success = false; + throw e; + + } finally { + if (timer != null) { + timer.stop(); + } + if (success) { + incrementCounter("F1Access.getAccessToken.success"); + } else { + incrementCounter("F1Access.getAccessToken.failure"); + } + } + } + + /** + * Create a new Account. + * + * @param firstname The user's first name. + * @param lastname The user's last name. + * @param email The user's email address. + * @param redirect The URL to send the user to after confirming his address. + * + * @return true if created, false if the account already exists. + */ + public boolean createAccount(String firstname, String lastname, String email, String redirect) + throws OAuthException { + Timer.Context timer = getTimer("F1Access.createAccount.time"); + boolean success = true; + + try { + String req = String.format("{\n\"account\":{\n\"firstName\":\"%s\",\n" + + "\"lastName\":\"%s\",\n\"email\":\"%s\",\n" + + "\"urlRedirect\":\"%s\"\n}\n}", + firstname, lastname, email, redirect); + + Request request = new Request(Method.POST, mBaseUrl + "Accounts"); + request.setChallengeResponse(new ChallengeResponse(ChallengeScheme.HTTP_OAUTH)); + request.setEntity(new StringRepresentation(req, MediaType.APPLICATION_JSON)); + + Response response = mOAuthHelper.getResponse(request); + + Status status = response.getStatus(); + if (Status.SUCCESS_NO_CONTENT.equals(status)) { + return true; + + } else if (Status.CLIENT_ERROR_CONFLICT.equals(status)) { + return false; + + } else { + throw new OAuthException(status); + } + + } catch (Exception e) { + success = false; + throw e; + + } finally { + if (timer != null) { + timer.stop(); + } + if (success) { + incrementCounter("F1Access.createAccount.success"); + } else { + incrementCounter("F1Access.createAccount.failure"); + } + } + } + + /** + * @return An F1API authenticated by the given user. + */ + public F1API getAuthenticatedApi(OAuthUser user) { + return new AuthenticatedApi(user); + } + + private class AuthenticatedApi implements F1API { + private final OAuthUser mUser; + + public AuthenticatedApi(OAuthUser user) { + mUser = user; + } + + /** + * Fetch information about a user. + * + * @param user The user to fetch information about. + * @return An F1User object. + */ + @Override + public F1User getF1User(OAuthUser user) throws OAuthException, IOException { + Timer.Context timer = getTimer("F1Access.getF1User.time"); + boolean success = true; + + try { + Request request = new Request(Method.GET, user.getLocation() + ".json"); + request.setChallengeResponse(mUser.getChallengeResponse()); + Response response = mOAuthHelper.getResponse(request); + + try { + Status status = response.getStatus(); + if (status.isSuccess()) { + JacksonRepresentation<Map> entity = + new JacksonRepresentation<Map>(response.getEntity(), Map.class); + Map data = entity.getObject(); + return new F1User(user, data); + + } else { + throw new OAuthException(status); + } + + } finally { + if (response.getEntity() != null) { + response.release(); + } + } + + } catch (Exception e) { + success = false; + throw e; + + } finally { + if (timer != null) { + timer.stop(); + } + if (success) { + incrementCounter("F1Access.getF1User.success"); + } else { + incrementCounter("F1Access.getF1User.failure"); + } + } + } + + @Override + public Map<String, String> getAttributeList() throws F1Exception { + // Note: this list is shared by all F1 users. + synchronized (mAttributeIdByName) { + if (mAttributeIdByName.size() == 0) { + Timer.Context timer = getTimer("F1Access.getAttributeList.time"); + boolean success = true; + + try { + // Reload attributes. Maybe it will be there now... + Request request = new Request(Method.GET, + mBaseUrl + "People/AttributeGroups.json"); + request.setChallengeResponse(mUser.getChallengeResponse()); + Response response = mOAuthHelper.getResponse(request); + + Representation representation = response.getEntity(); + try { + Status status = response.getStatus(); + if (status.isSuccess()) { + JacksonRepresentation<Map> entity = + new JacksonRepresentation<Map>(response.getEntity(), Map.class); + + Map attributeGroups = (Map) entity.getObject().get("attributeGroups"); + List<Map> groups = (List<Map>) attributeGroups.get("attributeGroup"); + + for (Map group : groups) { + List<Map> attributes = (List<Map>) group.get("attribute"); + if (attributes != null) { + for (Map attribute : attributes) { + String id = (String) attribute.get("@id"); + String name = ((String) attribute.get("name")); + mAttributeIdByName.put(name.toLowerCase(), id); + LOG.debug("Caching attribute '" + name + + "' with id '" + id + "'"); + } + } + } + } + + } catch (IOException e) { + throw new F1Exception("Could not parse AttributeGroups.", e); + + } finally { + if (representation != null) { + representation.release(); + } + } + + } catch (Exception e) { + success = false; + throw e; + + } finally { + if (timer != null) { + timer.stop(); + } + if (success) { + incrementCounter("F1Access.getAttributeList.success"); + } else { + incrementCounter("F1Access.getAttributeList.failure"); + } + } + } + + return mAttributeIdByName; + } + } + + /** + * Add an attribute to the user. + * + * @param user The user to add the attribute to. + * @param attributeName The attribute to add. + * @param attribute The attribute to add. + */ + public boolean addAttribute(String userId, Attribute attribute) + throws F1Exception { + + // Get the attribute id. + String attributeId = getAttributeId(attribute.getAttributeName()); + if (attributeId == null) { + throw new F1Exception("Could not find id for " + attribute.getAttributeName()); + } + + // Get Attribute Template + Map attributeTemplate = null; + + Timer.Context timer = getTimer("F1Access.addAttribute.GET.time"); + boolean success = true; + + try { + Request request = new Request(Method.GET, + mBaseUrl + "People/" + userId + "/Attributes/new.json"); + request.setChallengeResponse(mUser.getChallengeResponse()); + Response response = mOAuthHelper.getResponse(request); + + Representation representation = response.getEntity(); + try { + Status status = response.getStatus(); + if (status.isSuccess()) { + JacksonRepresentation<Map> entity = + new JacksonRepresentation<Map>(response.getEntity(), Map.class); + attributeTemplate = entity.getObject(); + + } else { + throw new F1Exception("Failed to retrieve attribute template: " + + status); + } + + } catch (IOException e) { + throw new F1Exception("Could not parse attribute template.", e); + + } finally { + if (representation != null) { + representation.release(); + } + } + } catch (Exception e) { + success = false; + throw e; + + } finally { + if (timer != null) { + timer.stop(); + } + if (success) { + incrementCounter("F1Access.addAttribute.GET.success"); + } else { + incrementCounter("F1Access.addAttribute.GET.failure"); + } + } + + if (attributeTemplate == null) { + throw new F1Exception("Could not retrieve attribute template."); + } + + // Populate Attribute Template + Map attributeMap = (Map) attributeTemplate.get("attribute"); + Map attributeGroup = (Map) attributeMap.get("attributeGroup"); + + Map<String, String> attributeIdMap = new HashMap<>(); + attributeIdMap.put("@id", attributeId); + attributeGroup.put("attribute", attributeIdMap); + + if (attribute.getStartDate() != null) { + attributeMap.put("startDate", DATE_FORMAT.format(attribute.getStartDate())); + } + + if (attribute.getStartDate() != null) { + attributeMap.put("endDate", DATE_FORMAT.format(attribute.getStartDate())); + } + + attributeMap.put("comment", attribute.getComment()); + + // POST new attribute + Status status; + timer = getTimer("F1Access.addAttribute.POST.time"); + success = true; + + try { + Request request = new Request(Method.POST, + mBaseUrl + "People/" + userId + "/Attributes.json"); + request.setChallengeResponse(mUser.getChallengeResponse()); + request.setEntity(new JacksonRepresentation<Map>(attributeTemplate)); + Response response = mOAuthHelper.getResponse(request); + + Representation representation = response.getEntity(); + try { + status = response.getStatus(); + + if (status.isSuccess()) { + return true; + } + + } finally { + if (representation != null) { + representation.release(); + } + } + } catch (Exception e) { + success = false; + throw e; + + } finally { + if (timer != null) { + timer.stop(); + } + if (success) { + incrementCounter("F1Access.addAttribute.POST.success"); + } else { + incrementCounter("F1Access.getAccessToken.POST.failure"); + } + } + + LOG.debug("addAttribute failed POST: " + status); + return false; + } + + @Override + public List<Attribute> getAttribute(String userId, String attributeNameFilter) + throws F1Exception { + + Map attributesResponse; + + // Get Attributes + Timer.Context timer = getTimer("F1Access.getAttribute.time"); + boolean success = true; + + try { + Request request = new Request(Method.GET, + mBaseUrl + "People/" + userId + "/Attributes.json"); + request.setChallengeResponse(mUser.getChallengeResponse()); + Response response = mOAuthHelper.getResponse(request); + + Representation representation = response.getEntity(); + try { + Status status = response.getStatus(); + if (status.isSuccess()) { + JacksonRepresentation<Map> entity = + new JacksonRepresentation<Map>(response.getEntity(), Map.class); + attributesResponse = entity.getObject(); + + } else { + throw new F1Exception("Failed to retrieve attributes: " + + status); + } + + } catch (IOException e) { + throw new F1Exception("Could not parse attributes.", e); + + } finally { + if (representation != null) { + representation.release(); + } + } + } catch (Exception e) { + success = false; + throw e; + + } finally { + if (timer != null) { + timer.stop(); + } + if (success) { + incrementCounter("F1Access.getAttribute.success"); + } else { + incrementCounter("F1Access.getAttribute.failure"); + } + } + + // Parse Response + List<Attribute> result = new ArrayList<>(); + + try { + // I feel like I'm writing lisp here... + Map attributesMap = (Map) attributesResponse.get("attributes"); + if (attributesMap == null) { + return result; + } + + List<Map> attributes = (List<Map>) (attributesMap).get("attribute"); + for (Map attributeMap : attributes) { + String id = (String) attributeMap.get("@id"); + String startDate = (String) attributeMap.get("startDate"); + String endDate = (String) attributeMap.get("endDate"); + String comment = (String) attributeMap.get("comment"); + + Map attributeIdMap = (Map) ((Map) attributeMap.get("attributeGroup")) + .get("attribute"); + String attributeName = (String) attributeIdMap.get("name"); + + if (attributeNameFilter == null + || attributeNameFilter.equalsIgnoreCase(attributeName)) { + + Attribute attribute = new Attribute(attributeName); + attribute.setId(id); + if (startDate != null) { + attribute.setStartDate(DATE_FORMAT.parse(startDate)); + } + if (endDate != null) { + attribute.setEndDate(DATE_FORMAT.parse(endDate)); + } + attribute.setComment(comment); + result.add(attribute); + } + } + } catch (Exception e) { + throw new F1Exception("Failed to parse attributes response.", e); + } + + return result; + } + + /** + * @return an attribute id for the given attribute name. + */ + private String getAttributeId(String attributeName) throws F1Exception { + Map<String, String> attributeMap = getAttributeList(); + + return attributeMap.get(attributeName.toLowerCase()); + } + + } + + private Timer.Context getTimer(String name) { + if (mMetricRegistry != null) { + return mMetricRegistry.timer(name).time(); + } else { + return null; + } + } + + private void incrementCounter(String name) { + if (mMetricRegistry != null) { + mMetricRegistry.counter(name).inc(); + } + } +} diff --git a/src/main/java/com/p4square/f1oauth/F1Exception.java b/src/main/java/com/p4square/f1oauth/F1Exception.java new file mode 100644 index 0000000..54c1a77 --- /dev/null +++ b/src/main/java/com/p4square/f1oauth/F1Exception.java @@ -0,0 +1,15 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.f1oauth; + +public class F1Exception extends Exception { + public F1Exception(String message) { + super(message); + } + + public F1Exception(String message, Exception cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/p4square/f1oauth/F1ProgressReporter.java b/src/main/java/com/p4square/f1oauth/F1ProgressReporter.java new file mode 100644 index 0000000..8382020 --- /dev/null +++ b/src/main/java/com/p4square/f1oauth/F1ProgressReporter.java @@ -0,0 +1,57 @@ +package com.p4square.f1oauth; + +import com.p4square.grow.frontend.ProgressReporter; +import org.apache.log4j.Logger; +import org.restlet.security.User; + +import java.util.Date; + +/** + * A ProgressReporter implementation to record progress in F1. + */ +public class F1ProgressReporter implements ProgressReporter { + + private static final Logger LOG = Logger.getLogger(F1ProgressReporter.class); + + private F1Access mF1Access; + + public F1ProgressReporter(final F1Access f1access) { + mF1Access = f1access; + } + + @Override + public void reportAssessmentComplete(final User user, final String level, final Date date, final String results) { + String attributeName = "Assessment Complete - " + level; + Attribute attribute = new Attribute(attributeName); + attribute.setStartDate(date); + attribute.setComment(results); + addAttribute(user, attribute); + } + + @Override + public void reportChapterComplete(final User user, final String chapter, final Date date) { + final String attributeName = "Training Complete - " + chapter; + final Attribute attribute = new Attribute(attributeName); + attribute.setStartDate(date); + addAttribute(user, attribute); + } + + private void addAttribute(final User user, final Attribute attribute) { + if (!(user instanceof F1User)) { + throw new IllegalArgumentException("User must be an F1User, but got " + user.getClass().getName()); + } + + try { + final F1User f1User = (F1User) user; + final F1API f1 = mF1Access.getAuthenticatedApi(f1User); + + if (!f1.addAttribute(user.getIdentifier(), attribute)) { + LOG.error("addAttribute failed for " + user.getIdentifier() + " with attribute " + + attribute.getAttributeName()); + } + } catch (Exception e) { + LOG.error("addAttribute failed for " + user.getIdentifier() + " with attribute " + + attribute.getAttributeName(), e); + } + } +} diff --git a/src/main/java/com/p4square/f1oauth/F1User.java b/src/main/java/com/p4square/f1oauth/F1User.java new file mode 100644 index 0000000..e5ab487 --- /dev/null +++ b/src/main/java/com/p4square/f1oauth/F1User.java @@ -0,0 +1,70 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.f1oauth; + +import java.util.Map; + +import com.p4square.restlet.oauth.OAuthException; +import com.p4square.restlet.oauth.OAuthUser; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class F1User extends OAuthUser { + public static final String ID = "@id"; + public static final String FIRST_NAME = "firstName"; + public static final String LAST_NAME = "lastName"; + public static final String ICODE = "@iCode"; + + private final Map mData; + + /** + * Copy the user information from user into a new F1User. + * + * @param user Original user. + * @param data F1 Person Record. + * @throws IllegalStateException if data.get("person") is null. + */ + public F1User(OAuthUser user, Map data) { + super(user.getLocation(), user.getToken()); + + mData = (Map) data.get("person"); + if (mData == null) { + throw new IllegalStateException("Bad data"); + } + + setIdentifier(getString(ID)); + setFirstName(getString(FIRST_NAME)); + setLastName(getString(LAST_NAME)); + } + + /** + * Get a String from the map. + * + * @param key The map key. + * @return The value associated with the key, or null. + */ + public String getString(String key) { + Object blob = get(key); + + if (blob instanceof String) { + return (String) blob; + + } else { + return null; + } + } + + /** + * Fetch an object from the F1 record. + * + * @param key The map key + * @return The object in the map or null. + */ + public Object get(String key) { + return mData.get(key); + } +} diff --git a/src/main/java/com/p4square/f1oauth/FellowshipOneIntegrationDriver.java b/src/main/java/com/p4square/f1oauth/FellowshipOneIntegrationDriver.java new file mode 100644 index 0000000..865f5d6 --- /dev/null +++ b/src/main/java/com/p4square/f1oauth/FellowshipOneIntegrationDriver.java @@ -0,0 +1,55 @@ +package com.p4square.f1oauth; + +import com.codahale.metrics.MetricRegistry; +import com.p4square.grow.config.Config; +import com.p4square.grow.frontend.IntegrationDriver; +import com.p4square.grow.frontend.ProgressReporter; +import org.restlet.Context; +import org.restlet.security.Verifier; + +/** + * The FellowshipOneIntegrationDriver creates implementations of various + * objects to support integration with Fellowship One. + */ +public class FellowshipOneIntegrationDriver implements IntegrationDriver { + + private final Context mContext; + private final MetricRegistry mMetricRegistry; + private final Config mConfig; + private final F1Access mAPI; + + private final ProgressReporter mProgressReporter; + + public FellowshipOneIntegrationDriver(final Context context) { + mContext = context; + mConfig = (Config) context.getAttributes().get("com.p4square.grow.config"); + mMetricRegistry = (MetricRegistry) context.getAttributes().get("com.p4square.grow.metrics"); + + mAPI = new F1Access(context, + mConfig.getString("f1ConsumerKey", ""), + mConfig.getString("f1ConsumerSecret", ""), + mConfig.getString("f1BaseUrl", "staging.fellowshiponeapi.com"), + mConfig.getString("f1ChurchCode", "pfseawa"), + F1Access.UserType.WEBLINK); + mAPI.setMetricRegistry(mMetricRegistry); + + mProgressReporter = new F1ProgressReporter(mAPI); + } + + /** + * @return An F1Access instance. + */ + public F1Access getF1Access() { + return mAPI; + } + + @Override + public Verifier newUserAuthenticationVerifier() { + return new SecondPartyVerifier(mContext, mAPI); + } + + @Override + public ProgressReporter getProgressReporter() { + return mProgressReporter; + } +} diff --git a/src/main/java/com/p4square/f1oauth/SecondPartyAuthenticator.java b/src/main/java/com/p4square/f1oauth/SecondPartyAuthenticator.java new file mode 100644 index 0000000..8deefec --- /dev/null +++ b/src/main/java/com/p4square/f1oauth/SecondPartyAuthenticator.java @@ -0,0 +1,52 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.f1oauth; + +import org.apache.log4j.Logger; + +import com.p4square.restlet.oauth.OAuthException; +import com.p4square.restlet.oauth.OAuthUser; + +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.security.Authenticator; + +/** + * Restlet Authenticator for 2nd + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SecondPartyAuthenticator extends Authenticator { + private static final Logger LOG = Logger.getLogger(SecondPartyAuthenticator.class); + + private final F1Access mHelper; + + public SecondPartyAuthenticator(Context context, boolean optional, F1Access helper) { + super(context, optional); + + mHelper = helper; + } + + protected boolean authenticate(Request request, Response response) { + if (request.getChallengeResponse() == null) { + return false; // no credentials + } + + String username = request.getChallengeResponse().getIdentifier(); + String password = new String(request.getChallengeResponse().getSecret()); + + try { + OAuthUser user = mHelper.getAccessToken(username, password); + request.getClientInfo().setUser(user); + + return true; + + } catch (OAuthException e) { + LOG.info("OAuth Exception: " + e); + } + + return false; // Invalid credentials + } +} diff --git a/src/main/java/com/p4square/f1oauth/SecondPartyVerifier.java b/src/main/java/com/p4square/f1oauth/SecondPartyVerifier.java new file mode 100644 index 0000000..882c7e7 --- /dev/null +++ b/src/main/java/com/p4square/f1oauth/SecondPartyVerifier.java @@ -0,0 +1,72 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.f1oauth; + +import java.io.IOException; +import java.util.Map; + +import org.apache.log4j.Logger; + +import com.p4square.restlet.oauth.OAuthException; +import com.p4square.restlet.oauth.OAuthUser; + +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.data.Method; +import org.restlet.data.Status; +import org.restlet.ext.jackson.JacksonRepresentation; +import org.restlet.security.Verifier; + +/** + * Restlet Verifier for F1 2nd Party Authentication + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SecondPartyVerifier implements Verifier { + private static final Logger LOG = Logger.getLogger(SecondPartyVerifier.class); + + private final Restlet mDispatcher; + private final F1Access mHelper; + + public SecondPartyVerifier(Context context, F1Access helper) { + if (helper == null) { + throw new IllegalArgumentException("Helper can not be null."); + } + + mDispatcher = context.getClientDispatcher(); + mHelper = helper; + } + + @Override + public int verify(Request request, Response response) { + if (request.getChallengeResponse() == null) { + return RESULT_MISSING; // no credentials + } + + String username = request.getChallengeResponse().getIdentifier(); + String password = new String(request.getChallengeResponse().getSecret()); + + try { + OAuthUser ouser = mHelper.getAccessToken(username, password); + + // Once we have a user, fetch the people record to get the user id. + F1User user = mHelper.getAuthenticatedApi(ouser).getF1User(ouser); + user.setEmail(username); + + // This seems like a hack... but it'll work + request.getClientInfo().setUser(user); + + return RESULT_VALID; + + } catch (Exception e) { + LOG.info("OAuth Exception: " + e, e); + } + + return RESULT_INVALID; // Invalid credentials + } + +} diff --git a/src/main/java/com/p4square/fmfacade/FMFacade.java b/src/main/java/com/p4square/fmfacade/FMFacade.java new file mode 100644 index 0000000..0e552b0 --- /dev/null +++ b/src/main/java/com/p4square/fmfacade/FMFacade.java @@ -0,0 +1,107 @@ +/* + * Copyright 2013 Jesse Morgan <jesse@jesterpm.net> + */ + +package com.p4square.fmfacade; + +import java.io.IOException; + +import org.restlet.Application; +import org.restlet.Component; +import org.restlet.data.Protocol; +import org.restlet.Restlet; +import org.restlet.routing.Router; + +import freemarker.template.Configuration; +import freemarker.template.DefaultObjectWrapper; +import freemarker.template.Template; + +import org.apache.log4j.Logger; + +import com.p4square.grow.config.Config; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class FMFacade extends Application { + private static final Logger cLog = Logger.getLogger(FMFacade.class); + private final Configuration mFMConfig; + + public FMFacade() { + mFMConfig = new Configuration(); + mFMConfig.setClassForTemplateLoading(getClass(), "/templates"); + mFMConfig.setObjectWrapper(new DefaultObjectWrapper()); + } + + /** + * @return a Config object. + */ + public Config getConfig() { + return null; + } + + @Override + public synchronized Restlet createInboundRoot() { + return createRouter(); + } + + /** + * Retrieve a template. + * + * @param name The template name. + * @return A FreeMarker template or null on error. + */ + public Template getTemplate(String name) { + try { + return mFMConfig.getTemplate(name); + + } catch (IOException e) { + cLog.error("Could not load template \"" + name + "\"", e); + return null; + } + } + + /** + * Create the router to be used by this application. This can be overriden + * by sub-classes to add additional routes. + * + * @return The router. + */ + protected Router createRouter() { + Router router = new Router(getContext()); + router.attachDefault(FreeMarkerPageResource.class); + + return router; + } + + /** + * Stand-alone main for testing. + */ + public static void main(String[] args) { + // Start the HTTP Server + final Component component = new Component(); + component.getServers().add(Protocol.HTTP, 8085); + component.getClients().add(Protocol.HTTP); + component.getDefaultHost().attach(new FMFacade()); + + // Setup shutdown hook + Runtime.getRuntime().addShutdownHook(new Thread() { + public void run() { + try { + component.stop(); + } catch (Exception e) { + cLog.error("Exception during cleanup", e); + } + } + }); + + cLog.info("Starting server..."); + + try { + component.start(); + } catch (Exception e) { + cLog.fatal("Could not start: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/p4square/fmfacade/FreeMarkerPageResource.java b/src/main/java/com/p4square/fmfacade/FreeMarkerPageResource.java new file mode 100644 index 0000000..8c8948a --- /dev/null +++ b/src/main/java/com/p4square/fmfacade/FreeMarkerPageResource.java @@ -0,0 +1,98 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.fmfacade; + +import java.util.Map; +import java.util.HashMap; + +import freemarker.template.Template; + +import org.restlet.Context; +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.ext.freemarker.TemplateRepresentation; +import org.restlet.representation.Representation; +import org.restlet.resource.ServerResource; +import org.restlet.security.User; + +import org.apache.log4j.Logger; + +import com.p4square.fmfacade.ftl.GetMethod; + +import com.p4square.session.Session; +import com.p4square.session.Sessions; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class FreeMarkerPageResource extends ServerResource { + private static Logger cLog = Logger.getLogger(FreeMarkerPageResource.class); + + public static Map<String, Object> baseRootObject(final Context context, final FMFacade fmf) { + Map<String, Object> root = new HashMap<String, Object>(); + + root.put("get", new GetMethod(context.getClientDispatcher())); + root.put("config", fmf.getConfig()); + + return root; + } + + private FMFacade mFMF; + private String mCurrentPage; + + @Override + public void doInit() { + mFMF = (FMFacade) getApplication(); + mCurrentPage = getReference().getRemainingPart(false, false); + } + + protected Representation get() { + try { + Template t = mFMF.getTemplate("pages" + mCurrentPage + ".ftl"); + + if (t == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + return new TemplateRepresentation(t, getRootObject(), + MediaType.TEXT_HTML); + + } catch (Exception e) { + cLog.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } + + /** + * Build and return the root object to pass to the FTL Template. + * @return A map of objects and methods for the template to access. + */ + protected Map<String, Object> getRootObject() { + Map<String, Object> root = baseRootObject(getContext(), mFMF); + + root.put("attributes", getRequestAttributes()); + root.put("query", getQuery().getValuesMap()); + + if (getClientInfo().isAuthenticated()) { + final User user = getClientInfo().getUser(); + final Map<String, String> userMap = new HashMap<String, String>(); + userMap.put("id", user.getIdentifier()); + userMap.put("firstName", user.getFirstName()); + userMap.put("lastName", user.getLastName()); + userMap.put("email", user.getEmail()); + root.put("user", userMap); + } + + Session s = Sessions.getInstance().get(getRequest()); + if (s != null) { + root.put("session", s.getMap()); + } + + return root; + } +} diff --git a/src/main/java/com/p4square/fmfacade/ftl/GetMethod.java b/src/main/java/com/p4square/fmfacade/ftl/GetMethod.java new file mode 100644 index 0000000..a47c4b0 --- /dev/null +++ b/src/main/java/com/p4square/fmfacade/ftl/GetMethod.java @@ -0,0 +1,94 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.fmfacade.ftl; + +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +import java.io.IOException; + +import freemarker.core.Environment; +import freemarker.template.SimpleScalar; +import freemarker.template.TemplateMethodModel; +import freemarker.template.TemplateModel; +import freemarker.template.TemplateModelException; + +import org.apache.log4j.Logger; + +import org.restlet.data.Status; +import org.restlet.data.Method; +import org.restlet.representation.Representation; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; + +import org.restlet.ext.jackson.JacksonRepresentation; + +/** + * This method allows templates to make GET requests. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class GetMethod implements TemplateMethodModel { + private static final Logger cLog = Logger.getLogger(GetMethod.class); + + private final Restlet mDispatcher; + + public GetMethod(Restlet dispatcher) { + mDispatcher = dispatcher; + } + + /** + * @param args List with exactly two arguments: + * * The variable in which to put the result. + * * The URI to GET. + */ + public TemplateModel exec(List args) throws TemplateModelException { + final Environment env = Environment.getCurrentEnvironment(); + + if (args.size() != 2) { + throw new TemplateModelException( + "Expecting exactly one argument containing the URI"); + } + + Request request = new Request(Method.GET, (String) args.get(1)); + Response response = mDispatcher.handle(request); + Status status = response.getStatus(); + Representation representation = response.getEntity(); + + try { + if (response.getStatus().isSuccess()) { + JacksonRepresentation<Map> mapRepresentation; + if (representation instanceof JacksonRepresentation) { + mapRepresentation = (JacksonRepresentation<Map>) representation; + } else { + mapRepresentation = new JacksonRepresentation<Map>( + representation, Map.class); + } + try { + TemplateModel mapModel = env.getObjectWrapper().wrap(mapRepresentation.getObject()); + + env.setVariable((String) args.get(0), mapModel); + + } catch (IOException e) { + cLog.warn("Exception occurred when calling getObject(): " + + e.getMessage(), e); + status = Status.SERVER_ERROR_INTERNAL; + } + } + + Map statusMap = new HashMap(); + statusMap.put("code", status.getCode()); + statusMap.put("reason", status.getReasonPhrase()); + statusMap.put("succeeded", status.isSuccess()); + return env.getObjectWrapper().wrap(statusMap); + } finally { + if (representation != null) { + representation.release(); + } + } + } +} diff --git a/src/main/java/com/p4square/fmfacade/json/ClientException.java b/src/main/java/com/p4square/fmfacade/json/ClientException.java new file mode 100644 index 0000000..c233193 --- /dev/null +++ b/src/main/java/com/p4square/fmfacade/json/ClientException.java @@ -0,0 +1,20 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.fmfacade.json; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class ClientException extends Exception { + + public ClientException(final String msg) { + super(msg); + } + + public ClientException(final String msg, final Exception cause) { + super(msg, cause); + } +} diff --git a/src/main/java/com/p4square/fmfacade/json/JsonRequestClient.java b/src/main/java/com/p4square/fmfacade/json/JsonRequestClient.java new file mode 100644 index 0000000..19a394f --- /dev/null +++ b/src/main/java/com/p4square/fmfacade/json/JsonRequestClient.java @@ -0,0 +1,109 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.fmfacade.json; + +import java.util.Map; + +import java.io.IOException; + +import org.apache.log4j.Logger; + +import org.restlet.data.Status; +import org.restlet.data.Method; +import org.restlet.representation.Representation; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; + +import org.restlet.ext.jackson.JacksonRepresentation; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class JsonRequestClient { + private final Restlet mDispatcher; + + public JsonRequestClient(Restlet dispatcher) { + mDispatcher = dispatcher; + } + + /** + * Perform a GET request for the given URI and parse the response as a + * JSON map. + * + * @return A JsonResponse object which can be used to retrieve the + * response as a JSON map. + */ + public JsonResponse get(final String uri) { + final Request request = new Request(Method.GET, uri); + final Response response = mDispatcher.handle(request); + + return new JsonResponse(response); + } + + /** + * Perform a PUT request for the given URI and parse the response as a + * JSON map. + * + * @return A JsonResponse object which can be used to retrieve the + * response as a JSON map. + */ + public JsonResponse put(final String uri, Representation entity) { + final Request request = new Request(Method.PUT, uri); + request.setEntity(entity); + + final Response response = mDispatcher.handle(request); + return new JsonResponse(response); + } + + /** + * Perform a PUT request for the given URI and parse the response as a + * JSON map. + * + * @return A JsonResponse object which can be used to retrieve the + * response as a JSON map. + */ + public JsonResponse put(final String uri, Map map) { + return put(uri, new JacksonRepresentation<Map>(map)); + } + + /** + * Perform a POST request for the given URI and parse the response as a + * JSON map. + * + * @return A JsonResponse object which can be used to retrieve the + * response as a JSON map. + */ + public JsonResponse post(final String uri, Representation entity) { + final Request request = new Request(Method.POST, uri); + request.setEntity(entity); + + final Response response = mDispatcher.handle(request); + return new JsonResponse(response); + } + + /** + * Perform a POST request for the given URI and parse the response as a + * JSON map. + * + * @return A JsonResponse object which can be used to retrieve the + * response as a JSON map. + */ + public JsonResponse post(final String uri, Map map) { + return post(uri, new JacksonRepresentation<Map>(map)); + } + + /** + * Perform a DELETE request for the given URI. + * + * @return A JsonResponse object with the status of the request. + */ + public JsonResponse delete(final String uri) { + final Request request = new Request(Method.DELETE, uri); + final Response response = mDispatcher.handle(request); + return new JsonResponse(response); + } +} diff --git a/src/main/java/com/p4square/fmfacade/json/JsonResponse.java b/src/main/java/com/p4square/fmfacade/json/JsonResponse.java new file mode 100644 index 0000000..b9cb587 --- /dev/null +++ b/src/main/java/com/p4square/fmfacade/json/JsonResponse.java @@ -0,0 +1,87 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.fmfacade.json; + +import java.util.Map; + +import java.io.IOException; + +import org.restlet.data.Status; +import org.restlet.data.Reference; +import org.restlet.representation.Representation; +import org.restlet.Response; + +import org.restlet.ext.jackson.JacksonRepresentation; + +/** + * JsonResponse wraps a Restlet Response object and parses the entity, if any, + * as a JSON map. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class JsonResponse { + private final Response mResponse; + private final Representation mRepresentation; + + private Map<String, Object> mMap; + + JsonResponse(Response response) { + mResponse = response; + mRepresentation = response.getEntity(); + mMap = null; + + if (!response.getStatus().isSuccess()) { + if (mRepresentation != null) { + mRepresentation.release(); + } + } + } + + /** + * @return the Status info from the response. + */ + public Status getStatus() { + return mResponse.getStatus(); + } + + /** + * @return the Reference for a redirect. + */ + public Reference getRedirectLocation() { + return mResponse.getLocationRef(); + } + + /** + * Return the parsed json map from the response. + */ + public Map<String, Object> getMap() throws ClientException { + if (mMap == null) { + Representation representation = mRepresentation; + + // Parse response + if (representation == null) { + return null; + } + + JacksonRepresentation<Map> mapRepresentation; + if (representation instanceof JacksonRepresentation) { + mapRepresentation = (JacksonRepresentation<Map>) representation; + } else { + mapRepresentation = new JacksonRepresentation<Map>( + representation, Map.class); + } + + try { + mMap = (Map<String, Object>) mapRepresentation.getObject(); + + } catch (IOException e) { + throw new ClientException("Failed to parse response: " + e.getMessage(), e); + } + } + + return mMap; + } + +} diff --git a/src/main/java/com/p4square/grow/GrowProcessComponent.java b/src/main/java/com/p4square/grow/GrowProcessComponent.java new file mode 100644 index 0000000..608ec0d --- /dev/null +++ b/src/main/java/com/p4square/grow/GrowProcessComponent.java @@ -0,0 +1,166 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import com.codahale.metrics.ConsoleReporter; +import com.codahale.metrics.MetricRegistry; + +import org.apache.log4j.Logger; + +import org.restlet.Application; +import org.restlet.Component; +import org.restlet.Restlet; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.Protocol; +import org.restlet.resource.Directory; +import org.restlet.security.ChallengeAuthenticator; + +import com.p4square.grow.backend.BackendVerifier; +import com.p4square.grow.backend.GrowBackend; +import com.p4square.grow.config.Config; +import com.p4square.grow.frontend.GrowFrontend; +import com.p4square.restlet.metrics.MetricsApplication; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class GrowProcessComponent extends Component { + private static Logger LOG = Logger.getLogger(GrowProcessComponent.class); + + private static final String BACKEND_REALM = "Grow Backend"; + + private final Config mConfig; + private final MetricRegistry mMetricRegistry; + + /** + * Create a new Grow Process website component combining a frontend and backend. + */ + public GrowProcessComponent() throws Exception { + this(new Config()); + } + + public GrowProcessComponent(Config config) throws Exception { + // Clients + getClients().add(Protocol.FILE); + getClients().add(Protocol.HTTP); + getClients().add(Protocol.HTTPS); + + // Prepare mConfig + mConfig = config; + mConfig.updateConfig(this.getClass().getResourceAsStream("/grow.properties")); + + // Prepare Metrics + mMetricRegistry = new MetricRegistry(); + + // Frontend + GrowFrontend frontend = new GrowFrontend(mConfig, mMetricRegistry); + getDefaultHost().attach(frontend); + + // Backend + GrowBackend backend = new GrowBackend(mConfig, mMetricRegistry); + getInternalRouter().attach("/backend", backend); + + // Authenticated access to the backend + BackendVerifier verifier = new BackendVerifier(backend.getUserRecordProvider()); + ChallengeAuthenticator auth = new ChallengeAuthenticator(getContext().createChildContext(), + false, ChallengeScheme.HTTP_BASIC, BACKEND_REALM, verifier); + auth.setNext(backend); + getDefaultHost().attach("/backend", auth); + + // Authenticated access to metrics + ChallengeAuthenticator metricAuth = new ChallengeAuthenticator( + getContext().createChildContext(), false, + ChallengeScheme.HTTP_BASIC, BACKEND_REALM, verifier); + metricAuth.setNext(new MetricsApplication(mMetricRegistry)); + getDefaultHost().attach("/metrics", metricAuth); + } + + + @Override + public void start() throws Exception { + String configDomain = getContext().getParameters().getFirstValue("com.p4square.grow.configDomain"); + if (configDomain != null) { + mConfig.setDomain(configDomain); + } + + String configFilename = getContext().getParameters().getFirstValue("com.p4square.grow.configFile"); + if (configFilename != null) { + mConfig.updateConfig(configFilename); + } + + super.start(); + } + + /** + * Stand-alone main for testing. + */ + public static void main(String[] args) throws Exception { + // Load an optional config file from the first argument. + Config config = new Config(); + config.setDomain("dev"); + if (args.length >= 1) { + config.updateConfig(args[0]); + } + + // Override domain + if (args.length == 2) { + config.setDomain(args[1]); + } + + // Start the HTTP Server + final GrowProcessComponent component = new GrowProcessComponent(config); + component.getServers().add(Protocol.HTTP, 8085); + + // Static content + try { + component.getDefaultHost().attach("/images/", new FileServingApp("./src/main/webapp/images/")); + component.getDefaultHost().attach("/scripts", new FileServingApp("./src/main/webapp/scripts")); + component.getDefaultHost().attach("/style.css", new FileServingApp("./src/main/webapp/style.css")); + component.getDefaultHost().attach("/favicon.ico", new FileServingApp("./src/main/webapp/favicon.ico")); + component.getDefaultHost().attach("/notfound.html", new FileServingApp("./src/main/webapp/notfound.html")); + component.getDefaultHost().attach("/error.html", new FileServingApp("./src/main/webapp/error.html")); + } catch (IOException e) { + LOG.error("Could not create directory for static resources: " + + e.getMessage(), e); + } + + // Setup shutdown hook + Runtime.getRuntime().addShutdownHook(new Thread() { + public void run() { + try { + component.stop(); + } catch (Exception e) { + LOG.error("Exception during cleanup", e); + } + } + }); + + LOG.info("Starting server..."); + + try { + component.start(); + } catch (Exception e) { + LOG.fatal("Could not start: " + e.getMessage(), e); + } + } + + private static class FileServingApp extends Application { + private final String mPath; + + public FileServingApp(String path) throws IOException { + mPath = new File(path).getAbsolutePath(); + } + + @Override + public Restlet createInboundRoot() { + return new Directory(getContext(), "file://" + mPath); + } + } +} diff --git a/src/main/java/com/p4square/grow/backend/BackendVerifier.java b/src/main/java/com/p4square/grow/backend/BackendVerifier.java new file mode 100644 index 0000000..83160a9 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/BackendVerifier.java @@ -0,0 +1,92 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.backend; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import org.apache.commons.codec.binary.Hex; + +import org.restlet.security.SecretVerifier; + +import com.p4square.grow.model.UserRecord; +import com.p4square.grow.provider.Provider; + +/** + * Verify the given credentials against the users with backend access. + */ +public class BackendVerifier extends SecretVerifier { + + private final Provider<String, UserRecord> mUserProvider; + + public BackendVerifier(Provider<String, UserRecord> userProvider) { + mUserProvider = userProvider; + } + + @Override + public int verify(String identifier, char[] secret) { + if (identifier == null) { + throw new IllegalArgumentException("Null identifier"); + } + + if (secret == null) { + throw new IllegalArgumentException("Null secret"); + } + + // Does the user exist? + UserRecord user; + try { + user = mUserProvider.get(identifier); + if (user == null) { + return RESULT_UNKNOWN; + } + + } catch (IOException e) { + return RESULT_UNKNOWN; + } + + // Does the user have a backend password? + String storedHash = user.getBackendPasswordHash(); + if (storedHash == null) { + // This user doesn't have access + return RESULT_INVALID; + } + + // Validate the password. + try { + String hashedInput = hashPassword(secret); + if (hashedInput.equals(storedHash)) { + return RESULT_VALID; + } + + } catch (NoSuchAlgorithmException e) { + return RESULT_UNSUPPORTED; + } + + // If all else fails, fail. + return RESULT_INVALID; + } + + /** + * Hash the given secret. + */ + public static String hashPassword(char[] secret) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + + // Convert the char[] to byte[] + // FIXME This approach is incorrectly truncating multibyte + // characters. + byte[] b = new byte[secret.length]; + for (int i = 0; i < secret.length; i++) { + b[i] = (byte) secret[i]; + } + + md.update(b); + + byte[] hash = md.digest(); + return new String(Hex.encodeHex(hash)); + } +} diff --git a/src/main/java/com/p4square/grow/backend/CassandraGrowData.java b/src/main/java/com/p4square/grow/backend/CassandraGrowData.java new file mode 100644 index 0000000..22a7716 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/CassandraGrowData.java @@ -0,0 +1,172 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.backend; + +import java.io.IOException; + +import com.p4square.grow.config.Config; + +import com.p4square.grow.backend.db.CassandraDatabase; +import com.p4square.grow.backend.db.CassandraKey; +import com.p4square.grow.backend.db.CassandraProviderImpl; +import com.p4square.grow.backend.db.CassandraCollectionProvider; +import com.p4square.grow.backend.db.CassandraTrainingRecordProvider; + +import com.p4square.grow.model.Message; +import com.p4square.grow.model.MessageThread; +import com.p4square.grow.model.Playlist; +import com.p4square.grow.model.Question; +import com.p4square.grow.model.TrainingRecord; +import com.p4square.grow.model.UserRecord; + +import com.p4square.grow.provider.CollectionProvider; +import com.p4square.grow.provider.DelegateCollectionProvider; +import com.p4square.grow.provider.DelegateProvider; +import com.p4square.grow.provider.Provider; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +class CassandraGrowData implements GrowData { + private static final String DEFAULT_COLUMN = "value"; + + private final Config mConfig; + private final CassandraDatabase mDatabase; + + private final Provider<String, UserRecord> mUserRecordProvider; + + private final Provider<String, Question> mQuestionProvider; + private final CassandraTrainingRecordProvider mTrainingRecordProvider; + private final CollectionProvider<String, String, String> mVideoProvider; + + private final CollectionProvider<String, String, MessageThread> mFeedThreadProvider; + private final CollectionProvider<String, String, Message> mFeedMessageProvider; + + private final Provider<String, String> mStringProvider; + + private final CollectionProvider<String, String, String> mAnswerProvider; + + public CassandraGrowData(final Config config) { + mConfig = config; + mDatabase = new CassandraDatabase(); + + mUserRecordProvider = new DelegateProvider<String, CassandraKey, UserRecord>( + new CassandraProviderImpl<UserRecord>(mDatabase, UserRecord.class)) { + @Override + public CassandraKey makeKey(String userid) { + return new CassandraKey("accounts", userid, DEFAULT_COLUMN); + } + }; + + mQuestionProvider = new DelegateProvider<String, CassandraKey, Question>( + new CassandraProviderImpl<Question>(mDatabase, Question.class)) { + @Override + public CassandraKey makeKey(String questionId) { + return new CassandraKey("strings", "/questions/" + questionId, DEFAULT_COLUMN); + } + }; + + mFeedThreadProvider = new CassandraCollectionProvider<MessageThread>(mDatabase, + "feedthreads", MessageThread.class); + mFeedMessageProvider = new CassandraCollectionProvider<Message>(mDatabase, + "feedmessages", Message.class); + + mTrainingRecordProvider = new CassandraTrainingRecordProvider(mDatabase); + + mVideoProvider = new DelegateCollectionProvider<String, String, String, String, String>( + new CassandraCollectionProvider<String>(mDatabase, "strings", String.class)) { + @Override + public String makeCollectionKey(String key) { + return "/training/" + key; + } + + @Override + public String makeKey(String key) { + return key; + } + + @Override + public String unmakeKey(String key) { + return key; + } + }; + + mStringProvider = new DelegateProvider<String, CassandraKey, String>( + new CassandraProviderImpl<String>(mDatabase, String.class)) { + @Override + public CassandraKey makeKey(String id) { + return new CassandraKey("strings", id, DEFAULT_COLUMN); + } + }; + + mAnswerProvider = new CassandraCollectionProvider<String>( + mDatabase, "assessments", String.class); + } + + @Override + public void start() throws Exception { + mDatabase.setClusterName(mConfig.getString("clusterName", "Dev Cluster")); + mDatabase.setKeyspaceName(mConfig.getString("keyspace", "GROW")); + mDatabase.init(); + } + + @Override + public void stop() throws Exception { + mDatabase.close(); + } + + /** + * @return the current database. + */ + public CassandraDatabase getDatabase() { + return mDatabase; + } + + @Override + public Provider<String, UserRecord> getUserRecordProvider() { + return mUserRecordProvider; + } + + @Override + public Provider<String, Question> getQuestionProvider() { + return mQuestionProvider; + } + + @Override + public Provider<String, TrainingRecord> getTrainingRecordProvider() { + return mTrainingRecordProvider; + } + + @Override + public CollectionProvider<String, String, String> getVideoProvider() { + return mVideoProvider; + } + + @Override + public Playlist getDefaultPlaylist() throws IOException { + return mTrainingRecordProvider.getDefaultPlaylist(); + } + + @Override + public CollectionProvider<String, String, MessageThread> getThreadProvider() { + return mFeedThreadProvider; + } + + @Override + public CollectionProvider<String, String, Message> getMessageProvider() { + return mFeedMessageProvider; + } + + @Override + public Provider<String, String> getStringProvider() { + return mStringProvider; + } + + @Override + public CollectionProvider<String, String, String> getAnswerProvider() { + return mAnswerProvider; + } +} diff --git a/src/main/java/com/p4square/grow/backend/DynamoGrowData.java b/src/main/java/com/p4square/grow/backend/DynamoGrowData.java new file mode 100644 index 0000000..3b38eac --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/DynamoGrowData.java @@ -0,0 +1,180 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.backend; + +import java.io.IOException; + +import com.amazonaws.auth.AWSCredentials; + +import com.p4square.grow.backend.dynamo.DynamoDatabase; +import com.p4square.grow.backend.dynamo.DynamoKey; +import com.p4square.grow.backend.dynamo.DynamoProviderImpl; +import com.p4square.grow.backend.dynamo.DynamoCollectionProviderImpl; + +import com.p4square.grow.config.Config; + +import com.p4square.grow.model.Message; +import com.p4square.grow.model.MessageThread; +import com.p4square.grow.model.Playlist; +import com.p4square.grow.model.Question; +import com.p4square.grow.model.TrainingRecord; +import com.p4square.grow.model.UserRecord; + +import com.p4square.grow.provider.CollectionProvider; +import com.p4square.grow.provider.DelegateCollectionProvider; +import com.p4square.grow.provider.DelegateProvider; +import com.p4square.grow.provider.Provider; +import com.p4square.grow.provider.JsonEncodedProvider; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +class DynamoGrowData implements GrowData { + private static final String DEFAULT_COLUMN = "value"; + private static final String DEFAULT_PLAYLIST_KEY = "/training/defaultplaylist"; + + private final Config mConfig; + private final DynamoDatabase mDatabase; + + private final Provider<String, UserRecord> mUserRecordProvider; + + private final Provider<String, Question> mQuestionProvider; + private final Provider<String, TrainingRecord> mTrainingRecordProvider; + private final CollectionProvider<String, String, String> mVideoProvider; + + private final CollectionProvider<String, String, MessageThread> mFeedThreadProvider; + private final CollectionProvider<String, String, Message> mFeedMessageProvider; + + private final Provider<String, String> mStringProvider; + + private final CollectionProvider<String, String, String> mAnswerProvider; + + public DynamoGrowData(final Config config) { + mConfig = config; + + mDatabase = new DynamoDatabase(config); + + mUserRecordProvider = new DelegateProvider<String, DynamoKey, UserRecord>( + new DynamoProviderImpl<UserRecord>(mDatabase, UserRecord.class)) { + @Override + public DynamoKey makeKey(String userid) { + return DynamoKey.newAttributeKey("accounts", userid, DEFAULT_COLUMN); + } + }; + + mQuestionProvider = new DelegateProvider<String, DynamoKey, Question>( + new DynamoProviderImpl<Question>(mDatabase, Question.class)) { + @Override + public DynamoKey makeKey(String questionId) { + return DynamoKey.newAttributeKey("strings", + "/questions/" + questionId, + DEFAULT_COLUMN); + } + }; + + mFeedThreadProvider = new DynamoCollectionProviderImpl<MessageThread>( + mDatabase, "feedthreads", MessageThread.class); + mFeedMessageProvider = new DynamoCollectionProviderImpl<Message>( + mDatabase, "feedmessages", Message.class); + + mTrainingRecordProvider = new DelegateProvider<String, DynamoKey, TrainingRecord>( + new DynamoProviderImpl<TrainingRecord>(mDatabase, TrainingRecord.class)) { + @Override + public DynamoKey makeKey(String userId) { + return DynamoKey.newAttributeKey("training", + userId, + DEFAULT_COLUMN); + } + }; + + mVideoProvider = new DelegateCollectionProvider<String, String, String, String, String>( + new DynamoCollectionProviderImpl<String>(mDatabase, "strings", String.class)) { + @Override + public String makeCollectionKey(String key) { + return "/training/" + key; + } + + @Override + public String makeKey(String key) { + return key; + } + + @Override + public String unmakeKey(String key) { + return key; + } + }; + + mStringProvider = new DelegateProvider<String, DynamoKey, String>( + new DynamoProviderImpl<String>(mDatabase, String.class)) { + @Override + public DynamoKey makeKey(String id) { + return DynamoKey.newAttributeKey("strings", id, DEFAULT_COLUMN); + } + }; + + mAnswerProvider = new DynamoCollectionProviderImpl<String>( + mDatabase, "assessments", String.class); + } + + @Override + public void start() throws Exception { + } + + @Override + public void stop() throws Exception { + } + + @Override + public Provider<String, UserRecord> getUserRecordProvider() { + return mUserRecordProvider; + } + + @Override + public Provider<String, Question> getQuestionProvider() { + return mQuestionProvider; + } + + @Override + public Provider<String, TrainingRecord> getTrainingRecordProvider() { + return mTrainingRecordProvider; + } + + @Override + public CollectionProvider<String, String, String> getVideoProvider() { + return mVideoProvider; + } + + @Override + public Playlist getDefaultPlaylist() throws IOException { + String blob = mStringProvider.get(DEFAULT_PLAYLIST_KEY); + if (blob == null) { + return null; + } + + return JsonEncodedProvider.MAPPER.readValue(blob, Playlist.class); + } + + @Override + public CollectionProvider<String, String, MessageThread> getThreadProvider() { + return mFeedThreadProvider; + } + + @Override + public CollectionProvider<String, String, Message> getMessageProvider() { + return mFeedMessageProvider; + } + + @Override + public Provider<String, String> getStringProvider() { + return mStringProvider; + } + + @Override + public CollectionProvider<String, String, String> getAnswerProvider() { + return mAnswerProvider; + } +} diff --git a/src/main/java/com/p4square/grow/backend/GrowBackend.java b/src/main/java/com/p4square/grow/backend/GrowBackend.java new file mode 100644 index 0000000..4091138 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/GrowBackend.java @@ -0,0 +1,211 @@ +/* + * Copyright 2012 Jesse Morgan + */ + +package com.p4square.grow.backend; + +import java.io.IOException; + +import com.codahale.metrics.MetricRegistry; + +import org.apache.log4j.Logger; + +import org.restlet.Application; +import org.restlet.Component; +import org.restlet.Restlet; +import org.restlet.data.Protocol; +import org.restlet.data.Reference; +import org.restlet.resource.Directory; +import org.restlet.routing.Router; + +import com.p4square.grow.config.Config; + +import com.p4square.grow.model.Message; +import com.p4square.grow.model.MessageThread; +import com.p4square.grow.model.Playlist; +import com.p4square.grow.model.Question; +import com.p4square.grow.model.TrainingRecord; +import com.p4square.grow.model.UserRecord; + +import com.p4square.grow.provider.CollectionProvider; +import com.p4square.grow.provider.Provider; +import com.p4square.grow.provider.ProvidesQuestions; +import com.p4square.grow.provider.ProvidesTrainingRecords; +import com.p4square.grow.provider.ProvidesUserRecords; + +import com.p4square.grow.backend.resources.AccountResource; +import com.p4square.grow.backend.resources.BannerResource; +import com.p4square.grow.backend.resources.SurveyResource; +import com.p4square.grow.backend.resources.SurveyResultsResource; +import com.p4square.grow.backend.resources.TrainingRecordResource; +import com.p4square.grow.backend.resources.TrainingResource; + +import com.p4square.grow.backend.feed.FeedDataProvider; +import com.p4square.grow.backend.feed.ThreadResource; +import com.p4square.grow.backend.feed.TopicResource; + +import com.p4square.restlet.metrics.MetricRouter; + +/** + * Main class for the backend application. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class GrowBackend extends Application implements GrowData { + + private final static Logger LOG = Logger.getLogger(GrowBackend.class); + + private final MetricRegistry mMetricRegistry; + + private final Config mConfig; + private final GrowData mGrowData; + + public GrowBackend() { + this(new Config(), new MetricRegistry()); + } + + public GrowBackend(Config config, MetricRegistry metricRegistry) { + mConfig = config; + + mMetricRegistry = metricRegistry; + + mGrowData = new DynamoGrowData(config); + } + + public MetricRegistry getMetrics() { + return mMetricRegistry; + } + + @Override + public Restlet createInboundRoot() { + Router router = new MetricRouter(getContext(), mMetricRegistry); + + // Account API + router.attach("/accounts/{userId}", AccountResource.class); + + // Survey API + router.attach("/assessment/question/{questionId}", SurveyResource.class); + + router.attach("/accounts/{userId}/assessment", SurveyResultsResource.class); + router.attach("/accounts/{userId}/assessment/answers/{questionId}", + SurveyResultsResource.class); + + // Training API + router.attach("/training/{level}", TrainingResource.class); + router.attach("/training/{level}/videos/{videoId}", TrainingResource.class); + + router.attach("/accounts/{userId}/training", TrainingRecordResource.class); + router.attach("/accounts/{userId}/training/videos/{videoId}", + TrainingRecordResource.class); + + // Misc. + router.attach("/banner", BannerResource.class); + + // Feed + router.attach("/feed/{topic}", TopicResource.class); + router.attach("/feed/{topic}/{thread}", ThreadResource.class); + //router.attach("/feed/{topic/{thread}/{message}", MessageResource.class); + + router.attachDefault(new Directory(getContext(), new Reference(getClass().getResource("apiinfo.html")))); + + return router; + } + + /** + * Open the database. + */ + @Override + public void start() throws Exception { + super.start(); + + mGrowData.start(); + } + + /** + * Close the database. + */ + @Override + public void stop() throws Exception { + LOG.info("Shutting down..."); + mGrowData.stop(); + + super.stop(); + } + + @Override + public Provider<String, UserRecord> getUserRecordProvider() { + return mGrowData.getUserRecordProvider(); + } + + @Override + public Provider<String, Question> getQuestionProvider() { + return mGrowData.getQuestionProvider(); + } + + @Override + public CollectionProvider<String, String, String> getVideoProvider() { + return mGrowData.getVideoProvider(); + } + + @Override + public Provider<String, TrainingRecord> getTrainingRecordProvider() { + return mGrowData.getTrainingRecordProvider(); + } + + /** + * @return the Default Playlist. + */ + public Playlist getDefaultPlaylist() throws IOException { + return mGrowData.getDefaultPlaylist(); + } + + @Override + public CollectionProvider<String, String, MessageThread> getThreadProvider() { + return mGrowData.getThreadProvider(); + } + + @Override + public CollectionProvider<String, String, Message> getMessageProvider() { + return mGrowData.getMessageProvider(); + } + + @Override + public Provider<String, String> getStringProvider() { + return mGrowData.getStringProvider(); + } + + @Override + public CollectionProvider<String, String, String> getAnswerProvider() { + return mGrowData.getAnswerProvider(); + } + + /** + * Stand-alone main for testing. + */ + public static void main(String[] args) throws Exception { + // Start the HTTP Server + final Component component = new Component(); + component.getServers().add(Protocol.HTTP, 9095); + component.getClients().add(Protocol.HTTP); + component.getDefaultHost().attach(new GrowBackend()); + + // Setup shutdown hook + Runtime.getRuntime().addShutdownHook(new Thread() { + public void run() { + try { + component.stop(); + } catch (Exception e) { + LOG.error("Exception during cleanup", e); + } + } + }); + + LOG.info("Starting server..."); + + try { + component.start(); + } catch (Exception e) { + LOG.fatal("Could not start: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/p4square/grow/backend/GrowData.java b/src/main/java/com/p4square/grow/backend/GrowData.java new file mode 100644 index 0000000..293bb88 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/GrowData.java @@ -0,0 +1,36 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.backend; + +import com.p4square.grow.backend.feed.FeedDataProvider; +import com.p4square.grow.model.Playlist; +import com.p4square.grow.provider.ProvidesAssessments; +import com.p4square.grow.provider.ProvidesQuestions; +import com.p4square.grow.provider.ProvidesStrings; +import com.p4square.grow.provider.ProvidesTrainingRecords; +import com.p4square.grow.provider.ProvidesUserRecords; +import com.p4square.grow.provider.ProvidesVideos; + +/** + * Aggregate of the data provider interfaces. + * + * Used by GrowBackend to swap out implementations of the providers. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +interface GrowData extends ProvidesQuestions, ProvidesTrainingRecords, ProvidesVideos, + FeedDataProvider, ProvidesUserRecords, ProvidesStrings, + ProvidesAssessments { + + /** + * Start the data provider. + */ + void start() throws Exception; + + /** + * Stop the data provider. + */ + void stop() throws Exception; +} diff --git a/src/main/java/com/p4square/grow/backend/db/CassandraCollectionProvider.java b/src/main/java/com/p4square/grow/backend/db/CassandraCollectionProvider.java new file mode 100644 index 0000000..bfcb48d --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/db/CassandraCollectionProvider.java @@ -0,0 +1,109 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.db; + +import java.io.IOException; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.netflix.astyanax.model.Column; +import com.netflix.astyanax.model.ColumnList; + +import com.p4square.grow.provider.CollectionProvider; +import com.p4square.grow.provider.JsonEncodedProvider; + +/** + * CollectionProvider implementation backed by a Cassandra ColumnFamily. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class CassandraCollectionProvider<V> implements CollectionProvider<String, String, V> { + private final CassandraDatabase mDb; + private final String mCF; + private final Class<V> mClazz; + + public CassandraCollectionProvider(CassandraDatabase db, String columnFamily, Class<V> clazz) { + mDb = db; + mCF = columnFamily; + mClazz = clazz; + } + + @Override + public V get(String collection, String key) throws IOException { + String blob = mDb.getKey(mCF, collection, key); + return decode(blob); + } + + @Override + public Map<String, V> query(String collection) throws IOException { + return query(collection, -1); + } + + @Override + public Map<String, V> query(String collection, int limit) throws IOException { + Map<String, V> result = new LinkedHashMap<>(); + + ColumnList<String> row = mDb.getRow(mCF, collection); + if (!row.isEmpty()) { + int count = 0; + for (Column<String> c : row) { + if (limit >= 0 && ++count > limit) { + break; // Limit reached. + } + + String key = c.getName(); + String blob = c.getStringValue(); + V obj = decode(blob); + + result.put(key, obj); + } + } + + return Collections.unmodifiableMap(result); + } + + @Override + public void put(String collection, String key, V obj) throws IOException { + String blob = encode(obj); + mDb.putKey(mCF, collection, key, blob); + } + + /** + * Encode the object as JSON. + * + * @param obj The object to encode. + * @return The JSON encoding of obj. + * @throws IOException if the object cannot be encoded. + */ + protected String encode(V obj) throws IOException { + if (mClazz == String.class) { + return (String) obj; + } else { + return JsonEncodedProvider.MAPPER.writeValueAsString(obj); + } + } + + /** + * Decode the JSON string as an object. + * + * @param blob The JSON data to decode. + * @return The decoded object or null if blob is null. + * @throws IOException If an object cannot be decoded. + */ + protected V decode(String blob) throws IOException { + if (blob == null) { + return null; + } + + if (mClazz == String.class) { + return (V) blob; + } + + V obj = JsonEncodedProvider.MAPPER.readValue(blob, mClazz); + return obj; + } +} diff --git a/src/main/java/com/p4square/grow/backend/db/CassandraDatabase.java b/src/main/java/com/p4square/grow/backend/db/CassandraDatabase.java new file mode 100644 index 0000000..b8cb6df --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/db/CassandraDatabase.java @@ -0,0 +1,212 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.db; + +import com.netflix.astyanax.AstyanaxContext; +import com.netflix.astyanax.connectionpool.exceptions.ConnectionException; +import com.netflix.astyanax.connectionpool.impl.ConnectionPoolConfigurationImpl; +import com.netflix.astyanax.connectionpool.impl.CountingConnectionPoolMonitor; +import com.netflix.astyanax.connectionpool.NodeDiscoveryType; +import com.netflix.astyanax.connectionpool.OperationResult; +import com.netflix.astyanax.impl.AstyanaxConfigurationImpl; +import com.netflix.astyanax.Keyspace; +import com.netflix.astyanax.ColumnMutation; +import com.netflix.astyanax.model.Column; +import com.netflix.astyanax.model.ColumnFamily; +import com.netflix.astyanax.model.ColumnList; +import com.netflix.astyanax.ColumnListMutation; +import com.netflix.astyanax.MutationBatch; +import com.netflix.astyanax.serializers.StringSerializer; +import com.netflix.astyanax.thrift.ThriftFamilyFactory; + +import org.apache.log4j.Logger; + +/** + * Cassandra Database Abstraction for the Backend. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class CassandraDatabase { + private static Logger cLog = Logger.getLogger(CassandraDatabase.class); + + // Configuration fields. + private String mClusterName; + private String mKeyspaceName; + private String mSeedEndpoint = "127.0.0.1:9160"; + private int mPort = 9160; + + private AstyanaxContext<Keyspace> mContext; + private Keyspace mKeyspace; + + /** + * Connect to Cassandra. + * + * Cluster and Keyspace must be set before calling init(). + */ + public void init() { + mContext = new AstyanaxContext.Builder() + .forCluster(mClusterName) + .forKeyspace(mKeyspaceName) + .withAstyanaxConfiguration(new AstyanaxConfigurationImpl() + .setDiscoveryType(NodeDiscoveryType.RING_DESCRIBE) + ) + .withConnectionPoolConfiguration(new ConnectionPoolConfigurationImpl("MyConnectionPool") + .setPort(mPort) + .setMaxConnsPerHost(1) + .setSeeds(mSeedEndpoint) + ) + .withConnectionPoolMonitor(new CountingConnectionPoolMonitor()) + .buildKeyspace(ThriftFamilyFactory.getInstance()); + + mContext.start(); + mKeyspace = mContext.getClient(); + } + + /** + * Close the database connection. + */ + public void close() { + mContext.shutdown(); + } + + /** + * Set the cluster name to connect to. + */ + public void setClusterName(final String cluster) { + mClusterName = cluster; + } + + /** + * Set the name of the keyspace to open. + */ + public void setKeyspaceName(final String keyspace) { + mKeyspaceName = keyspace; + } + + /** + * Change the seed endpoint. + * The default is 127.0.0.1:9160. + */ + public void setSeedEndpoint(final String endpoint) { + mSeedEndpoint = endpoint; + } + + /** + * Change the port to connect to. + * The default is 9160. + */ + public void setPort(final int port) { + mPort = port; + } + + /** + * @return The entire row associated with this key. + */ + public ColumnList<String> getRow(final String cfName, final String key) { + try { + ColumnFamily<String, String> cf = new ColumnFamily(cfName, + StringSerializer.get(), + StringSerializer.get()); + + OperationResult<ColumnList<String>> result = + mKeyspace.prepareQuery(cf) + .getKey(key) + .execute(); + + return result.getResult(); + + } catch (ConnectionException e) { + cLog.error("getRow failed due to Connection Exception", e); + throw new RuntimeException(e); + } + } + + /** + * @return The value associated with the given key. + */ + public String getKey(final String cfName, final String key) { + return getKey(cfName, key, "value"); + } + + /** + * @return The value associated with the given key, column pair. + */ + public String getKey(final String cfName, final String key, final String column) { + final ColumnList<String> row = getRow(cfName, key); + + if (row != null) { + final Column rowColumn = row.getColumnByName(column); + if (rowColumn != null) { + return rowColumn.getStringValue(); + } + } + + return null; + } + + /** + * Assign value to key. + */ + public void putKey(final String cfName, final String key, final String value) { + putKey(cfName, key, "value", value); + } + + /** + * Assign value to the key, column pair. + */ + public void putKey(final String cfName, final String key, + final String column, final String value) { + + ColumnFamily<String, String> cf = new ColumnFamily(cfName, + StringSerializer.get(), + StringSerializer.get()); + + MutationBatch m = mKeyspace.prepareMutationBatch(); + m.withRow(cf, key).putColumn(column, value); + + try { + m.execute(); + } catch (ConnectionException e) { + cLog.error("putKey failed due to Connection Exception", e); + throw new RuntimeException(e); + } + } + + /** + * Remove a key, column pair. + */ + public void deleteKey(final String cfName, final String key, final String column) { + ColumnFamily<String, String> cf = new ColumnFamily(cfName, + StringSerializer.get(), + StringSerializer.get()); + + try { + ColumnMutation m = mKeyspace.prepareColumnMutation(cf, key, column); + m.deleteColumn().execute(); + } catch (ConnectionException e) { + cLog.error("deleteKey failed due to Connection Exception", e); + throw new RuntimeException(e); + } + } + + /** + * Remove a row + */ + public void deleteRow(final String cfName, final String key) { + ColumnFamily<String, String> cf = new ColumnFamily(cfName, + StringSerializer.get(), + StringSerializer.get()); + + try { + MutationBatch batch = mKeyspace.prepareMutationBatch(); + ColumnListMutation<String> cfm = batch.withRow(cf, key).delete(); + batch.execute(); + + } catch (ConnectionException e) { + cLog.error("deleteRow failed due to Connection Exception", e); + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/p4square/grow/backend/db/CassandraKey.java b/src/main/java/com/p4square/grow/backend/db/CassandraKey.java new file mode 100644 index 0000000..853fe96 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/db/CassandraKey.java @@ -0,0 +1,34 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.db; + +/** + * CassandraKey represents a Cassandra key / column pair. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class CassandraKey { + private final String mColumnFamily; + private final String mId; + private final String mColumn; + + public CassandraKey(String columnFamily, String id, String column) { + mColumnFamily = columnFamily; + mId = id; + mColumn = column; + } + + public String getColumnFamily() { + return mColumnFamily; + } + + public String getId() { + return mId; + } + + public String getColumn() { + return mColumn; + } +} diff --git a/src/main/java/com/p4square/grow/backend/db/CassandraProviderImpl.java b/src/main/java/com/p4square/grow/backend/db/CassandraProviderImpl.java new file mode 100644 index 0000000..da5a9f2 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/db/CassandraProviderImpl.java @@ -0,0 +1,37 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.db; + +import java.io.IOException; + +import com.p4square.grow.provider.Provider; +import com.p4square.grow.provider.JsonEncodedProvider; + +/** + * Provider implementation backed by a Cassandra ColumnFamily. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class CassandraProviderImpl<V> extends JsonEncodedProvider<V> implements Provider<CassandraKey, V> { + private final CassandraDatabase mDb; + + public CassandraProviderImpl(CassandraDatabase db, Class<V> clazz) { + super(clazz); + + mDb = db; + } + + @Override + public V get(CassandraKey key) throws IOException { + String blob = mDb.getKey(key.getColumnFamily(), key.getId(), key.getColumn()); + return decode(blob); + } + + @Override + public void put(CassandraKey key, V obj) throws IOException { + String blob = encode(obj); + mDb.putKey(key.getColumnFamily(), key.getId(), key.getColumn(), blob); + } +} diff --git a/src/main/java/com/p4square/grow/backend/db/CassandraTrainingRecordProvider.java b/src/main/java/com/p4square/grow/backend/db/CassandraTrainingRecordProvider.java new file mode 100644 index 0000000..4face52 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/db/CassandraTrainingRecordProvider.java @@ -0,0 +1,71 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.db; + +import java.io.IOException; + +import com.p4square.grow.model.Playlist; +import com.p4square.grow.model.TrainingRecord; + +import com.p4square.grow.provider.JsonEncodedProvider; +import com.p4square.grow.provider.Provider; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class CassandraTrainingRecordProvider implements Provider<String, TrainingRecord> { + private static final CassandraKey DEFAULT_PLAYLIST_KEY = new CassandraKey("strings", "defaultPlaylist", "value"); + + private static final String COLUMN_FAMILY = "training"; + private static final String PLAYLIST_KEY = "playlist"; + private static final String LAST_VIDEO_KEY = "lastVideo"; + + private final CassandraDatabase mDb; + private final Provider<CassandraKey, Playlist> mPlaylistProvider; + + public CassandraTrainingRecordProvider(CassandraDatabase db) { + mDb = db; + mPlaylistProvider = new CassandraProviderImpl<>(db, Playlist.class); + } + + @Override + public TrainingRecord get(String userid) throws IOException { + Playlist playlist = mPlaylistProvider.get(new CassandraKey(COLUMN_FAMILY, userid, PLAYLIST_KEY)); + + if (playlist == null) { + // We consider no playlist to mean no record whatsoever. + return null; + } + + TrainingRecord r = new TrainingRecord(); + r.setPlaylist(playlist); + r.setLastVideo(mDb.getKey(COLUMN_FAMILY, userid, LAST_VIDEO_KEY)); + + return r; + } + + @Override + public void put(String userid, TrainingRecord record) throws IOException { + String lastVideo = record.getLastVideo(); + Playlist playlist = record.getPlaylist(); + + mDb.putKey(COLUMN_FAMILY, userid, LAST_VIDEO_KEY, lastVideo); + mPlaylistProvider.put(new CassandraKey(COLUMN_FAMILY, userid, PLAYLIST_KEY), playlist); + } + + /** + * @return the default playlist stored in the database. + */ + public Playlist getDefaultPlaylist() throws IOException { + Playlist playlist = mPlaylistProvider.get(DEFAULT_PLAYLIST_KEY); + + if (playlist == null) { + playlist = new Playlist(); + } + + return playlist; + } +} diff --git a/src/main/java/com/p4square/grow/backend/dynamo/DbTool.java b/src/main/java/com/p4square/grow/backend/dynamo/DbTool.java new file mode 100644 index 0000000..374fa83 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/dynamo/DbTool.java @@ -0,0 +1,481 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.backend.dynamo; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.io.File; +import java.io.FilenameFilter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import com.p4square.grow.backend.dynamo.DynamoDatabase; +import com.p4square.grow.backend.dynamo.DynamoKey; +import com.p4square.grow.config.Config; +import com.p4square.grow.model.UserRecord; +import com.p4square.grow.provider.Provider; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class DbTool { + private static final FilenameFilter JSON_FILTER = new JsonFilter(); + + private static Config mConfig; + private static DynamoDatabase mDatabase; + + public static void usage() { + System.out.println("java com.p4square.grow.backend.dynamo.DbTool <command>...\n"); + System.out.println("Commands:"); + System.out.println("\t--domain <domain> Set config domain"); + System.out.println("\t--dev Set config domain to dev"); + System.out.println("\t--config <file> Merge in config file"); + System.out.println("\t--list List all tables"); + System.out.println("\t--create <table> <reads> <writes> Create a table"); + System.out.println("\t--update <table> <reads> <writes> Update table throughput"); + System.out.println("\t--drop <table> Delete a table"); + System.out.println("\t--get <table> <key> <attribute> Get a value"); + System.out.println("\t--put <table> <key> <attribute> <value> Put a value"); + System.out.println("\t--delete <table> <key> <attribute> Delete a value"); + System.out.println("\t--scan <table> List all rows"); + System.out.println("\t--scanf <table> <attribute> List all rows"); + System.out.println(); + System.out.println("Bootstrap Commands:"); + System.out.println("\t--bootstrap <data> Create all tables and import all data"); + System.out.println("\t--loadStrings <data> Load all videos and questions"); + System.out.println("\t--destroy Drop all tables"); + System.out.println("\t--addadmin <user> <pass> Add a backend account"); + System.out.println("\t--import <table> <file> Backfill a table"); + } + + public static void main(String... args) { + if (args.length == 0) { + usage(); + System.exit(1); + } + + mConfig = new Config(); + + try { + mConfig.updateConfig(DbTool.class.getResourceAsStream("/grow.properties")); + + int offset = 0; + while (offset < args.length) { + if ("--domain".equals(args[offset])) { + mConfig.setDomain(args[offset + 1]); + mDatabase = null; + offset += 2; + + } else if ("--dev".equals(args[offset])) { + mConfig.setDomain("dev"); + mDatabase = null; + offset += 1; + + } else if ("--config".equals(args[offset])) { + mConfig.updateConfig(args[offset + 1]); + mDatabase = null; + offset += 2; + + } else if ("--list".equals(args[offset])) { + //offset = list(args, ++offset); + + } else if ("--create".equals(args[offset])) { + offset = create(args, ++offset); + + } else if ("--update".equals(args[offset])) { + offset = update(args, ++offset); + + } else if ("--drop".equals(args[offset])) { + offset = drop(args, ++offset); + + } else if ("--get".equals(args[offset])) { + offset = get(args, ++offset); + + } else if ("--put".equals(args[offset])) { + offset = put(args, ++offset); + + } else if ("--delete".equals(args[offset])) { + offset = delete(args, ++offset); + + } else if ("--scan".equals(args[offset])) { + offset = scan(args, ++offset); + + } else if ("--scanf".equals(args[offset])) { + offset = scanf(args, ++offset); + + /* Bootstrap Commands */ + } else if ("--bootstrap".equals(args[offset])) { + if ("dev".equals(mConfig.getDomain())) { + offset = bootstrapDevTables(args, ++offset); + } else { + offset = bootstrapTables(args, ++offset); + } + offset = loadStrings(args, offset); + + } else if ("--loadStrings".equals(args[offset])) { + offset = loadStrings(args, ++offset); + + } else if ("--destroy".equals(args[offset])) { + offset = destroy(args, ++offset); + + } else if ("--addadmin".equals(args[offset])) { + offset = addAdmin(args, ++offset); + + } else if ("--import".equals(args[offset])) { + offset = importTable(args, ++offset); + + } else { + throw new IllegalArgumentException("Unknown command " + args[offset]); + } + } + } catch (Exception e) { + e.printStackTrace(); + System.exit(2); + } + } + + private static DynamoDatabase getDatabase() { + if (mDatabase == null) { + mDatabase = new DynamoDatabase(mConfig); + } + + return mDatabase; + } + + private static int create(String[] args, int offset) { + String name = args[offset++]; + long reads = Long.parseLong(args[offset++]); + long writes = Long.parseLong(args[offset++]); + + DynamoDatabase db = getDatabase(); + + db.createTable(name, reads, writes); + + return offset; + } + + private static int update(String[] args, int offset) { + String name = args[offset++]; + long reads = Long.parseLong(args[offset++]); + long writes = Long.parseLong(args[offset++]); + + DynamoDatabase db = getDatabase(); + + db.updateTable(name, reads, writes); + + return offset; + } + + private static int drop(String[] args, int offset) { + String name = args[offset++]; + + DynamoDatabase db = getDatabase(); + + db.deleteTable(name); + + return offset; + } + + private static int get(String[] args, int offset) { + String table = args[offset++]; + String key = args[offset++]; + String attribute = args[offset++]; + + DynamoDatabase db = getDatabase(); + + String value = db.getAttribute(DynamoKey.newAttributeKey(table, key, attribute)); + + if (value == null) { + value = "<null>"; + } + + System.out.printf("%s %s:%s\n%s\n\n", table, key, attribute, value); + + return offset; + } + + private static int put(String[] args, int offset) { + String table = args[offset++]; + String key = args[offset++]; + String attribute = args[offset++]; + String value = args[offset++]; + + DynamoDatabase db = getDatabase(); + + db.putAttribute(DynamoKey.newAttributeKey(table, key, attribute), value); + + return offset; + } + + private static int delete(String[] args, int offset) { + String table = args[offset++]; + String key = args[offset++]; + String attribute = args[offset++]; + + DynamoDatabase db = getDatabase(); + + db.deleteAttribute(DynamoKey.newAttributeKey(table, key, attribute)); + + System.out.printf("Deleted %s %s:%s\n\n", table, key, attribute); + + return offset; + } + + private static int scan(String[] args, int offset) { + String table = args[offset++]; + + DynamoKey key = DynamoKey.newKey(table, null); + + doScan(key); + + return offset; + } + + private static int scanf(String[] args, int offset) { + String table = args[offset++]; + String attribute = args[offset++]; + + DynamoKey key = DynamoKey.newAttributeKey(table, null, attribute); + + doScan(key); + + return offset; + } + + private static void doScan(DynamoKey key) { + DynamoDatabase db = getDatabase(); + + String attributeFilter = key.getAttribute(); + + while (key != null) { + Map<DynamoKey, Map<String, String>> result = db.getAll(key); + + key = null; // If there are no results, exit + + for (Map.Entry<DynamoKey, Map<String, String>> entry : result.entrySet()) { + key = entry.getKey(); // Save the last key + + for (Map.Entry<String, String> attribute : entry.getValue().entrySet()) { + if (attributeFilter == null || attributeFilter.equals(attribute.getKey())) { + String keyString = key.getHashKey(); + if (key.getRangeKey() != null) { + keyString += "(" + key.getRangeKey() + ")"; + } + System.out.printf("%s %s:%s\n%s\n\n", + key.getTable(), keyString, attribute.getKey(), + attribute.getValue()); + } + } + } + } + } + + + private static int bootstrapTables(String[] args, int offset) { + DynamoDatabase db = getDatabase(); + + db.createTable("strings", 5, 1); + db.createTable("accounts", 5, 1); + db.createTable("assessments", 5, 5); + db.createTable("training", 5, 5); + db.createTable("feedthreads", 5, 1); + db.createTable("feedmessages", 5, 1); + + return offset; + } + + private static int bootstrapDevTables(String[] args, int offset) { + DynamoDatabase db = getDatabase(); + + db.createTable("strings", 1, 1); + db.createTable("accounts", 1, 1); + db.createTable("assessments", 1, 1); + db.createTable("training", 1, 1); + db.createTable("feedthreads", 1, 1); + db.createTable("feedmessages", 1, 1); + + return offset; + } + + private static int loadStrings(String[] args, int offset) throws IOException { + String data = args[offset++]; + File baseDir = new File(data); + + DynamoDatabase db = getDatabase(); + + insertQuestions(baseDir); + insertVideos(baseDir); + insertDefaultPlaylist(baseDir); + + return offset; + } + + private static int destroy(String[] args, int offset) { + DynamoDatabase db = getDatabase(); + + final String[] tables = { "strings", + "accounts", + "assessments", + "training", + "feedthreads", + "feedmessages" + }; + + for (String table : tables) { + try { + db.deleteTable(table); + } catch (Exception e) { + System.err.println("Deleting " + table + ": " + e.getMessage()); + } + } + + return offset; + } + + private static int addAdmin(String[] args, int offset) throws IOException { + String user = args[offset++]; + String pass = args[offset++]; + + DynamoDatabase db = getDatabase(); + + UserRecord record = new UserRecord(); + record.setId(user); + record.setBackendPassword(pass); + + Provider<DynamoKey, UserRecord> provider = new DynamoProviderImpl(db, UserRecord.class); + provider.put(DynamoKey.newAttributeKey("accounts", user, "value"), record); + + return offset; + } + + private static int importTable(String[] args, int offset) throws IOException { + String table = args[offset++]; + String filename = args[offset++]; + + DynamoDatabase db = getDatabase(); + + List<String> lines = Files.readAllLines(new File(filename).toPath(), + StandardCharsets.UTF_8); + + int count = 0; + + String key = null; + Map<String, String> attributes = new HashMap<>(); + for (String line : lines) { + if (line.length() == 0) { + if (attributes.size() > 0) { + db.putKey(DynamoKey.newKey(table, key), attributes); + count++; + + if (count % 50 == 0) { + System.out.printf("Imported %d records into %s...\n", count, table); + } + } + key = null; + attributes = new HashMap<>(); + continue; + } + + if (key == null) { + key = line; + continue; + } + + int space = line.indexOf(' '); + String attribute = line.substring(0, space); + String value = line.substring(space + 1); + + attributes.put(attribute, value); + } + + // Finish up the remaining attributes. + if (key != null && attributes.size() > 0) { + db.putKey(DynamoKey.newKey(table, key), attributes); + count++; + } + + System.out.printf("Imported %d records into %s.\n", count, table); + + return offset; + } + + private static void insertQuestions(File baseDir) throws IOException { + DynamoDatabase db = getDatabase(); + File questions = new File(baseDir, "questions"); + + File[] files = questions.listFiles(JSON_FILTER); + Arrays.sort(files); + + for (File file : files) { + String filename = file.getName(); + String questionId = filename.substring(0, filename.lastIndexOf('.')); + + byte[] encoded = Files.readAllBytes(file.toPath()); + String value = new String(encoded, StandardCharsets.UTF_8); + db.putAttribute(DynamoKey.newAttributeKey("strings", + "/questions/" + questionId, "value"), value); + System.out.println("Inserted /questions/" + questionId); + } + + String filename = files[0].getName(); + String first = filename.substring(0, filename.lastIndexOf('.')); + int count = files.length; + String summary = "{\"first\": \"" + first + "\", \"count\": " + count + "}"; + db.putAttribute(DynamoKey.newAttributeKey("strings", "/questions", "value"), summary); + System.out.println("Inserted /questions"); + } + + private static void insertVideos(File baseDir) throws IOException { + DynamoDatabase db = getDatabase(); + File videos = new File(baseDir, "videos"); + + for (File topic : videos.listFiles()) { + if (!topic.isDirectory()) { + continue; + } + + String topicName = topic.getName(); + + Map<String, String> attributes = new HashMap<>(); + File[] files = topic.listFiles(JSON_FILTER); + for (File file : files) { + String filename = file.getName(); + String videoId = filename.substring(0, filename.lastIndexOf('.')); + + byte[] encoded = Files.readAllBytes(file.toPath()); + String value = new String(encoded, StandardCharsets.UTF_8); + + attributes.put(videoId, value); + System.out.println("Found /training/" + topicName + ":" + videoId); + } + + db.putKey(DynamoKey.newKey("strings", + "/training/" + topicName), attributes); + System.out.println("Inserted /training/" + topicName); + } + } + + private static void insertDefaultPlaylist(File baseDir) throws IOException { + DynamoDatabase db = getDatabase(); + File file = new File(baseDir, "videos/playlist.json"); + + byte[] encoded = Files.readAllBytes(file.toPath()); + String value = new String(encoded, StandardCharsets.UTF_8); + db.putAttribute(DynamoKey.newAttributeKey("strings", + "/training/defaultplaylist", "value"), value); + System.out.println("Inserted /training/defaultplaylist"); + } + + private static class JsonFilter implements FilenameFilter { + @Override + public boolean accept(File dir, String name) { + return name.endsWith(".json"); + } + } +} diff --git a/src/main/java/com/p4square/grow/backend/dynamo/DynamoCollectionProviderImpl.java b/src/main/java/com/p4square/grow/backend/dynamo/DynamoCollectionProviderImpl.java new file mode 100644 index 0000000..b53e9f7 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/dynamo/DynamoCollectionProviderImpl.java @@ -0,0 +1,109 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.backend.dynamo; + +import java.io.IOException; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.p4square.grow.provider.CollectionProvider; +import com.p4square.grow.provider.JsonEncodedProvider; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class DynamoCollectionProviderImpl<V> implements CollectionProvider<String, String, V> { + private final DynamoDatabase mDb; + private final String mTable; + private final Class<V> mClazz; + + public DynamoCollectionProviderImpl(DynamoDatabase db, String table, Class<V> clazz) { + mDb = db; + mTable = table; + mClazz = clazz; + } + + @Override + public V get(String collection, String key) throws IOException { + String blob = mDb.getAttribute(DynamoKey.newAttributeKey(mTable, collection, key)); + return decode(blob); + } + + @Override + public Map<String, V> query(String collection) throws IOException { + return query(collection, -1); + } + + @Override + public Map<String, V> query(String collection, int limit) throws IOException { + Map<String, V> result = new LinkedHashMap<>(); + + Map<String, String> row = mDb.getKey(DynamoKey.newKey(mTable, collection)); + if (row.size() > 0) { + int count = 0; + for (Map.Entry<String, String> c : row.entrySet()) { + if (limit >= 0 && ++count > limit) { + break; // Limit reached. + } + + String key = c.getKey(); + String blob = c.getValue(); + V obj = decode(blob); + + result.put(key, obj); + } + } + + return Collections.unmodifiableMap(result); + } + + @Override + public void put(String collection, String key, V obj) throws IOException { + if (obj == null) { + mDb.deleteAttribute(DynamoKey.newAttributeKey(mTable, collection, key)); + } else { + String blob = encode(obj); + mDb.putAttribute(DynamoKey.newAttributeKey(mTable, collection, key), blob); + } + } + + /** + * Encode the object as JSON. + * + * @param obj The object to encode. + * @return The JSON encoding of obj. + * @throws IOException if the object cannot be encoded. + */ + protected String encode(V obj) throws IOException { + if (mClazz == String.class) { + return (String) obj; + } else { + return JsonEncodedProvider.MAPPER.writeValueAsString(obj); + } + } + + /** + * Decode the JSON string as an object. + * + * @param blob The JSON data to decode. + * @return The decoded object or null if blob is null. + * @throws IOException If an object cannot be decoded. + */ + protected V decode(String blob) throws IOException { + if (blob == null) { + return null; + } + + if (mClazz == String.class) { + return (V) blob; + } + + V obj = JsonEncodedProvider.MAPPER.readValue(blob, mClazz); + return obj; + } +} diff --git a/src/main/java/com/p4square/grow/backend/dynamo/DynamoDatabase.java b/src/main/java/com/p4square/grow/backend/dynamo/DynamoDatabase.java new file mode 100644 index 0000000..68a165d --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/dynamo/DynamoDatabase.java @@ -0,0 +1,307 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.backend.dynamo; + +import java.util.Arrays; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.DefaultAWSCredentialsProviderChain; +import com.amazonaws.regions.Region; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient; +import com.amazonaws.services.dynamodbv2.model.AttributeAction; +import com.amazonaws.services.dynamodbv2.model.AttributeDefinition; +import com.amazonaws.services.dynamodbv2.model.AttributeValue; +import com.amazonaws.services.dynamodbv2.model.AttributeValueUpdate; +import com.amazonaws.services.dynamodbv2.model.CreateTableRequest; +import com.amazonaws.services.dynamodbv2.model.CreateTableResult; +import com.amazonaws.services.dynamodbv2.model.DeleteItemRequest; +import com.amazonaws.services.dynamodbv2.model.DeleteItemResult; +import com.amazonaws.services.dynamodbv2.model.DeleteTableRequest; +import com.amazonaws.services.dynamodbv2.model.DeleteTableResult; +import com.amazonaws.services.dynamodbv2.model.GetItemRequest; +import com.amazonaws.services.dynamodbv2.model.GetItemResult; +import com.amazonaws.services.dynamodbv2.model.KeySchemaElement; +import com.amazonaws.services.dynamodbv2.model.KeyType; +import com.amazonaws.services.dynamodbv2.model.ProvisionedThroughput; +import com.amazonaws.services.dynamodbv2.model.PutItemRequest; +import com.amazonaws.services.dynamodbv2.model.PutItemResult; +import com.amazonaws.services.dynamodbv2.model.ScanRequest; +import com.amazonaws.services.dynamodbv2.model.ScanResult; +import com.amazonaws.services.dynamodbv2.model.UpdateItemRequest; +import com.amazonaws.services.dynamodbv2.model.UpdateItemResult; +import com.amazonaws.services.dynamodbv2.model.UpdateTableRequest; +import com.amazonaws.services.dynamodbv2.model.UpdateTableResult; + +import com.p4square.grow.config.Config; + +/** + * A wrapper around the Dynamo API. + */ +public class DynamoDatabase { + private final AmazonDynamoDBClient mClient; + private final String mTablePrefix; + + public DynamoDatabase(final Config config) { + AWSCredentials creds; + + String awsAccessKey = config.getString("awsAccessKey"); + if (awsAccessKey != null) { + creds = new AWSCredentials() { + @Override + public String getAWSAccessKeyId() { + return config.getString("awsAccessKey"); + } + @Override + public String getAWSSecretKey() { + return config.getString("awsSecretKey"); + } + }; + } else { + creds = new DefaultAWSCredentialsProviderChain().getCredentials(); + } + + mClient = new AmazonDynamoDBClient(creds); + + String endpoint = config.getString("dynamoEndpoint"); + if (endpoint != null) { + mClient.setEndpoint(endpoint); + } + + String region = config.getString("awsRegion"); + if (region != null) { + mClient.setRegion(Region.getRegion(Regions.fromName(region))); + } + + mTablePrefix = config.getString("dynamoTablePrefix", ""); + } + + public void createTable(String name, long reads, long writes) { + ArrayList<AttributeDefinition> attributeDefinitions = new ArrayList<>(); + attributeDefinitions.add(new AttributeDefinition() + .withAttributeName("id") + .withAttributeType("S")); + + ArrayList<KeySchemaElement> ks = new ArrayList<>(); + ks.add(new KeySchemaElement().withAttributeName("id").withKeyType(KeyType.HASH)); + + ProvisionedThroughput provisionedThroughput = new ProvisionedThroughput() + .withReadCapacityUnits(reads) + .withWriteCapacityUnits(writes); + + CreateTableRequest request = new CreateTableRequest() + .withTableName(mTablePrefix + name) + .withAttributeDefinitions(attributeDefinitions) + .withKeySchema(ks) + .withProvisionedThroughput(provisionedThroughput); + + CreateTableResult result = mClient.createTable(request); + } + + public void updateTable(String name, long reads, long writes) { + ProvisionedThroughput provisionedThroughput = new ProvisionedThroughput() + .withReadCapacityUnits(reads) + .withWriteCapacityUnits(writes); + + UpdateTableRequest request = new UpdateTableRequest() + .withTableName(mTablePrefix + name) + .withProvisionedThroughput(provisionedThroughput); + + UpdateTableResult result = mClient.updateTable(request); + } + + public void deleteTable(String name) { + DeleteTableRequest deleteTableRequest = new DeleteTableRequest() + .withTableName(mTablePrefix + name); + + DeleteTableResult result = mClient.deleteTable(deleteTableRequest); + } + + /** + * Get all rows from a table. + * + * The key parameter must specify a table. If hash/range key is specified, + * the scan will begin after that key. + * + * @param key Previous key to start with. + * @return An ordered map of all results. + */ + public Map<DynamoKey, Map<String, String>> getAll(final DynamoKey key) { + ScanRequest scanRequest = new ScanRequest().withTableName(mTablePrefix + key.getTable()); + + if (key.getHashKey() != null) { + scanRequest.setExclusiveStartKey(generateKey(key)); + } + + ScanResult scanResult = mClient.scan(scanRequest); + + Map<DynamoKey, Map<String, String>> result = new LinkedHashMap<>(); + for (Map<String, AttributeValue> map : scanResult.getItems()) { + String id = null; + String range = null; + Map<String, String> row = new LinkedHashMap<>(); + for (Map.Entry<String, AttributeValue> entry : map.entrySet()) { + if ("id".equals(entry.getKey())) { + id = entry.getValue().getS(); + } else if ("range".equals(entry.getKey())) { + range = entry.getValue().getS(); + } else { + row.put(entry.getKey(), entry.getValue().getS()); + } + } + result.put(DynamoKey.newRangeKey(key.getTable(), id, range), row); + } + + return result; + } + + public Map<String, String> getKey(final DynamoKey key) { + GetItemRequest getItemRequest = new GetItemRequest() + .withTableName(mTablePrefix + key.getTable()) + .withKey(generateKey(key)); + + GetItemResult getItemResult = mClient.getItem(getItemRequest); + Map<String, AttributeValue> map = getItemResult.getItem(); + + Map<String, String> result = new LinkedHashMap<>(); + if (map != null) { + for (Map.Entry<String, AttributeValue> entry : map.entrySet()) { + if (!"id".equals(entry.getKey())) { + result.put(entry.getKey(), entry.getValue().getS()); + } + } + } + + return result; + } + + public String getAttribute(final DynamoKey key) { + checkAttributeKey(key); + + GetItemRequest getItemRequest = new GetItemRequest() + .withTableName(mTablePrefix + key.getTable()) + .withKey(generateKey(key)) + .withAttributesToGet(key.getAttribute()); + + GetItemResult result = mClient.getItem(getItemRequest); + Map<String, AttributeValue> map = result.getItem(); + + if (map == null) { + return null; + } + + AttributeValue value = map.get(key.getAttribute()); + if (value != null) { + return value.getS(); + + } else { + return null; + } + } + + /** + * Set all attributes for the given key. + * + * @param key The key. + * @param values Map of attributes to values. + */ + public void putKey(final DynamoKey key, final Map<String, String> values) { + Map<String, AttributeValue> item = new HashMap<>(); + for (Map.Entry<String, String> entry : values.entrySet()) { + item.put(entry.getKey(), new AttributeValue().withS(entry.getValue())); + } + + // Set the Key + item.putAll(generateKey(key)); + + PutItemRequest putItemRequest = new PutItemRequest() + .withTableName(mTablePrefix + key.getTable()) + .withItem(item); + + PutItemResult result = mClient.putItem(putItemRequest); + } + + /** + * Set the particular attributes of the given key. + * + * @param key The key. + * @param value The new value. + */ + public void putAttribute(final DynamoKey key, final String value) { + checkAttributeKey(key); + + Map<String, AttributeValueUpdate> updateItem = new HashMap<>(); + updateItem.put(key.getAttribute(), + new AttributeValueUpdate() + .withAction(AttributeAction.PUT) + .withValue(new AttributeValue().withS(value))); + + UpdateItemRequest updateItemRequest = new UpdateItemRequest() + .withTableName(mTablePrefix + key.getTable()) + .withKey(generateKey(key)) + .withAttributeUpdates(updateItem); + // TODO: Check conditions. + + UpdateItemResult result = mClient.updateItem(updateItemRequest); + } + + /** + * Delete the given key. + * + * @param key The key. + */ + public void deleteKey(final DynamoKey key) { + DeleteItemRequest deleteItemRequest = new DeleteItemRequest() + .withTableName(mTablePrefix + key.getTable()) + .withKey(generateKey(key)); + + DeleteItemResult result = mClient.deleteItem(deleteItemRequest); + } + + /** + * Delete an attribute from the given key. + * + * @param key The key. + */ + public void deleteAttribute(final DynamoKey key) { + checkAttributeKey(key); + + Map<String, AttributeValueUpdate> updateItem = new HashMap<>(); + updateItem.put(key.getAttribute(), + new AttributeValueUpdate().withAction(AttributeAction.DELETE)); + + UpdateItemRequest updateItemRequest = new UpdateItemRequest() + .withTableName(mTablePrefix + key.getTable()) + .withKey(generateKey(key)) + .withAttributeUpdates(updateItem); + + UpdateItemResult result = mClient.updateItem(updateItemRequest); + } + + /** + * Generate a DynamoDB Key Map from the DynamoKey. + */ + private Map<String, AttributeValue> generateKey(final DynamoKey key) { + HashMap<String, AttributeValue> keyMap = new HashMap<>(); + keyMap.put("id", new AttributeValue().withS(key.getHashKey())); + + String range = key.getRangeKey(); + if (range != null) { + keyMap.put("range", new AttributeValue().withS(range)); + } + + return keyMap; + } + + private void checkAttributeKey(DynamoKey key) { + if (null == key.getAttribute()) { + throw new IllegalArgumentException("Attribute must be non-null"); + } + } +} diff --git a/src/main/java/com/p4square/grow/backend/dynamo/DynamoKey.java b/src/main/java/com/p4square/grow/backend/dynamo/DynamoKey.java new file mode 100644 index 0000000..5cdbacd --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/dynamo/DynamoKey.java @@ -0,0 +1,56 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.backend.dynamo; + +/** + * DynamoKey represents a table, hash key, and range key tupl. + */ +public class DynamoKey { + private final String mTable; + private final String mHashKey; + private final String mRangeKey; + private final String mAttribute; + + public static DynamoKey newKey(final String table, final String hashKey) { + return new DynamoKey(table, hashKey, null, null); + } + + public static DynamoKey newRangeKey(final String table, final String hashKey, + final String rangeKey) { + + return new DynamoKey(table, hashKey, rangeKey, null); + } + + public static DynamoKey newAttributeKey(final String table, final String hashKey, + final String attribute) { + + return new DynamoKey(table, hashKey, null, attribute); + } + + public DynamoKey(final String table, final String hashKey, final String rangeKey, + final String attribute) { + + mTable = table; + mHashKey = hashKey; + mRangeKey = rangeKey; + mAttribute = attribute; + } + + public String getTable() { + return mTable; + } + + public String getHashKey() { + return mHashKey; + } + + public String getRangeKey() { + return mRangeKey; + } + + public String getAttribute() { + return mAttribute; + } +} diff --git a/src/main/java/com/p4square/grow/backend/dynamo/DynamoProviderImpl.java b/src/main/java/com/p4square/grow/backend/dynamo/DynamoProviderImpl.java new file mode 100644 index 0000000..93a535f --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/dynamo/DynamoProviderImpl.java @@ -0,0 +1,37 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.dynamo; + +import java.io.IOException; + +import com.p4square.grow.provider.Provider; +import com.p4square.grow.provider.JsonEncodedProvider; + +/** + * Provider implementation backed by a DynamoDB Table. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class DynamoProviderImpl<V> extends JsonEncodedProvider<V> implements Provider<DynamoKey, V> { + private final DynamoDatabase mDb; + + public DynamoProviderImpl(DynamoDatabase db, Class<V> clazz) { + super(clazz); + + mDb = db; + } + + @Override + public V get(DynamoKey key) throws IOException { + String blob = mDb.getAttribute(key); + return decode(blob); + } + + @Override + public void put(DynamoKey key, V obj) throws IOException { + String blob = encode(obj); + mDb.putAttribute(key, blob); + } +} diff --git a/src/main/java/com/p4square/grow/backend/feed/FeedDataProvider.java b/src/main/java/com/p4square/grow/backend/feed/FeedDataProvider.java new file mode 100644 index 0000000..6f090c0 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/feed/FeedDataProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.feed; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; + +import com.p4square.grow.model.MessageThread; +import com.p4square.grow.model.Message; +import com.p4square.grow.provider.CollectionProvider; + +/** + * Implementing this interface indicates you can provide a data source for the Feed. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public interface FeedDataProvider { + public static final Collection<String> TOPICS = Collections.unmodifiableCollection( + Arrays.asList(new String[] { "seeker", "believer", "disciple", "teacher", "leader" })); + + /** + * @return a CollectionProvider of Threads. + */ + CollectionProvider<String, String, MessageThread> getThreadProvider(); + + /** + * @return a CollectionProvider of Messages. + */ + CollectionProvider<String, String, Message> getMessageProvider(); +} diff --git a/src/main/java/com/p4square/grow/backend/feed/ThreadResource.java b/src/main/java/com/p4square/grow/backend/feed/ThreadResource.java new file mode 100644 index 0000000..e8f46c2 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/feed/ThreadResource.java @@ -0,0 +1,106 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.feed; + +import java.io.IOException; + +import java.util.Date; +import java.util.Map; + +import org.restlet.data.Status; +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; + +import org.restlet.ext.jackson.JacksonRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.grow.model.Message; + +/** + * ThreadResource manages the messages that make up a thread. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class ThreadResource extends ServerResource { + private static final Logger LOG = Logger.getLogger(ThreadResource.class); + + private FeedDataProvider mBackend; + private String mTopic; + private String mThreadId; + + @Override + public void doInit() { + super.doInit(); + + mBackend = (FeedDataProvider) getApplication(); + mTopic = getAttribute("topic"); + mThreadId = getAttribute("thread"); + } + + /** + * GET a list of messages in a thread. + */ + @Override + protected Representation get() { + // If the topic or threadId are missing, return a 404. + if (mTopic == null || mTopic.length() == 0 || + mThreadId == null || mThreadId.length() == 0) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + // TODO: Support limit query parameter. + + try { + String collectionKey = mTopic + "/" + mThreadId; + Map<String, Message> messages = mBackend.getMessageProvider().query(collectionKey); + return new JacksonRepresentation(messages.values()); + + } catch (IOException e) { + LOG.error("Unexpected exception: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } + + /** + * POST a new message to the thread. + */ + @Override + protected Representation post(Representation entity) { + // If the topic and thread are not provided, respond with not allowed. + // TODO: Check if the thread exists. + if (mTopic == null || !mBackend.TOPICS.contains(mTopic) || + mThreadId == null || mThreadId.length() == 0) { + setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + return null; + } + + try { + JacksonRepresentation<Message> jsonRep = new JacksonRepresentation<Message>(entity, Message.class); + Message message = jsonRep.getObject(); + + // Force the thread id and message to be what we expect. + message.setThreadId(mThreadId); + message.setId(Message.generateId()); + + if (message.getCreated() == null) { + message.setCreated(new Date()); + } + + String collectionKey = mTopic + "/" + mThreadId; + mBackend.getMessageProvider().put(collectionKey, message.getId(), message); + + setLocationRef(mThreadId + "/" + message.getId()); + return new JacksonRepresentation(message); + + } catch (IOException e) { + LOG.error("Unexpected exception: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } +} diff --git a/src/main/java/com/p4square/grow/backend/feed/TopicResource.java b/src/main/java/com/p4square/grow/backend/feed/TopicResource.java new file mode 100644 index 0000000..24b6a92 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/feed/TopicResource.java @@ -0,0 +1,117 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.feed; + +import java.io.IOException; + +import java.util.Date; +import java.util.Map; + +import org.restlet.data.Status; +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; + +import org.restlet.ext.jackson.JacksonRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.grow.model.Message; +import com.p4square.grow.model.MessageThread; + +/** + * TopicResource manages the threads contained in a topic. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class TopicResource extends ServerResource { + private static final Logger LOG = Logger.getLogger(TopicResource.class); + + private FeedDataProvider mBackend; + private String mTopic; + + @Override + public void doInit() { + super.doInit(); + + mBackend = (FeedDataProvider) getApplication(); + mTopic = getAttribute("topic"); + } + + /** + * GET a list of threads in the topic. + */ + @Override + protected Representation get() { + // If no topic is provided, return a list of topics. + if (mTopic == null || mTopic.length() == 0) { + return new JacksonRepresentation(FeedDataProvider.TOPICS); + } + + // Parse limit query parameter. + int limit = -1; + String limitString = getQueryValue("limit"); + if (limitString != null) { + try { + limit = Integer.parseInt(limitString); + } catch (NumberFormatException e) { + setStatus(Status.CLIENT_ERROR_BAD_REQUEST); + return null; + } + } + + try { + Map<String, MessageThread> threads = mBackend.getThreadProvider().query(mTopic, limit); + return new JacksonRepresentation(threads.values()); + + } catch (IOException e) { + LOG.error("Unexpected exception: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } + + /** + * POST a new thread to the topic. + */ + @Override + protected Representation post(Representation entity) { + // If no topic is provided, respond with not allowed. + if (mTopic == null || !mBackend.TOPICS.contains(mTopic)) { + setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + return null; + } + + try { + // Deserialize the incoming message. + JacksonRepresentation<MessageThread> jsonRep = + new JacksonRepresentation<MessageThread>(entity, MessageThread.class); + + // Get the message from the request. + // Throw away the wrapping MessageThread because we'll create our own later. + Message message = jsonRep.getObject().getMessage(); + if (message.getCreated() == null) { + message.setCreated(new Date()); + } + + // Create the new thread. + MessageThread newThread = MessageThread.createNew(); + + // Force the thread id and message to be what we expect. + message.setId(Message.generateId()); + message.setThreadId(newThread.getId()); + newThread.setMessage(message); + + mBackend.getThreadProvider().put(mTopic, newThread.getId(), newThread); + + setLocationRef(mTopic + "/" + newThread.getId()); + return new JacksonRepresentation(newThread); + + } catch (IOException e) { + LOG.error("Unexpected exception: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } +} diff --git a/src/main/java/com/p4square/grow/backend/resources/AccountResource.java b/src/main/java/com/p4square/grow/backend/resources/AccountResource.java new file mode 100644 index 0000000..2ac7061 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/resources/AccountResource.java @@ -0,0 +1,87 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +import java.io.IOException; + +import org.restlet.data.Status; +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; + +import org.restlet.ext.jackson.JacksonRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.grow.model.UserRecord; +import com.p4square.grow.provider.Provider; +import com.p4square.grow.provider.ProvidesUserRecords; +import com.p4square.grow.provider.JsonEncodedProvider; + +/** + * Stores a document about a user. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class AccountResource extends ServerResource { + private static final Logger LOG = Logger.getLogger(AccountResource.class); + + private Provider<String, UserRecord> mUserRecordProvider; + + private String mUserId; + + @Override + public void doInit() { + super.doInit(); + + final ProvidesUserRecords backend = (ProvidesUserRecords) getApplication(); + mUserRecordProvider = backend.getUserRecordProvider(); + + mUserId = getAttribute("userId"); + } + + /** + * Handle GET Requests. + */ + @Override + protected Representation get() { + try { + UserRecord result = mUserRecordProvider.get(mUserId); + + if (result == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + JacksonRepresentation<UserRecord> rep = new JacksonRepresentation<UserRecord>(result); + rep.setObjectMapper(JsonEncodedProvider.MAPPER); + return rep; + + } catch (IOException e) { + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } + + /** + * Handle PUT requests + */ + @Override + protected Representation put(Representation entity) { + try { + JacksonRepresentation<UserRecord> representation = + new JacksonRepresentation<>(entity, UserRecord.class); + representation.setObjectMapper(JsonEncodedProvider.MAPPER); + UserRecord record = representation.getObject(); + + mUserRecordProvider.put(mUserId, record); + setStatus(Status.SUCCESS_NO_CONTENT); + + } catch (IOException e) { + setStatus(Status.SERVER_ERROR_INTERNAL); + } + + return null; + } +} diff --git a/src/main/java/com/p4square/grow/backend/resources/BannerResource.java b/src/main/java/com/p4square/grow/backend/resources/BannerResource.java new file mode 100644 index 0000000..2b9c8e6 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/resources/BannerResource.java @@ -0,0 +1,85 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +import java.io.IOException; + +import org.restlet.data.Status; +import org.restlet.ext.jackson.JacksonRepresentation; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.resource.ServerResource; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.log4j.Logger; + +import com.p4square.grow.backend.GrowBackend; +import com.p4square.grow.model.Banner; +import com.p4square.grow.provider.JsonEncodedProvider; +import com.p4square.grow.provider.Provider; + +/** + * Fetches or sets the banner string. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class BannerResource extends ServerResource { + private static final Logger LOG = Logger.getLogger(BannerResource.class); + + public static final ObjectMapper MAPPER = JsonEncodedProvider.MAPPER; + + private Provider<String, String> mStringProvider; + + @Override + public void doInit() { + super.doInit(); + + final GrowBackend backend = (GrowBackend) getApplication(); + mStringProvider = backend.getStringProvider(); + } + + /** + * Handle GET Requests. + */ + @Override + protected Representation get() { + String result = null; + try { + result = mStringProvider.get("banner"); + + } catch (IOException e) { + LOG.warn("Exception loading banner: " + e); + } + + if (result == null || result.length() == 0) { + result = "{\"html\":null}"; + } + + return new StringRepresentation(result); + } + + /** + * Handle PUT requests + */ + @Override + protected Representation put(Representation entity) { + try { + JacksonRepresentation<Banner> representation = + new JacksonRepresentation<>(entity, Banner.class); + representation.setObjectMapper(MAPPER); + + Banner banner = representation.getObject(); + + mStringProvider.put("banner", MAPPER.writeValueAsString(banner)); + setStatus(Status.SUCCESS_NO_CONTENT); + + } catch (IOException e) { + setStatus(Status.SERVER_ERROR_INTERNAL); + } + + return null; + } +} diff --git a/src/main/java/com/p4square/grow/backend/resources/SurveyResource.java b/src/main/java/com/p4square/grow/backend/resources/SurveyResource.java new file mode 100644 index 0000000..8723ee2 --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/resources/SurveyResource.java @@ -0,0 +1,115 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +import java.io.IOException; + +import java.util.Map; +import java.util.HashMap; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.ext.jackson.JacksonRepresentation; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.resource.ServerResource; + +import org.apache.log4j.Logger; + +import com.p4square.grow.backend.GrowBackend; +import com.p4square.grow.model.Question; +import com.p4square.grow.provider.JsonEncodedProvider; +import com.p4square.grow.provider.Provider; + +/** + * This resource manages assessment questions. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SurveyResource extends ServerResource { + private static final Logger LOG = Logger.getLogger(SurveyResource.class); + + private static final ObjectMapper MAPPER = JsonEncodedProvider.MAPPER; + + private Provider<String, Question> mQuestionProvider; + private Provider<String, String> mStringProvider; + + private String mQuestionId; + + @Override + public void doInit() { + super.doInit(); + + final GrowBackend backend = (GrowBackend) getApplication(); + mQuestionProvider = backend.getQuestionProvider(); + mStringProvider = backend.getStringProvider(); + + mQuestionId = getAttribute("questionId"); + } + + /** + * Handle GET Requests. + */ + @Override + protected Representation get() { + String result = "{}"; + + if (mQuestionId == null) { + // TODO: List all question ids + + } else if (mQuestionId.equals("first")) { + // Get the first question id from db? + Map<?, ?> questionSummary = getQuestionsSummary(); + mQuestionId = (String) questionSummary.get("first"); + + } else if (mQuestionId.equals("count")) { + // Get the first question id from db? + Map<?, ?> questionSummary = getQuestionsSummary(); + + return new StringRepresentation("{\"count\":" + + String.valueOf((Integer) questionSummary.get("count")) + "}"); + } + + if (mQuestionId != null) { + // Get a question by id + Question question = null; + try { + question = mQuestionProvider.get(mQuestionId); + } catch (IOException e) { + LOG.error("IOException loading question: " + e); + } + + if (question == null) { + // 404 + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + JacksonRepresentation<Question> rep = new JacksonRepresentation<>(question); + rep.setObjectMapper(MAPPER); + return rep; + } + + return new StringRepresentation(result); + } + + private Map<?, ?> getQuestionsSummary() { + try { + // TODO: This could be better. Quick fix for provider support. + String json = mStringProvider.get("/questions"); + + if (json != null) { + return MAPPER.readValue(json, Map.class); + } + + } catch (IOException e) { + LOG.info("Exception reading questions summary.", e); + } + + return null; + } +} diff --git a/src/main/java/com/p4square/grow/backend/resources/SurveyResultsResource.java b/src/main/java/com/p4square/grow/backend/resources/SurveyResultsResource.java new file mode 100644 index 0000000..7c15cfd --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/resources/SurveyResultsResource.java @@ -0,0 +1,253 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +import java.io.IOException; +import java.util.Map; +import java.util.HashMap; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.resource.ServerResource; + +import org.apache.log4j.Logger; + +import com.p4square.grow.backend.GrowBackend; +import com.p4square.grow.model.Answer; +import com.p4square.grow.model.Question; +import com.p4square.grow.model.RecordedAnswer; +import com.p4square.grow.model.Score; +import com.p4square.grow.model.UserRecord; +import com.p4square.grow.provider.CollectionProvider; +import com.p4square.grow.provider.Provider; + + +/** + * Store the user's answers to the assessment and generate their score. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SurveyResultsResource extends ServerResource { + private static final Logger LOG = Logger.getLogger(SurveyResultsResource.class); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + static enum RequestType { + ASSESSMENT, ANSWER + } + + private CollectionProvider<String, String, String> mAnswerProvider; + private Provider<String, Question> mQuestionProvider; + private Provider<String, UserRecord> mUserRecordProvider; + + private RequestType mRequestType; + private String mUserId; + private String mQuestionId; + + @Override + public void doInit() { + super.doInit(); + + final GrowBackend backend = (GrowBackend) getApplication(); + mAnswerProvider = backend.getAnswerProvider(); + mQuestionProvider = backend.getQuestionProvider(); + mUserRecordProvider = backend.getUserRecordProvider(); + + mUserId = getAttribute("userId"); + mQuestionId = getAttribute("questionId"); + + mRequestType = RequestType.ASSESSMENT; + if (mQuestionId != null) { + mRequestType = RequestType.ANSWER; + } + } + + /** + * Handle GET Requests. + */ + @Override + protected Representation get() { + try { + String result = null; + + switch (mRequestType) { + case ANSWER: + result = mAnswerProvider.get(mUserId, mQuestionId); + break; + + case ASSESSMENT: + result = mAnswerProvider.get(mUserId, "summary"); + if (result == null || result.length() == 0) { + result = buildAssessment(); + } + break; + } + + if (result == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + return new StringRepresentation(result); + } catch (IOException e) { + LOG.error("IOException getting answer: ", e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } + + /** + * Handle PUT requests + */ + @Override + protected Representation put(Representation entity) { + boolean success = false; + + switch (mRequestType) { + case ANSWER: + try { + mAnswerProvider.put(mUserId, mQuestionId, entity.getText()); + mAnswerProvider.put(mUserId, "lastAnswered", mQuestionId); + mAnswerProvider.put(mUserId, "summary", null); + success = true; + + } catch (Exception e) { + LOG.warn("Caught exception putting answer: " + e.getMessage(), e); + } + break; + + default: + setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + return null; + } + + if (success) { + setStatus(Status.SUCCESS_NO_CONTENT); + + } else { + setStatus(Status.SERVER_ERROR_INTERNAL); + } + + return null; + } + + /** + * Clear assessment results. + */ + @Override + protected Representation delete() { + boolean success = false; + + switch (mRequestType) { + case ANSWER: + try { + mAnswerProvider.put(mUserId, mQuestionId, null); + mAnswerProvider.put(mUserId, "summary", null); + success = true; + + } catch (Exception e) { + LOG.warn("Caught exception putting answer: " + e.getMessage(), e); + } + break; + + case ASSESSMENT: + try { + mAnswerProvider.put(mUserId, "summary", null); + mAnswerProvider.put(mUserId, "lastAnswered", null); + // TODO Delete answers + + UserRecord record = mUserRecordProvider.get(mUserId); + if (record != null) { + record.setLanding("assessment"); + mUserRecordProvider.put(mUserId, record); + } + + success = true; + + } catch (Exception e) { + LOG.warn("Caught exception putting answer: " + e.getMessage(), e); + } + break; + + default: + setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + return null; + } + + if (success) { + setStatus(Status.SUCCESS_NO_CONTENT); + + } else { + setStatus(Status.SERVER_ERROR_INTERNAL); + } + + return null; + + } + + /** + * This method compiles assessment results. + */ + private String buildAssessment() throws IOException { + StringBuilder sb = new StringBuilder("{ "); + + // Last question answered + final String lastAnswered = mAnswerProvider.get(mUserId, "lastAnswered"); + if (lastAnswered != null && lastAnswered.length() > 0) { + sb.append("\"lastAnswered\": \"" + lastAnswered + "\", "); + } + + // Compute score + Map<String, String> row = mAnswerProvider.query(mUserId); + if (row.size() > 0) { + Score score = new Score(); + boolean scoringDone = false; + int totalAnswers = 0; + for (Map.Entry<String, String> c : row.entrySet()) { + if (c.getKey().equals("lastAnswered") || c.getKey().equals("summary")) { + continue; + } + + try { + Question question = mQuestionProvider.get(c.getKey()); + RecordedAnswer userAnswer = MAPPER.readValue(c.getValue(), RecordedAnswer.class); + + if (question == null) { + LOG.warn("Answer for unknown question: " + c.getKey()); + continue; + } + + LOG.debug("Scoring questionId: " + c.getKey()); + scoringDone = !question.scoreAnswer(score, userAnswer); + + } catch (Exception e) { + LOG.error("Failed to score question: {userid: \"" + mUserId + + "\", questionid:\"" + c.getKey() + + "\", userAnswer:\"" + c.getValue() + "\"}", e); + } + + totalAnswers++; + } + + sb.append("\"score\":" + score.getScore()); + sb.append(", \"sum\":" + score.getSum()); + sb.append(", \"count\":" + score.getCount()); + sb.append(", \"totalAnswers\":" + totalAnswers); + sb.append(", \"result\":\"" + score.toString() + "\""); + } + + sb.append(" }"); + String summary = sb.toString(); + + // Persist summary + mAnswerProvider.put(mUserId, "summary", summary); + + return summary; + } +} diff --git a/src/main/java/com/p4square/grow/backend/resources/TrainingRecordResource.java b/src/main/java/com/p4square/grow/backend/resources/TrainingRecordResource.java new file mode 100644 index 0000000..51ba56a --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/resources/TrainingRecordResource.java @@ -0,0 +1,235 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +import java.io.IOException; + +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; + +import org.restlet.ext.jackson.JacksonRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.grow.backend.GrowBackend; + +import com.p4square.grow.model.Chapter; +import com.p4square.grow.model.Playlist; +import com.p4square.grow.model.VideoRecord; +import com.p4square.grow.model.TrainingRecord; + +import com.p4square.grow.provider.CollectionProvider; +import com.p4square.grow.provider.JsonEncodedProvider; +import com.p4square.grow.provider.Provider; +import com.p4square.grow.provider.ProvidesAssessments; +import com.p4square.grow.provider.ProvidesTrainingRecords; + +import com.p4square.grow.model.Score; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class TrainingRecordResource extends ServerResource { + private static final Logger LOG = Logger.getLogger(TrainingRecordResource.class); + private static final ObjectMapper MAPPER = JsonEncodedProvider.MAPPER; + + static enum RequestType { + SUMMARY, VIDEO + } + + private Provider<String, TrainingRecord> mTrainingRecordProvider; + private CollectionProvider<String, String, String> mAnswerProvider; + + private RequestType mRequestType; + private String mUserId; + private String mVideoId; + private TrainingRecord mRecord; + + @Override + public void doInit() { + super.doInit(); + + mTrainingRecordProvider = ((ProvidesTrainingRecords) getApplication()).getTrainingRecordProvider(); + mAnswerProvider = ((ProvidesAssessments) getApplication()).getAnswerProvider(); + + mUserId = getAttribute("userId"); + mVideoId = getAttribute("videoId"); + + try { + Playlist defaultPlaylist = ((ProvidesTrainingRecords) getApplication()).getDefaultPlaylist(); + + mRecord = mTrainingRecordProvider.get(mUserId); + if (mRecord == null) { + mRecord = new TrainingRecord(); + mRecord.setPlaylist(defaultPlaylist); + skipAssessedChapters(mUserId, mRecord); + } else { + // Merge the playlist with the most recent version. + mRecord.getPlaylist().merge(defaultPlaylist); + } + + } catch (IOException e) { + LOG.error("IOException loading TrainingRecord: " + e.getMessage(), e); + mRecord = null; + } + + mRequestType = RequestType.SUMMARY; + if (mVideoId != null) { + mRequestType = RequestType.VIDEO; + } + } + + /** + * Handle GET Requests. + */ + @Override + protected Representation get() { + JacksonRepresentation<?> rep = null; + + if (mRecord == null) { + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + + switch (mRequestType) { + case VIDEO: + VideoRecord video = mRecord.getPlaylist().find(mVideoId); + if (video == null) { + break; // Fall through and return 404 + } + rep = new JacksonRepresentation<VideoRecord>(video); + break; + + case SUMMARY: + rep = new JacksonRepresentation<TrainingRecord>(mRecord); + break; + } + + if (rep == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + + } else { + rep.setObjectMapper(JsonEncodedProvider.MAPPER); + return rep; + } + } + + /** + * Handle PUT requests + */ + @Override + protected Representation put(Representation entity) { + if (mRecord == null) { + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + + switch (mRequestType) { + case VIDEO: + try { + JacksonRepresentation<VideoRecord> representation = + new JacksonRepresentation<>(entity, VideoRecord.class); + representation.setObjectMapper(JsonEncodedProvider.MAPPER); + VideoRecord update = representation.getObject(); + VideoRecord video = mRecord.getPlaylist().find(mVideoId); + + if (video == null) { + // TODO: Video isn't on their playlist... + LOG.warn("Skipping video completion for video missing from playlist."); + + } else if (update.getComplete() && !video.getComplete()) { + // Video was newly completed + video.complete(); + mRecord.setLastVideo(mVideoId); + + mTrainingRecordProvider.put(mUserId, mRecord); + } + + setStatus(Status.SUCCESS_NO_CONTENT); + + } catch (Exception e) { + LOG.warn("Caught exception updating training record: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + } + break; + + default: + setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + } + + return null; + } + + private Score getAssessedScore(String userId) throws IOException { + // Get the user's score. + Score assessedScore = new Score(0, 0); + + String summaryString = mAnswerProvider.get(userId, "summary"); + if (summaryString == null) { + throw new IOException("Asked to create training record for unassessed user " + userId); + } + + Map<?,?> summary = MAPPER.readValue(summaryString, Map.class); + + if (summary.containsKey("sum") && summary.containsKey("count")) { + double sum = (Double) summary.get("sum"); + int count = (Integer) summary.get("count"); + assessedScore = new Score(sum, count); + } + + return assessedScore; + } + + /** + * Mark the chapters which the user assessed through as not required. + */ + private void skipAssessedChapters(String userId, TrainingRecord record) { + // Get the user's score. + Score assessedScore = new Score(0, 0); + + try { + assessedScore = getAssessedScore(userId); + } catch (IOException e) { + LOG.error("IOException fetching assessment record for " + userId, e); + return; + } + + // Mark the correct videos as not required. + Playlist playlist = record.getPlaylist(); + + for (Map.Entry<String, Chapter> entry : playlist.getChaptersMap().entrySet()) { + String chapterId = entry.getKey(); + Chapter chapter = entry.getValue(); + boolean required; + + if ("introduction".equals(chapter)) { + // Introduction chapter is always required + required = true; + + } else { + // Chapter required if the floor of the score is <= the chapter's numeric value. + required = assessedScore.floor() <= Score.numericScore(chapterId); + } + + if (!required) { + for (VideoRecord video : chapter.getVideos().values()) { + video.setRequired(required); + } + } + } + } +} diff --git a/src/main/java/com/p4square/grow/backend/resources/TrainingResource.java b/src/main/java/com/p4square/grow/backend/resources/TrainingResource.java new file mode 100644 index 0000000..6efdfab --- /dev/null +++ b/src/main/java/com/p4square/grow/backend/resources/TrainingResource.java @@ -0,0 +1,97 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.backend.resources; + +import java.io.IOException; +import java.util.Map; + +import org.restlet.data.Status; +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.grow.backend.GrowBackend; +import com.p4square.grow.backend.db.CassandraDatabase; + +import com.p4square.grow.provider.CollectionProvider; +/** + * This resource returns a listing of training items for a particular level. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class TrainingResource extends ServerResource { + private final static Logger LOG = Logger.getLogger(TrainingResource.class); + + private CollectionProvider<String, String, String> mVideoProvider; + + private String mLevel; + private String mVideoId; + + @Override + public void doInit() { + super.doInit(); + + GrowBackend backend = (GrowBackend) getApplication(); + mVideoProvider = backend.getVideoProvider(); + + mLevel = getAttribute("level"); + mVideoId = getAttribute("videoId"); + } + + /** + * Handle GET Requests. + */ + @Override + protected Representation get() { + String result = null; + + if (mLevel == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + try { + if (mVideoId == null) { + // Get all videos + // TODO: This could be improved, but this is the quickest way to get + // providers working. + Map<String, String> videos = mVideoProvider.query(mLevel); + if (videos.size() > 0) { + StringBuilder sb = new StringBuilder("{ \"level\": \"" + mLevel + "\""); + sb.append(", \"videos\": ["); + boolean first = true; + for (String value : videos.values()) { + if (!first) { + sb.append(", "); + } + sb.append(value); + first = false; + } + sb.append("] }"); + result = sb.toString(); + } + + } else { + // Get single video + result = mVideoProvider.get(mLevel, mVideoId); + } + + if (result == null) { + // 404 + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + return new StringRepresentation(result); + + } catch (IOException e) { + LOG.error("IOException fetch video: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } +} diff --git a/src/main/java/com/p4square/grow/ccb/CCBProgressReporter.java b/src/main/java/com/p4square/grow/ccb/CCBProgressReporter.java new file mode 100644 index 0000000..d2826eb --- /dev/null +++ b/src/main/java/com/p4square/grow/ccb/CCBProgressReporter.java @@ -0,0 +1,104 @@ +package com.p4square.grow.ccb; + +import com.p4square.ccbapi.CCBAPI; +import com.p4square.ccbapi.model.*; +import com.p4square.grow.frontend.ProgressReporter; +import com.p4square.grow.model.Score; +import org.apache.log4j.Logger; +import org.restlet.security.User; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Date; + +/** + * A ProgressReporter which records progress in CCB. + * + * Except not really, because it's not implemented yet. + * This is just a placeholder until ccb-api-client-java has support for updating an individual. + */ +public class CCBProgressReporter implements ProgressReporter { + + private static final Logger LOG = Logger.getLogger(CCBProgressReporter.class); + + private static final String GROW_LEVEL = "GrowLevelTrain"; + private static final String GROW_ASSESSMENT = "GrowLevelAsmnt"; + + private final CCBAPI mAPI; + private final CustomFieldCache mCache; + + public CCBProgressReporter(final CCBAPI api, final CustomFieldCache cache) { + mAPI = api; + mCache = cache; + } + + @Override + public void reportAssessmentComplete(final User user, final String level, final Date date, final String results) { + if (!(user instanceof CCBUser)) { + throw new IllegalArgumentException("Expected CCBUser but got " + user.getClass().getCanonicalName()); + } + final CCBUser ccbuser = (CCBUser) user; + + updateLevelAndDate(ccbuser, GROW_ASSESSMENT, level, date); + } + + @Override + public void reportChapterComplete(final User user, final String chapter, final Date date) { + if (!(user instanceof CCBUser)) { + throw new IllegalArgumentException("Expected CCBUser but got " + user.getClass().getCanonicalName()); + } + final CCBUser ccbuser = (CCBUser) user; + + // Only update the level if it is increasing. + final CustomPulldownFieldValue currentLevel = ccbuser.getProfile() + .getCustomPulldownFields().getByLabel(GROW_LEVEL); + + if (currentLevel != null) { + if (Score.numericScore(chapter) <= Score.numericScore(currentLevel.getSelection().getLabel())) { + LOG.info("Not updating level for " + user.getIdentifier() + + " because current level (" + currentLevel.getSelection().getLabel() + + ") is greater than new level (" + chapter + ")"); + return; + } + } + + updateLevelAndDate(ccbuser, GROW_LEVEL, chapter, date); + } + + private void updateLevelAndDate(final CCBUser user, final String field, final String level, final Date date) { + boolean modified = false; + + final UpdateIndividualProfileRequest req = new UpdateIndividualProfileRequest() + .withIndividualId(user.getProfile().getId()); + + final CustomField pulldownField = mCache.getIndividualPulldownByLabel(field); + if (pulldownField != null) { + final LookupTableType type = LookupTableType.valueOf(pulldownField.getName().toUpperCase()); + final LookupTableItem item = mCache.getPulldownItemByName(type, level); + if (item != null) { + req.withCustomPulldownField(pulldownField.getName(), item.getId()); + modified = true; + } + } + + final CustomField dateField = mCache.getDateFieldByLabel(field); + if (dateField != null) { + req.withCustomDateField(dateField.getName(), date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()); + modified = true; + } + + try { + // Only update if a field exists. + if (modified) { + mAPI.updateIndividualProfile(req); + } + + } catch (IOException e) { + LOG.error("updateIndividual failed for " + user.getIdentifier() + + ", field " + field + + ", level " + level + + ", date " + date.toString()); + } + } +} diff --git a/src/main/java/com/p4square/grow/ccb/CCBUser.java b/src/main/java/com/p4square/grow/ccb/CCBUser.java new file mode 100644 index 0000000..7313172 --- /dev/null +++ b/src/main/java/com/p4square/grow/ccb/CCBUser.java @@ -0,0 +1,37 @@ +package com.p4square.grow.ccb; + +import com.p4square.ccbapi.model.IndividualProfile; +import org.restlet.security.User; + +/** + * CCBUser is an adapter between a CCB IndividualProfile and a Restlet User. + * + * Note: CCBUser prefixes the user's identifier with "CCB-". This is done to + * ensure the identifier does not collide with identifiers from other + * systems. + */ +public class CCBUser extends User { + + private final IndividualProfile mProfile; + + /** + * Wrap an IndividualProfile inside a User object. + * + * @param profile The CCB IndividualProfile for the user. + */ + public CCBUser(final IndividualProfile profile) { + mProfile = profile; + + setIdentifier("CCB-" + mProfile.getId()); + setFirstName(mProfile.getFirstName()); + setLastName(mProfile.getLastName()); + setEmail(mProfile.getEmail()); + } + + /** + * @return The IndividualProfile of the user. + */ + public IndividualProfile getProfile() { + return mProfile; + } +} diff --git a/src/main/java/com/p4square/grow/ccb/CCBUserVerifier.java b/src/main/java/com/p4square/grow/ccb/CCBUserVerifier.java new file mode 100644 index 0000000..db10b75 --- /dev/null +++ b/src/main/java/com/p4square/grow/ccb/CCBUserVerifier.java @@ -0,0 +1,50 @@ +package com.p4square.grow.ccb; + +import com.p4square.ccbapi.CCBAPI; +import com.p4square.ccbapi.model.GetIndividualProfilesRequest; +import com.p4square.ccbapi.model.GetIndividualProfilesResponse; +import org.apache.log4j.Logger; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.security.Verifier; + +/** + * CCBUserVerifier authenticates a user through the CCB individual_profile_from_login_password API. + */ +public class CCBUserVerifier implements Verifier { + private static final Logger LOG = Logger.getLogger(CCBUserVerifier.class); + + private final CCBAPI mAPI; + + public CCBUserVerifier(final CCBAPI api) { + mAPI = api; + } + + @Override + public int verify(Request request, Response response) { + if (request.getChallengeResponse() == null) { + return RESULT_MISSING; // no credentials + } + + final String username = request.getChallengeResponse().getIdentifier(); + final char[] password = request.getChallengeResponse().getSecret(); + + try { + GetIndividualProfilesResponse resp = mAPI.getIndividualProfiles( + new GetIndividualProfilesRequest().withLoginPassword(username, password)); + + if (resp.getIndividuals().size() == 1) { + // Wrap the IndividualProfile up in an User and update the user on the request. + final CCBUser user = new CCBUser(resp.getIndividuals().get(0)); + LOG.info("Successfully authenticated " + user.getIdentifier()); + request.getClientInfo().setUser(user); + return RESULT_VALID; + } + + } catch (Exception e) { + LOG.error("CCB API Exception: " + e, e); + } + + return RESULT_INVALID; // Invalid credentials + } +} diff --git a/src/main/java/com/p4square/grow/ccb/ChurchCommunityBuilderIntegrationDriver.java b/src/main/java/com/p4square/grow/ccb/ChurchCommunityBuilderIntegrationDriver.java new file mode 100644 index 0000000..fc6148f --- /dev/null +++ b/src/main/java/com/p4square/grow/ccb/ChurchCommunityBuilderIntegrationDriver.java @@ -0,0 +1,61 @@ +package com.p4square.grow.ccb; + +import com.codahale.metrics.MetricRegistry; +import com.p4square.ccbapi.CCBAPI; +import com.p4square.ccbapi.CCBAPIClient; +import com.p4square.grow.config.Config; +import com.p4square.grow.frontend.IntegrationDriver; +import com.p4square.grow.frontend.ProgressReporter; +import org.restlet.Context; +import org.restlet.security.Verifier; + +import java.net.URI; +import java.net.URISyntaxException; + +/** + * The ChurchCommunityBuilderIntegrationDriver is used to integrate Grow with Church Community Builder. + */ +public class ChurchCommunityBuilderIntegrationDriver implements IntegrationDriver { + + private final Context mContext; + private final MetricRegistry mMetricRegistry; + private final Config mConfig; + + private final CCBAPI mAPI; + + private final CCBProgressReporter mProgressReporter; + + public ChurchCommunityBuilderIntegrationDriver(final Context context) { + mContext = context; + mConfig = (Config) context.getAttributes().get("com.p4square.grow.config"); + mMetricRegistry = (MetricRegistry) context.getAttributes().get("com.p4square.grow.metrics"); + + try { + CCBAPI api = new CCBAPIClient(new URI(mConfig.getString("CCBAPIURL", "")), + mConfig.getString("CCBAPIUser", ""), + mConfig.getString("CCBAPIPassword", "")); + + if (mMetricRegistry != null) { + api = new MonitoredCCBAPI(api, mMetricRegistry); + } + + mAPI = api; + + final CustomFieldCache cache = new CustomFieldCache(mAPI); + mProgressReporter = new CCBProgressReporter(mAPI, cache); + + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + + @Override + public Verifier newUserAuthenticationVerifier() { + return new CCBUserVerifier(mAPI); + } + + @Override + public ProgressReporter getProgressReporter() { + return mProgressReporter; + } +} diff --git a/src/main/java/com/p4square/grow/ccb/CustomFieldCache.java b/src/main/java/com/p4square/grow/ccb/CustomFieldCache.java new file mode 100644 index 0000000..d93e6d9 --- /dev/null +++ b/src/main/java/com/p4square/grow/ccb/CustomFieldCache.java @@ -0,0 +1,126 @@ +package com.p4square.grow.ccb; + +import com.p4square.ccbapi.CCBAPI; +import com.p4square.ccbapi.model.*; +import org.apache.log4j.Logger; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * CustomFieldCache maintains an index from custom field labels to names. + */ +public class CustomFieldCache { + + private static final Logger LOG = Logger.getLogger(CustomFieldCache.class); + + private final CCBAPI mAPI; + + private CustomFieldCollection<CustomField> mTextFields; + private CustomFieldCollection<CustomField> mDateFields; + private CustomFieldCollection<CustomField> mIndividualPulldownFields; + private CustomFieldCollection<CustomField> mGroupPulldownFields; + + private final Map<LookupTableType, Map<String, LookupTableItem>> mItemByNameTable; + + public CustomFieldCache(final CCBAPI api) { + mAPI = api; + mTextFields = new CustomFieldCollection<>(); + mDateFields = new CustomFieldCollection<>(); + mIndividualPulldownFields = new CustomFieldCollection<>(); + mGroupPulldownFields = new CustomFieldCollection<>(); + mItemByNameTable = new HashMap<>(); + } + + public CustomField getTextFieldByLabel(final String label) { + if (mTextFields.size() == 0) { + refresh(); + } + return mTextFields.getByLabel(label); + } + + public CustomField getDateFieldByLabel(final String label) { + if (mDateFields.size() == 0) { + refresh(); + } + return mDateFields.getByLabel(label); + } + + public CustomField getIndividualPulldownByLabel(final String label) { + if (mIndividualPulldownFields.size() == 0) { + refresh(); + } + return mIndividualPulldownFields.getByLabel(label); + } + + public CustomField getGroupPulldownByLabel(final String label) { + if (mGroupPulldownFields.size() == 0) { + refresh(); + } + return mGroupPulldownFields.getByLabel(label); + } + + public LookupTableItem getPulldownItemByName(final LookupTableType type, final String name) { + Map<String, LookupTableItem> items = mItemByNameTable.get(type); + if (items == null) { + if (!cacheLookupTable(type)) { + return null; + } + items = mItemByNameTable.get(type); + } + + return items.get(name.toLowerCase()); + } + + private synchronized void refresh() { + try { + // Get all of the custom fields. + final GetCustomFieldLabelsResponse resp = mAPI.getCustomFieldLabels(); + + final CustomFieldCollection<CustomField> newTextFields = new CustomFieldCollection<>(); + final CustomFieldCollection<CustomField> newDateFields = new CustomFieldCollection<>(); + final CustomFieldCollection<CustomField> newIndPulldownFields = new CustomFieldCollection<>(); + final CustomFieldCollection<CustomField> newGrpPulldownFields = new CustomFieldCollection<>(); + + for (final CustomField field : resp.getCustomFields()) { + if (field.getName().startsWith("udf_ind_text_")) { + newTextFields.add(field); + } else if (field.getName().startsWith("udf_ind_date_")) { + newDateFields.add(field); + } else if (field.getName().startsWith("udf_ind_pulldown_")) { + newIndPulldownFields.add(field); + } else if (field.getName().startsWith("udf_grp_pulldown_")) { + newGrpPulldownFields.add(field); + } else { + LOG.warn("Unknown custom field type " + field.getName()); + } + } + + this.mTextFields = newTextFields; + this.mDateFields = newDateFields; + this.mIndividualPulldownFields = newIndPulldownFields; + this.mGroupPulldownFields = newGrpPulldownFields; + + } catch (IOException e) { + // Error fetching labels. + LOG.error("Error fetching custom fields: " + e.getMessage(), e); + } + } + + private synchronized boolean cacheLookupTable(final LookupTableType type) { + try { + final GetLookupTableResponse resp = mAPI.getLookupTable(new GetLookupTableRequest().withType(type)); + mItemByNameTable.put(type, resp.getItems().stream().collect( + Collectors.toMap(item -> item.getName().toLowerCase(), Function.identity()))); + return true; + + } catch (IOException e) { + LOG.error("Exception caching lookup table of type " + type, e); + } + + return false; + } +} diff --git a/src/main/java/com/p4square/grow/ccb/MonitoredCCBAPI.java b/src/main/java/com/p4square/grow/ccb/MonitoredCCBAPI.java new file mode 100644 index 0000000..43b6433 --- /dev/null +++ b/src/main/java/com/p4square/grow/ccb/MonitoredCCBAPI.java @@ -0,0 +1,96 @@ +package com.p4square.grow.ccb; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import com.p4square.ccbapi.CCBAPI; +import com.p4square.ccbapi.model.*; + +import java.io.IOException; + +/** + * MonitoredCCBAPI is a CCBAPI decorator which records metrics for each API call. + */ +public class MonitoredCCBAPI implements CCBAPI { + + private final CCBAPI mAPI; + private final MetricRegistry mMetricRegistry; + + public MonitoredCCBAPI(final CCBAPI api, final MetricRegistry metricRegistry) { + if (api == null) { + throw new IllegalArgumentException("api must not be null."); + } + mAPI = api; + + if (metricRegistry == null) { + throw new IllegalArgumentException("metricRegistry must not be null."); + } + mMetricRegistry = metricRegistry; + } + + @Override + public GetCustomFieldLabelsResponse getCustomFieldLabels() throws IOException { + final Timer.Context timer = mMetricRegistry.timer("CCBAPI.getCustomFieldLabels.time").time(); + boolean success = false; + try { + final GetCustomFieldLabelsResponse resp = mAPI.getCustomFieldLabels(); + success = true; + return resp; + } finally { + timer.stop(); + mMetricRegistry.counter("CCBAPI.getCustomFieldLabels.success").inc(success ? 1 : 0); + mMetricRegistry.counter("CCBAPI.getCustomFieldLabels.failure").inc(!success ? 1 : 0); + } + } + + @Override + public GetLookupTableResponse getLookupTable(final GetLookupTableRequest request) throws IOException { + final Timer.Context timer = mMetricRegistry.timer("CCBAPI.getLookupTable.time").time(); + boolean success = false; + try { + final GetLookupTableResponse resp = mAPI.getLookupTable(request); + success = true; + return resp; + } finally { + timer.stop(); + mMetricRegistry.counter("CCBAPI.getLookupTable.success").inc(success ? 1 : 0); + mMetricRegistry.counter("CCBAPI.getLookupTable.failure").inc(!success ? 1 : 0); + } + } + + @Override + public GetIndividualProfilesResponse getIndividualProfiles(GetIndividualProfilesRequest request) + throws IOException { + final Timer.Context timer = mMetricRegistry.timer("CCBAPI.getIndividualProfiles").time(); + boolean success = false; + try { + final GetIndividualProfilesResponse resp = mAPI.getIndividualProfiles(request); + mMetricRegistry.counter("CCBAPI.getIndividualProfiles.count").inc(resp.getIndividuals().size()); + success = true; + return resp; + } finally { + timer.stop(); + mMetricRegistry.counter("CCBAPI.getIndividualProfiles.success").inc(success ? 1 : 0); + mMetricRegistry.counter("CCBAPI.getIndividualProfiles.failure").inc(!success ? 1 : 0); + } + } + + @Override + public UpdateIndividualProfileResponse updateIndividualProfile(UpdateIndividualProfileRequest request) throws IOException { + final Timer.Context timer = mMetricRegistry.timer("CCBAPI.updateIndividualProfile").time(); + boolean success = false; + try { + final UpdateIndividualProfileResponse resp = mAPI.updateIndividualProfile(request); + success = true; + return resp; + } finally { + timer.stop(); + mMetricRegistry.counter("CCBAPI.updateIndividualProfile.success").inc(success ? 1 : 0); + mMetricRegistry.counter("CCBAPI.updateIndividualProfile.failure").inc(!success ? 1 : 0); + } + } + + @Override + public void close() throws IOException { + mAPI.close(); + } +} diff --git a/src/main/java/com/p4square/grow/config/Config.java b/src/main/java/com/p4square/grow/config/Config.java new file mode 100644 index 0000000..2fc2ea3 --- /dev/null +++ b/src/main/java/com/p4square/grow/config/Config.java @@ -0,0 +1,203 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.config; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.IOException; +import java.util.Properties; + +import org.apache.log4j.Logger; + +/** + * Manage configuration for an application. + * + * Config reads one or more property files as the application config. Duplicate + * properties loaded later override properties loaded earlier. Config has the + * concept of a domain to distinguish settings for development and production. + * The default domain is prod for production. Domain can be any String such as + * dev for development or test for testing. + * + * The property files are processed like java.util.Properties except that the + * keys are specified as DOMAIN.KEY. An asterisk (*) can be used in place of a + * domain to indicate it should apply to all domains. If a domain specific entry + * exists for the current domain, it will override any global config. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class Config { + private static final Logger LOG = Logger.getLogger(Config.class); + + private String mDomain; + private Properties mProperties; + + /** + * Construct a new Config object. + * + * Sets the domain to the value of the system property CONFIG_DOMAIN, if present. + * If the system property is not set then the environment variable CONFIG_DOMAIN is checked. + * If neither are set the domain defaults to prod. + */ + public Config() { + // Check the command line for a domain property. + mDomain = System.getProperty("CONFIG_DOMAIN"); + + // If the domain was not set with a property, check for an environment variable. + if (mDomain == null) { + mDomain = System.getenv("CONFIG_DOMAIN"); + } + + // If neither were set, default to prod + if (mDomain == null) { + mDomain = "prod"; + } + + mProperties = new Properties(); + } + + /** + * Change the domain from the default string "prod". + * + * @param domain The new domain. + */ + public void setDomain(String domain) { + LOG.info("Setting Config domain to " + domain); + mDomain = domain; + } + + /** + * @return the current domain. + */ + public String getDomain() { + return mDomain; + } + + /** + * Load properties from a file. + * Any exception are logged and suppressed. + */ + public void updateConfig(String propertyFilename) { + final File propFile = new File(propertyFilename); + + LOG.info("Loading properties from " + propFile); + + try { + InputStream in = new FileInputStream(propFile); + updateConfig(in); + + } catch (IOException e) { + LOG.error("Could not load properties file: " + e.getMessage(), e); + } + } + + /** + * Load properties from an InputStream. + * This method closes the InputStream when it completes. + * + * @param in The InputStream + */ + public void updateConfig(InputStream in) throws IOException { + LOG.info("Loading properties from InputStream"); + mProperties.load(in); + in.close(); + } + + /** + * Get a String from the config. + * + * @return The config value or null if it is not found. + */ + public String getString(String key) { + return getString(key, null); + } + + /** + * Get a String from the config. + * + * @return The config value or defaultValue if it can not be found. + */ + public String getString(final String key, final String defaultValue) { + String result; + + // Command line properties trump all. + result = System.getProperty(key); + if (result != null) { + LOG.debug("Reading System.getProperty(" + key + "). Got result = { " + result + " }"); + return result; + } + + // Environment variables can also override configs + result = System.getenv(key); + if (result != null) { + LOG.debug("Reading System.getenv(" + key + "). Got result = { " + result + " }"); + return result; + } + + final String domainKey = mDomain + "." + key; + result = mProperties.getProperty(domainKey); + if (result != null) { + LOG.debug("Reading config for key = { " + key + " }. Got result = { " + result + " }"); + return result; + } + + final String globalKey = "*." + key; + result = mProperties.getProperty(globalKey); + if (result != null) { + LOG.debug("Reading config for key = { " + key + " }. Got result = { " + result + " }"); + return result; + } + + LOG.debug("Reading config for key = { " + key + " }. Got default value = { " + defaultValue + " }"); + return defaultValue; + } + + /** + * Get an integer from the config. + * + * @return The config value or Integer.MIN_VALUE if the key is not present or the + * config can not be parsed. + */ + public int getInt(String key) { + return getInt(key, Integer.MIN_VALUE); + } + + /** + * Get an integer from the config. + * + * @return The config value or defaultValue if the key is not present or the + * config can not be parsed. + */ + public int getInt(String key, int defaultValue) { + final String propertyValue = getString(key); + + if (propertyValue != null) { + try { + final int result = Integer.valueOf(propertyValue); + return result; + + } catch (NumberFormatException e) { + LOG.warn("Expected property to be an integer: " + + key + " = { " + propertyValue + " }"); + } + } + + return defaultValue; + } + + public boolean getBoolean(String key) { + return getBoolean(key, false); + } + + public boolean getBoolean(String key, boolean defaultValue) { + final String propertyValue = getString(key); + + if (propertyValue != null) { + return (propertyValue.charAt(0) & 0xDF) == 'T'; + } + + return defaultValue; + } +} diff --git a/src/main/java/com/p4square/grow/frontend/AccountRedirectResource.java b/src/main/java/com/p4square/grow/frontend/AccountRedirectResource.java new file mode 100644 index 0000000..be2ae65 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/AccountRedirectResource.java @@ -0,0 +1,113 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import org.restlet.data.Status; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.resource.ServerResource; + +import org.apache.log4j.Logger; + +import com.p4square.fmfacade.FreeMarkerPageResource; + +import com.p4square.grow.config.Config; +import com.p4square.grow.model.UserRecord; +import com.p4square.grow.provider.Provider; +import com.p4square.grow.provider.DelegateProvider; +import com.p4square.grow.provider.JsonEncodedProvider; + +/** + * This resource simply redirects the user to either the assessment + * or the training page. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class AccountRedirectResource extends ServerResource { + private static final Logger LOG = Logger.getLogger(AccountRedirectResource.class); + + private Config mConfig; + private Provider<String, UserRecord> mUserRecordProvider; + + // Fields pertaining to this request. + private String mUserId; + + @Override + public void doInit() { + super.doInit(); + + GrowFrontend growFrontend = (GrowFrontend) getApplication(); + mConfig = growFrontend.getConfig(); + + mUserRecordProvider = new DelegateProvider<String, String, UserRecord>( + new JsonRequestProvider<UserRecord>(getContext().getClientDispatcher(), + UserRecord.class)) { + @Override + public String makeKey(String userid) { + return getBackendEndpoint() + "/accounts/" + userid; + } + }; + + mUserId = getRequest().getClientInfo().getUser().getIdentifier(); + } + + /** + * Redirect to the correct landing. + */ + @Override + protected Representation get() { + if (mUserId == null || mUserId.length() == 0) { + // This shouldn't happen, but I want to be safe because of the DB insert below. + setStatus(Status.CLIENT_ERROR_FORBIDDEN); + return new ErrorPage("Not Authenticated!"); + } + + try { + // Fetch account Map. + UserRecord user = null; + try { + user = mUserRecordProvider.get(mUserId); + } catch (NotFoundException e) { + // User record doesn't exist, so create a new one. + user = new UserRecord(getRequest().getClientInfo().getUser()); + mUserRecordProvider.put(mUserId, user); + } + + // Check for the new believers cookie + String cookie = getRequest().getCookies().getFirstValue(NewBelieverResource.COOKIE_NAME); + if (cookie != null && cookie.length() != 0) { + user.setLanding("training"); + user.setNewBeliever(true); + mUserRecordProvider.put(mUserId, user); + } + + String landing = user.getLanding(); + if (landing == null) { + landing = "assessment"; + } + + String nextPage = mConfig.getString("dynamicRoot", ""); + nextPage += "/account/" + landing; + getResponse().redirectSeeOther(nextPage); + return new StringRepresentation("Redirecting to " + nextPage); + + } catch (Exception e) { + LOG.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.RENDER_ERROR; + } + } + + /** + * @return The backend endpoint URI + */ + private String getBackendEndpoint() { + return mConfig.getString("backendUri", "riap://component/backend"); + } +} diff --git a/src/main/java/com/p4square/grow/frontend/AssessmentResetPage.java b/src/main/java/com/p4square/grow/frontend/AssessmentResetPage.java new file mode 100644 index 0000000..519b135 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/AssessmentResetPage.java @@ -0,0 +1,99 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.util.Map; + +import freemarker.template.Template; + +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.ext.freemarker.TemplateRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.fmfacade.FreeMarkerPageResource; + +import com.p4square.fmfacade.json.JsonRequestClient; +import com.p4square.fmfacade.json.JsonResponse; +import com.p4square.fmfacade.json.ClientException; + +import com.p4square.grow.config.Config; + +/** + * This page delete's the current user's assessment. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class AssessmentResetPage extends FreeMarkerPageResource { + private static final Logger LOG = Logger.getLogger(AssessmentResetPage.class); + + private GrowFrontend mGrowFrontend; + private Config mConfig; + private JsonRequestClient mJsonClient; + + private String mUserId; + + @Override + public void doInit() { + super.doInit(); + + mGrowFrontend = (GrowFrontend) getApplication(); + mConfig = mGrowFrontend.getConfig(); + + mJsonClient = new JsonRequestClient(getContext().getClientDispatcher()); + + mUserId = getRequest().getClientInfo().getUser().getIdentifier(); + } + + /** + * Return the login page. + */ + @Override + protected Representation get() { + try { + // Get the assessment results + JsonResponse response = backendDelete("/accounts/" + mUserId + "/assessment"); + if (!response.getStatus().isSuccess()) { + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.BACKEND_ERROR; + } + + String nextPage = mConfig.getString("dynamicRoot", "") + + "/account/assessment/question/first"; + getResponse().redirectSeeOther(nextPage); + return new StringRepresentation("Redirecting to " + nextPage); + + } catch (Exception e) { + LOG.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.RENDER_ERROR; + } + } + + /** + * @return The backend endpoint URI + */ + private String getBackendEndpoint() { + return mConfig.getString("backendUri", "riap://component/backend"); + } + + /** + * Helper method to send a GET to the backend. + */ + private JsonResponse backendDelete(final String uri) { + LOG.debug("Sending backend GET " + uri); + + final JsonResponse response = mJsonClient.delete(getBackendEndpoint() + uri); + final Status status = response.getStatus(); + if (!status.isSuccess() && !Status.CLIENT_ERROR_NOT_FOUND.equals(status)) { + LOG.warn("Error making backend request for '" + uri + "'. status = " + response.getStatus().toString()); + } + + return response; + } +} diff --git a/src/main/java/com/p4square/grow/frontend/AssessmentResultsPage.java b/src/main/java/com/p4square/grow/frontend/AssessmentResultsPage.java new file mode 100644 index 0000000..f1c924b --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/AssessmentResultsPage.java @@ -0,0 +1,145 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.util.Date; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.p4square.f1oauth.FellowshipOneIntegrationDriver; +import freemarker.template.Template; + +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.ext.freemarker.TemplateRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.fmfacade.FreeMarkerPageResource; + +import com.p4square.fmfacade.json.JsonRequestClient; +import com.p4square.fmfacade.json.JsonResponse; +import com.p4square.fmfacade.json.ClientException; + +import com.p4square.f1oauth.Attribute; +import com.p4square.f1oauth.F1API; +import com.p4square.f1oauth.F1User; + +import com.p4square.grow.config.Config; +import com.p4square.grow.provider.JsonEncodedProvider; +import org.restlet.security.User; + +/** + * This page fetches the user's final score and displays the transitional page between + * the assessment and the videos. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class AssessmentResultsPage extends FreeMarkerPageResource { + private static final Logger LOG = Logger.getLogger(AssessmentResultsPage.class); + + private GrowFrontend mGrowFrontend; + private Config mConfig; + private JsonRequestClient mJsonClient; + + private String mUserId; + + @Override + public void doInit() { + super.doInit(); + + mGrowFrontend = (GrowFrontend) getApplication(); + mConfig = mGrowFrontend.getConfig(); + + mJsonClient = new JsonRequestClient(getContext().getClientDispatcher()); + + mUserId = getRequest().getClientInfo().getUser().getIdentifier(); + } + + /** + * Return the login page. + */ + @Override + protected Representation get() { + Template t = mGrowFrontend.getTemplate("templates/assessment-results.ftl"); + + try { + if (t == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return ErrorPage.TEMPLATE_NOT_FOUND; + } + + Map<String, Object> root = getRootObject(); + + // Get the assessment results + JsonResponse response = backendGet("/accounts/" + mUserId + "/assessment"); + if (!response.getStatus().isSuccess()) { + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.BACKEND_ERROR; + } + + final String score = (String) response.getMap().get("result"); + if (score == null) { + // Odd... send them to the first questions + String nextPage = mConfig.getString("dynamicRoot", "") + + "/account/assessment/question/first"; + getResponse().redirectSeeOther(nextPage); + return new StringRepresentation("Redirecting to " + nextPage); + } + + // Publish results in F1 + publishScoreInF1(response.getMap()); + + root.put("stage", score); + return new TemplateRepresentation(t, root, MediaType.TEXT_HTML); + + } catch (Exception e) { + LOG.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.RENDER_ERROR; + } + } + + private void publishScoreInF1(Map results) { + final ProgressReporter reporter = mGrowFrontend.getThirdPartyIntegrationFactory().getProgressReporter(); + + try { + final User user = getRequest().getClientInfo().getUser(); + final String level = results.get("result").toString(); + final Date completionDate = new Date(); + final String data = JsonEncodedProvider.MAPPER.writeValueAsString(results); + + reporter.reportAssessmentComplete(user, level, completionDate, data); + + } catch (JsonProcessingException e) { + LOG.error("Failed to generate json " + e.getMessage(), e); + } + } + + /** + * @return The backend endpoint URI + */ + private String getBackendEndpoint() { + return mConfig.getString("backendUri", "riap://component/backend"); + } + + /** + * Helper method to send a GET to the backend. + */ + private JsonResponse backendGet(final String uri) { + LOG.debug("Sending backend GET " + uri); + + final JsonResponse response = mJsonClient.get(getBackendEndpoint() + uri); + final Status status = response.getStatus(); + if (!status.isSuccess() && !Status.CLIENT_ERROR_NOT_FOUND.equals(status)) { + LOG.warn("Error making backend request for '" + uri + "'. status = " + + response.getStatus().toString()); + } + + return response; + } +} diff --git a/src/main/java/com/p4square/grow/frontend/AuthenticatedResource.java b/src/main/java/com/p4square/grow/frontend/AuthenticatedResource.java new file mode 100644 index 0000000..800eb83 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/AuthenticatedResource.java @@ -0,0 +1,18 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class AuthenticatedResource extends ServerResource { + protected Representation post() { + return null; + } +} diff --git a/src/main/java/com/p4square/grow/frontend/ChapterCompletePage.java b/src/main/java/com/p4square/grow/frontend/ChapterCompletePage.java new file mode 100644 index 0000000..35abc43 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/ChapterCompletePage.java @@ -0,0 +1,209 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.util.Date; +import java.util.Map; + +import com.p4square.f1oauth.FellowshipOneIntegrationDriver; +import freemarker.template.Template; + +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.ext.freemarker.TemplateRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.fmfacade.FreeMarkerPageResource; + +import com.p4square.fmfacade.json.JsonRequestClient; +import com.p4square.fmfacade.json.JsonResponse; +import com.p4square.fmfacade.json.ClientException; + +import com.p4square.f1oauth.Attribute; +import com.p4square.f1oauth.F1API; +import com.p4square.f1oauth.F1User; + +import com.p4square.grow.config.Config; +import com.p4square.grow.model.TrainingRecord; +import com.p4square.grow.provider.Provider; +import com.p4square.grow.provider.TrainingRecordProvider; +import org.restlet.security.User; + +/** + * This resource displays the transitional page between chapters. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class ChapterCompletePage extends FreeMarkerPageResource { + private static final Logger LOG = Logger.getLogger(ChapterCompletePage.class); + + private GrowFrontend mGrowFrontend; + private Config mConfig; + private JsonRequestClient mJsonClient; + private Provider<String, TrainingRecord> mTrainingRecordProvider; + + private String mUserId; + private String mChapter; + + @Override + public void doInit() { + super.doInit(); + + mGrowFrontend = (GrowFrontend) getApplication(); + mConfig = mGrowFrontend.getConfig(); + + mJsonClient = new JsonRequestClient(getContext().getClientDispatcher()); + mTrainingRecordProvider = new TrainingRecordProvider<String>( + new JsonRequestProvider<TrainingRecord>( + getContext().getClientDispatcher(), + TrainingRecord.class)) { + @Override + public String makeKey(String userid) { + return getBackendEndpoint() + "/accounts/" + userid + "/training"; + } + }; + + mUserId = getRequest().getClientInfo().getUser().getIdentifier(); + + mChapter = getAttribute("chapter"); + } + + /** + * Return the login page. + */ + @Override + protected Representation get() { + try { + Map<String, Object> root = getRootObject(); + + // Get the training summary + TrainingRecord trainingRecord = mTrainingRecordProvider.get(mUserId); + if (trainingRecord == null) { + // Wait. What? Everyone has a training record... + setStatus(Status.SERVER_ERROR_INTERNAL); + return new ErrorPage("Could not retrieve your training record."); + } + + // Verify they completed the chapter. + Map<String, Boolean> chapters = trainingRecord.getPlaylist().getChapterStatuses(); + Boolean completed = chapters.get(mChapter); + if (completed == null || !completed) { + // Redirect back to training page... + String nextPage = mConfig.getString("dynamicRoot", ""); + nextPage += "/account/training/" + mChapter; + getResponse().redirectSeeOther(nextPage); + return new StringRepresentation("Redirecting to " + nextPage); + } + + // Publish the training chapter complete attribute. + assignAttribute(); + + // Find the next chapter + String nextChapter = null; + { + int min = Integer.MAX_VALUE; + for (Map.Entry<String, Boolean> chapter : chapters.entrySet()) { + int index = chapterIndex(chapter.getKey()); + if (!chapter.getValue() && index < min) { + min = index; + nextChapter = chapter.getKey(); + } + } + } + + String nextOverride = getQueryValue("next"); + if (nextOverride != null) { + nextChapter = nextOverride; + } + + root.put("stage", mChapter); + root.put("nextstage", nextChapter); + + /* + * We will display one of two transitional pages: + * + * If the next chapter has a forward page, display the forward page. + * Else, if this chapter is not "Introduction", display the chapter + * complete message. + */ + Template t = mGrowFrontend.getTemplate("templates/stage-" + + nextChapter + "-forward.ftl"); + + if (t == null) { + // Skip the chapter complete message for "Introduction" + if ("introduction".equals(mChapter)) { + String nextPage = mConfig.getString("dynamicRoot", ""); + nextPage += "/account/training/" + nextChapter; + getResponse().redirectSeeOther(nextPage); + return new StringRepresentation("Redirecting to " + nextPage); + } + + t = mGrowFrontend.getTemplate("templates/stage-complete.ftl"); + if (t == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return ErrorPage.TEMPLATE_NOT_FOUND; + } + } + + return new TemplateRepresentation(t, root, MediaType.TEXT_HTML); + + } catch (Exception e) { + LOG.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.RENDER_ERROR; + } + } + + private void assignAttribute() { + final ProgressReporter reporter = mGrowFrontend.getThirdPartyIntegrationFactory().getProgressReporter(); + + final User user = getRequest().getClientInfo().getUser(); + final Date completionDate = new Date(); + + reporter.reportChapterComplete(user, mChapter, completionDate); + } + + /** + * @return The backend endpoint URI + */ + private String getBackendEndpoint() { + return mConfig.getString("backendUri", "riap://component/backend"); + } + + /** + * Helper method to send a GET to the backend. + */ + private JsonResponse backendGet(final String uri) { + LOG.debug("Sending backend GET " + uri); + + final JsonResponse response = mJsonClient.get(getBackendEndpoint() + uri); + final Status status = response.getStatus(); + if (!status.isSuccess() && !Status.CLIENT_ERROR_NOT_FOUND.equals(status)) { + LOG.warn("Error making backend request for '" + uri + + "'. status = " + response.getStatus().toString()); + } + + return response; + } + + int chapterIndex(String chapter) { + if ("leader".equals(chapter)) { + return 5; + } else if ("teacher".equals(chapter)) { + return 4; + } else if ("disciple".equals(chapter)) { + return 3; + } else if ("believer".equals(chapter)) { + return 2; + } else if ("seeker".equals(chapter)) { + return 1; + } else { + return Integer.MAX_VALUE; + } + } +} diff --git a/src/main/java/com/p4square/grow/frontend/ErrorPage.java b/src/main/java/com/p4square/grow/frontend/ErrorPage.java new file mode 100644 index 0000000..81abe74 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/ErrorPage.java @@ -0,0 +1,77 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.util.HashMap; +import java.util.Map; + +import java.io.IOException; +import java.io.Writer; + +import freemarker.template.Template; + +import org.restlet.data.MediaType; +import org.restlet.ext.freemarker.TemplateRepresentation; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.representation.WriterRepresentation; + +import com.p4square.fmfacade.FreeMarkerPageResource; + +/** + * ErrorPage wraps a String or Template Representation and displays the given + * error message. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class ErrorPage extends WriterRepresentation { + public static final ErrorPage TEMPLATE_NOT_FOUND = + new ErrorPage("Could not find the requested page template."); + + public static final ErrorPage RENDER_ERROR = + new ErrorPage("Error rendering page."); + + public static final ErrorPage BACKEND_ERROR = + new ErrorPage("Error communicating with backend."); + + public static final ErrorPage NOT_FOUND = + new ErrorPage("The requested URL could not be found."); + + private static Template cTemplate = null; + private static Map<String, Object> cRoot = null; + + private final String mMessage; + + public ErrorPage(String msg) { + this(msg, MediaType.TEXT_HTML); + } + + public ErrorPage(String msg, MediaType mediaType) { + super(mediaType); + + mMessage = msg; + } + + public static synchronized void setTemplate(Template template, Map<String, Object> root) { + cTemplate = template; + cRoot = root; + } + + protected Representation getRepresentation() { + if (cTemplate == null) { + return new StringRepresentation(mMessage); + + } else { + Map<String, Object> root = new HashMap<String, Object>(cRoot); + root.put("errorMessage", mMessage); + return new TemplateRepresentation(cTemplate, root, MediaType.TEXT_HTML); + } + } + + @Override + public void write(Writer writer) throws IOException { + getRepresentation().write(writer); + } +} diff --git a/src/main/java/com/p4square/grow/frontend/FeedData.java b/src/main/java/com/p4square/grow/frontend/FeedData.java new file mode 100644 index 0000000..feb03a1 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/FeedData.java @@ -0,0 +1,105 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.io.IOException; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; + +import org.restlet.Context; +import org.restlet.Restlet; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.type.TypeFactory; + +import com.p4square.grow.config.Config; +import com.p4square.grow.frontend.JsonRequestProvider; +import com.p4square.grow.model.Message; +import com.p4square.grow.model.MessageThread; +import com.p4square.grow.provider.JsonEncodedProvider; +import com.p4square.grow.provider.Provider; + +/** + * Fetch feed data for a topic. + */ +public class FeedData { + + /** + * Allowed Topics. + */ + public static final HashSet<String> TOPICS = new HashSet(Arrays.asList("seeker", "believer", + "disciple", "teacher", "leader")); + + + private final Config mConfig; + private final String mBackendURI; + + // TODO: Elegantly merge the List and individual providers. + private final JsonRequestProvider<List<MessageThread>> mThreadsProvider; + private final JsonRequestProvider<MessageThread> mThreadProvider; + + private final JsonRequestProvider<List<Message>> mMessagesProvider; + private final JsonRequestProvider<Message> mMessageProvider; + + public FeedData(final Context context, final Config config) { + mConfig = config; + mBackendURI = mConfig.getString("backendUri", "riap://component/backend") + "/feed"; + + Restlet clientDispatcher = context.getClientDispatcher(); + + TypeFactory factory = JsonEncodedProvider.MAPPER.getTypeFactory(); + + JavaType threadType = factory.constructCollectionType(List.class, MessageThread.class); + mThreadsProvider = new JsonRequestProvider<List<MessageThread>>(clientDispatcher, threadType); + mThreadProvider = new JsonRequestProvider<MessageThread>(clientDispatcher, MessageThread.class); + + JavaType messageType = factory.constructCollectionType(List.class, Message.class); + mMessagesProvider = new JsonRequestProvider<List<Message>>(clientDispatcher, messageType); + mMessageProvider = new JsonRequestProvider<Message>(clientDispatcher, Message.class); + } + + /** + * Get the threads for a topic. + * + * @param topic The topic to request threads for. + * @param limit The maximum number of threads. + * @return A list of MessageThread objects. + */ + public List<MessageThread> getThreads(final String topic, final int limit) throws IOException { + return mThreadsProvider.get(makeUrl(limit, topic)); + } + + public List<Message> getMessages(final String topic, final String threadId) throws IOException { + return mMessagesProvider.get(makeUrl(topic, threadId)); + } + + public void createThread(final String topic, final Message message) throws IOException { + MessageThread thread = new MessageThread(); + thread.setMessage(message); + + mThreadProvider.post(makeUrl(topic), thread); + } + + public void createResponse(final String topic, final String thread, final Message message) + throws IOException { + + mMessageProvider.post(makeUrl(topic, thread), message); + } + + private String makeUrl(String... parts) { + String url = mBackendURI; + for (String part : parts) { + url += "/" + part; + } + + return url; + } + + private String makeUrl(int limit, String... parts) { + return makeUrl(parts) + "?limit=" + limit; + } +} diff --git a/src/main/java/com/p4square/grow/frontend/FeedResource.java b/src/main/java/com/p4square/grow/frontend/FeedResource.java new file mode 100644 index 0000000..13d0fa0 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/FeedResource.java @@ -0,0 +1,101 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.io.IOException; + +import org.restlet.data.Form; +import org.restlet.data.Status; +import org.restlet.representation.Representation; +import org.restlet.resource.ServerResource; + +import org.apache.log4j.Logger; + +import com.p4square.grow.config.Config; +import com.p4square.grow.model.Message; +import com.p4square.grow.model.UserRecord; + +/** + * This resource handles user interactions with the feed. + */ +public class FeedResource extends ServerResource { + private static final Logger LOG = Logger.getLogger(FeedResource.class); + + private Config mConfig; + + private FeedData mFeedData; + + // Fields pertaining to this request. + protected String mTopic; + protected String mThread; + + @Override + public void doInit() { + super.doInit(); + + GrowFrontend growFrontend = (GrowFrontend) getApplication(); + mConfig = growFrontend.getConfig(); + + mFeedData = new FeedData(getContext(), mConfig); + + mTopic = getAttribute("topic"); + if (mTopic != null) { + mTopic = mTopic.trim(); + } + + mThread = getAttribute("thread"); + if (mThread != null) { + mThread = mThread.trim(); + } + } + + /** + * Create a new MessageThread. + */ + @Override + protected Representation post(Representation entity) { + try { + if (mTopic == null || mTopic.length() == 0 || !FeedData.TOPICS.contains(mTopic)) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return ErrorPage.NOT_FOUND; + } + + Form form = new Form(entity); + + String question = form.getFirstValue("question"); + + Message message = new Message(); + message.setMessage(question); + + UserRecord user = new UserRecord(getRequest().getClientInfo().getUser()); + message.setAuthor(user); + + if (mThread != null && mThread.length() != 0) { + // Post a response + mFeedData.createResponse(mTopic, mThread, message); + + } else { + // Post a new thread + mFeedData.createThread(mTopic, message); + } + + /* + * Can't trust the referrer, so we'll send them to the + * appropriate part of the training page + * TODO: This could be better done. + */ + String nextPage = mConfig.getString("dynamicRoot", ""); + nextPage += "/account/training/" + mTopic; + getResponse().redirectSeeOther(nextPage); + return null; + + } catch (IOException e) { + LOG.fatal("Could not save message: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.BACKEND_ERROR; + + } + } +} diff --git a/src/main/java/com/p4square/grow/frontend/GroupLeaderTrainingPageResource.java b/src/main/java/com/p4square/grow/frontend/GroupLeaderTrainingPageResource.java new file mode 100644 index 0000000..3ab140e --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/GroupLeaderTrainingPageResource.java @@ -0,0 +1,26 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +/** + * Display the Group Leader training videos. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class GroupLeaderTrainingPageResource extends TrainingPageResource { + private static final String[] CHAPTERS = { "leader" }; + + @Override + public void doInit() { + super.doInit(); + + mChapter = "leader"; + } + + @Override + public String[] getChaptersInOrder() { + return CHAPTERS; + } +} diff --git a/src/main/java/com/p4square/grow/frontend/GrowFrontend.java b/src/main/java/com/p4square/grow/frontend/GrowFrontend.java new file mode 100644 index 0000000..b5f62fb --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/GrowFrontend.java @@ -0,0 +1,230 @@ +/* + * Copyright 2013 Jesse Morgan <jesse@jesterpm.net> + */ + +package com.p4square.grow.frontend; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; + +import freemarker.template.Template; + +import org.restlet.Application; +import org.restlet.Component; +import org.restlet.Context; +import org.restlet.Restlet; +import org.restlet.data.Protocol; +import org.restlet.resource.Directory; +import org.restlet.routing.Redirector; +import org.restlet.routing.Router; +import org.restlet.security.Authenticator; + +import com.codahale.metrics.MetricRegistry; + +import org.apache.log4j.Logger; + +import com.p4square.fmfacade.FMFacade; +import com.p4square.fmfacade.FreeMarkerPageResource; + +import com.p4square.grow.config.Config; + +import com.p4square.restlet.metrics.MetricRouter; + +import com.p4square.session.SessionCheckingAuthenticator; +import com.p4square.session.SessionCreatingAuthenticator; +import org.restlet.security.Verifier; + +/** + * This is the Restlet Application implementing the Grow project front-end. + * It's implemented as an extension of FMFacade that connects interactive pages + * with various ServerResources. This class provides a main method to start a + * Jetty instance for testing. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class GrowFrontend extends FMFacade { + private static Logger LOG = Logger.getLogger(GrowFrontend.class); + + private final Config mConfig; + private final MetricRegistry mMetricRegistry; + + private IntegrationDriver mIntegrationFactory; + + public GrowFrontend() { + this(new Config(), new MetricRegistry()); + } + + public GrowFrontend(Config config, MetricRegistry metricRegistry) { + mConfig = config; + mMetricRegistry = metricRegistry; + } + + public Config getConfig() { + return mConfig; + } + + public MetricRegistry getMetrics() { + return mMetricRegistry; + } + + @Override + public synchronized void start() throws Exception { + Template errorTemplate = getTemplate("templates/error.ftl"); + if (errorTemplate != null) { + ErrorPage.setTemplate(errorTemplate, + FreeMarkerPageResource.baseRootObject(getContext(), this)); + } + + getContext().getAttributes().put("com.p4square.grow.config", mConfig); + getContext().getAttributes().put("com.p4square.grow.metrics", mMetricRegistry); + + super.start(); + } + + public synchronized IntegrationDriver getThirdPartyIntegrationFactory() { + if (mIntegrationFactory == null) { + final String driverClassName = getConfig().getString("integrationDriver", + "com.p4square.f1oauth.FellowshipOneIntegrationDriver"); + try { + Class<?> clazz = Class.forName(driverClassName); + Constructor<?> constructor = clazz.getConstructor(Context.class); + mIntegrationFactory = (IntegrationDriver) constructor.newInstance(getContext()); + } catch (Exception e) { + LOG.error("Failed to instantiate IntegrationDriver " + driverClassName); + } + } + + return mIntegrationFactory; + } + + @Override + protected Router createRouter() { + Router router = new MetricRouter(getContext(), mMetricRegistry); + + final Authenticator defaultGuard = new SessionCheckingAuthenticator(getContext(), true); + defaultGuard.setNext(FreeMarkerPageResource.class); + router.attachDefault(defaultGuard); + router.attach("/", new Redirector(getContext(), "index.html", Redirector.MODE_CLIENT_PERMANENT)); + router.attach("/login.html", LoginPageResource.class); + router.attach("/newaccount.html", NewAccountResource.class); + router.attach("/newbeliever", NewBelieverResource.class); + + final Router accountRouter = new MetricRouter(getContext(), mMetricRegistry); + accountRouter.attach("/authenticate", AuthenticatedResource.class); + accountRouter.attach("/logout", LogoutResource.class); + + accountRouter.attach("", AccountRedirectResource.class); + accountRouter.attach("/assessment/question/{questionId}", SurveyPageResource.class); + accountRouter.attach("/assessment/results", AssessmentResultsPage.class); + accountRouter.attach("/assessment/reset", AssessmentResetPage.class); + accountRouter.attach("/assessment", SurveyPageResource.class); + accountRouter.attach("/training/{chapter}/completed", ChapterCompletePage.class); + accountRouter.attach("/training/{chapter}/videos/{videoId}.json", VideosResource.class); + accountRouter.attach("/training/{chapter}", TrainingPageResource.class); + accountRouter.attach("/training", TrainingPageResource.class); + accountRouter.attach("/feed/{topic}", FeedResource.class); + accountRouter.attach("/feed/{topic}/{thread}", FeedResource.class); + + final Authenticator accountGuard = createAuthenticatorChain(accountRouter); + router.attach("/account", accountGuard); + + return router; + } + + private Authenticator createAuthenticatorChain(Restlet last) { + final Context context = getContext(); + final String loginPage = getConfig().getString("dynamicRoot", "") + "/login.html"; + final String loginPost = getConfig().getString("dynamicRoot", "") + "/account/authenticate"; + final String defaultPage = getConfig().getString("dynamicRoot", "") + "/account"; + + // This is used to check for an existing session + SessionCheckingAuthenticator sessionChk = new SessionCheckingAuthenticator(context, true); + + // This is used to authenticate the user + Verifier verifier = getThirdPartyIntegrationFactory().newUserAuthenticationVerifier(); + LoginFormAuthenticator loginAuth = new LoginFormAuthenticator(context, false, verifier); + loginAuth.setLoginFormUrl(loginPage); + loginAuth.setLoginPostUrl(loginPost); + loginAuth.setDefaultPage(defaultPage); + + // This is used to create a new session for a newly authenticated user. + SessionCreatingAuthenticator sessionCreate = new SessionCreatingAuthenticator(context); + + sessionChk.setNext(loginAuth); + loginAuth.setNext(sessionCreate); + + sessionCreate.setNext(last); + + return sessionChk; + } + + /** + * Stand-alone main for testing. + */ + public static void main(String[] args) { + // Start the HTTP Server + final Component component = new Component(); + component.getServers().add(Protocol.HTTP, 8085); + component.getClients().add(Protocol.HTTP); + component.getClients().add(Protocol.HTTPS); + component.getClients().add(Protocol.FILE); + //component.getClients().add(new Client(null, Arrays.asList(Protocol.HTTPS), "org.restlet.ext.httpclient.HttpClientHelper")); + + // Static content + try { + component.getDefaultHost().attach("/images/", new FileServingApp("./build/root/images/")); + component.getDefaultHost().attach("/scripts", new FileServingApp("./build/root/scripts")); + component.getDefaultHost().attach("/style.css", new FileServingApp("./build/root/style.css")); + component.getDefaultHost().attach("/favicon.ico", new FileServingApp("./build/root/favicon.ico")); + component.getDefaultHost().attach("/notfound.html", new FileServingApp("./build/root/notfound.html")); + component.getDefaultHost().attach("/error.html", new FileServingApp("./build/root/error.html")); + } catch (IOException e) { + LOG.error("Could not create directory for static resources: " + + e.getMessage(), e); + } + + // Setup App + GrowFrontend app = new GrowFrontend(); + + // Load an optional config file from the first argument. + app.getConfig().setDomain("dev"); + if (args.length == 1) { + app.getConfig().updateConfig(args[0]); + } + + component.getDefaultHost().attach(app); + + // Setup shutdown hook + Runtime.getRuntime().addShutdownHook(new Thread() { + public void run() { + try { + component.stop(); + } catch (Exception e) { + LOG.error("Exception during cleanup", e); + } + } + }); + + LOG.info("Starting server..."); + + try { + component.start(); + } catch (Exception e) { + LOG.fatal("Could not start: " + e.getMessage(), e); + } + } + + private static class FileServingApp extends Application { + private final String mPath; + + public FileServingApp(String path) throws IOException { + mPath = new File(path).getAbsolutePath(); + } + + @Override + public Restlet createInboundRoot() { + return new Directory(getContext(), "file://" + mPath); + } + } +} diff --git a/src/main/java/com/p4square/grow/frontend/IntegrationDriver.java b/src/main/java/com/p4square/grow/frontend/IntegrationDriver.java new file mode 100644 index 0000000..b9c3508 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/IntegrationDriver.java @@ -0,0 +1,26 @@ +package com.p4square.grow.frontend; + +import org.restlet.security.Verifier; + +/** + * An IntegrationDriver is used to create implementations of various objects + * used to integration Grow with a particular Church Management System. + */ +public interface IntegrationDriver { + + /** + * Create a new Restlet Verifier to authenticate users when they login to the site. + * + * @return A Verifier. + */ + Verifier newUserAuthenticationVerifier(); + + /** + * Return a ProgressReporter for this Church Management System. + * + * The ProgressReporter should be thread-safe. + * + * @return The ProgressReporter. + */ + ProgressReporter getProgressReporter(); +} diff --git a/src/main/java/com/p4square/grow/frontend/JsonRequestProvider.java b/src/main/java/com/p4square/grow/frontend/JsonRequestProvider.java new file mode 100644 index 0000000..bf3b2b3 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/JsonRequestProvider.java @@ -0,0 +1,96 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.io.IOException; + +import com.fasterxml.jackson.databind.JavaType; + +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.data.Method; +import org.restlet.data.Status; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; + +import com.p4square.grow.provider.Provider; +import com.p4square.grow.provider.JsonEncodedProvider; + +/** + * Fetch a JSON object via a Request. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class JsonRequestProvider<V> extends JsonEncodedProvider<V> implements Provider<String, V> { + + private final Restlet mDispatcher; + + public JsonRequestProvider(Restlet dispatcher, Class<V> clazz) { + super(clazz); + + mDispatcher = dispatcher; + } + + public JsonRequestProvider(Restlet dispatcher, JavaType type) { + super(type); + + mDispatcher = dispatcher; + } + + @Override + public V get(String url) throws IOException { + Request request = new Request(Method.GET, url); + Response response = mDispatcher.handle(request); + Representation representation = response.getEntity(); + + if (!response.getStatus().isSuccess()) { + if (representation != null) { + representation.release(); + } + + if (Status.CLIENT_ERROR_NOT_FOUND.equals(response.getStatus())) { + throw new NotFoundException("Could not get object. " + response.getStatus()); + } else { + throw new IOException("Could not get object. " + response.getStatus()); + } + } + + return decode(representation.getText()); + } + + @Override + public void put(String url, V obj) throws IOException { + final Request request = new Request(Method.PUT, url); + request.setEntity(new StringRepresentation(encode(obj))); + + final Response response = mDispatcher.handle(request); + + if (!response.getStatus().isSuccess()) { + throw new IOException("Could not put object. " + response.getStatus()); + } + } + + /** + * Variant of put() which makes a POST request to the url. + * + * This method may eventually be incorporated into Provider for + * creating new objects with auto-generated IDs. + * + * @param url The url to make the request to. + * @param obj The post to post. + * @throws IOException on failure. + */ + public void post(String url, V obj) throws IOException { + final Request request = new Request(Method.POST, url); + request.setEntity(new StringRepresentation(encode(obj))); + + final Response response = mDispatcher.handle(request); + + if (!response.getStatus().isSuccess()) { + throw new IOException("Could not put object. " + response.getStatus()); + } + } +} diff --git a/src/main/java/com/p4square/grow/frontend/LoginFormAuthenticator.java b/src/main/java/com/p4square/grow/frontend/LoginFormAuthenticator.java new file mode 100644 index 0000000..21c9097 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/LoginFormAuthenticator.java @@ -0,0 +1,146 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import org.apache.log4j.Logger; + +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.ChallengeResponse; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.Form; +import org.restlet.data.Method; +import org.restlet.data.Reference; +import org.restlet.security.Authenticator; +import org.restlet.security.Verifier; + +/** + * LoginFormAuthenticator changes + * + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class LoginFormAuthenticator extends Authenticator { + private static final Logger LOG = Logger.getLogger(LoginFormAuthenticator.class); + + private final Verifier mVerifier; + + private String mLoginPage = "/login.html"; + private String mLoginPostUrl = "/authenticate"; + private String mDefaultRedirect = "/index.html"; + + public LoginFormAuthenticator(Context context, boolean optional, Verifier verifier) { + super(context, false, optional, null); + + mVerifier = verifier; + } + + public void setLoginFormUrl(String url) { + mLoginPage = url; + } + + public void setLoginPostUrl(String url) { + mLoginPostUrl = url; + } + + public void setDefaultPage(String url) { + mDefaultRedirect = url; + } + + @Override + protected int beforeHandle(Request request, Response response) { + if (!isLoginAttempt(request) && request.getClientInfo().isAuthenticated()) { + // TODO: Logout + LOG.debug("Already authenticated. Skipping"); + return CONTINUE; + + } else { + return super.beforeHandle(request, response); + } + } + + + @Override + protected boolean authenticate(Request request, Response response) { + boolean isLoginAttempt = isLoginAttempt(request); + + Form query = request.getOriginalRef().getQueryAsForm(); + String redirect = query.getFirstValue("redirect"); + if (redirect == null || redirect.length() == 0) { + if (isLoginAttempt) { + redirect = mDefaultRedirect; + } else { + redirect = request.getResourceRef().getPath(); + } + } + + boolean authenticationFailed = false; + + if (isLoginAttempt) { + LOG.debug("Attempting authentication"); + + // Process login form + final Form form = new Form(request.getEntity()); + final String email = form.getFirstValue("email"); + final String password = form.getFirstValue("password"); + + boolean authenticated = false; + + if (email != null && !"".equals(email) && + password != null && !"".equals(password)) { + + LOG.debug("Got login request from " + email); + + request.setChallengeResponse( + new ChallengeResponse(ChallengeScheme.HTTP_BASIC, email, password.toCharArray())); + + // We expect the verifier to setup the User object. + int result = mVerifier.verify(request, response); + if (result == Verifier.RESULT_VALID) { + return true; + } + } + + authenticationFailed = true; + } + + if (!isOptional() || authenticationFailed) { + Reference ref = new Reference(mLoginPage); + ref.addQueryParameter("redirect", redirect); + + if (authenticationFailed) { + ref.addQueryParameter("retry", "t"); + } + + LOG.debug("Redirecting to " + ref); + response.redirectSeeOther(ref.toString()); + } + LOG.debug("Failing authentication."); + return false; + } + + @Override + protected int authenticated(Request request, Response response) { + super.authenticated(request, response); + + Form query = request.getOriginalRef().getQueryAsForm(); + String redirect = query.getFirstValue("redirect"); + if (redirect == null || redirect.length() == 0) { + redirect = mDefaultRedirect; + } + + // TODO: Ensure redirect is a relative url. + LOG.debug("Redirecting to " + redirect); + response.redirectSeeOther(redirect); + + return CONTINUE; + } + + private boolean isLoginAttempt(Request request) { + String requestPath = request.getResourceRef().getPath(); + return request.getMethod() == Method.POST && mLoginPostUrl.equals(requestPath); + } +} diff --git a/src/main/java/com/p4square/grow/frontend/LoginPageResource.java b/src/main/java/com/p4square/grow/frontend/LoginPageResource.java new file mode 100644 index 0000000..38eba07 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/LoginPageResource.java @@ -0,0 +1,77 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.util.Map; + +import freemarker.template.Template; + +import org.restlet.data.Form; +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.resource.ServerResource; +import org.restlet.representation.Representation; +import org.restlet.ext.freemarker.TemplateRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.fmfacade.FreeMarkerPageResource; + +/** + * LoginPageResource presents a login page template and processes the response. + * Upon successful authentication, the user is redirected to another page and + * a cookie is set. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class LoginPageResource extends FreeMarkerPageResource { + private static Logger cLog = Logger.getLogger(LoginPageResource.class); + + private GrowFrontend mGrowFrontend; + + private String mErrorMessage; + + @Override + public void doInit() { + super.doInit(); + + mGrowFrontend = (GrowFrontend) getApplication(); + + mErrorMessage = null; + } + + /** + * Return the login page. + */ + @Override + protected Representation get() { + Template t = mGrowFrontend.getTemplate("pages/login.html.ftl"); + + try { + if (t == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + + Map<String, Object> root = getRootObject(); + + Form query = getRequest().getOriginalRef().getQueryAsForm(); + String redirect = query.getFirstValue("redirect"); + root.put("redirect", redirect); + String retry = query.getFirstValue("retry"); + if ("t".equals(retry)) { + root.put("errorMessage", "Invalid email or password."); + } + + return new TemplateRepresentation(t, root, MediaType.TEXT_HTML); + + } catch (Exception e) { + cLog.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } + +} diff --git a/src/main/java/com/p4square/grow/frontend/LogoutResource.java b/src/main/java/com/p4square/grow/frontend/LogoutResource.java new file mode 100644 index 0000000..e26dcb7 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/LogoutResource.java @@ -0,0 +1,40 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.resource.ServerResource; + +import com.p4square.session.Sessions; + +import com.p4square.grow.config.Config; + +/** + * This Resource removes a user's session and session cookies. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class LogoutResource extends ServerResource { + private Config mConfig; + + @Override + protected void doInit() { + super.doInit(); + + GrowFrontend growFrontend = (GrowFrontend) getApplication(); + mConfig = growFrontend.getConfig(); + } + + @Override + protected Representation get() { + Sessions.getInstance().delete(getRequest(), getResponse()); + + String nextPage = mConfig.getString("dynamicRoot", ""); + nextPage += "/index.html"; + getResponse().redirectSeeOther(nextPage); + return new StringRepresentation("Redirecting to " + nextPage); + } +} diff --git a/src/main/java/com/p4square/grow/frontend/NewAccountResource.java b/src/main/java/com/p4square/grow/frontend/NewAccountResource.java new file mode 100644 index 0000000..5c13017 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/NewAccountResource.java @@ -0,0 +1,135 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.util.Map; + +import com.p4square.f1oauth.FellowshipOneIntegrationDriver; +import freemarker.template.Template; + +import org.restlet.data.Form; +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.ext.freemarker.TemplateRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.f1oauth.F1Access; +import com.p4square.restlet.oauth.OAuthException; + +import com.p4square.fmfacade.FreeMarkerPageResource; + +/** + * This resource creates a new InFellowship account. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class NewAccountResource extends FreeMarkerPageResource { + private static Logger LOG = Logger.getLogger(NewAccountResource.class); + + private GrowFrontend mGrowFrontend; + private F1Access mHelper; + + private String mErrorMessage; + + private String mLoginPageUrl; + private String mVerificationPage; + + @Override + public void doInit() { + super.doInit(); + + mGrowFrontend = (GrowFrontend) getApplication(); + + final IntegrationDriver driver = mGrowFrontend.getThirdPartyIntegrationFactory(); + if (driver instanceof FellowshipOneIntegrationDriver) { + mHelper = ((FellowshipOneIntegrationDriver) driver).getF1Access(); + } else { + LOG.error("NewAccountResource only works with F1!"); + mHelper = null; + } + + mErrorMessage = ""; + + mLoginPageUrl = mGrowFrontend.getConfig().getString("postAccountCreationPage", + getRequest().getRootRef().toString()); + mVerificationPage = mGrowFrontend.getConfig().getString("dynamicRoot", "") + + "/verification.html"; + } + + /** + * Return the login page. + */ + @Override + protected Representation get() { + Template t = mGrowFrontend.getTemplate("pages/newaccount.html.ftl"); + + try { + if (t == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return ErrorPage.TEMPLATE_NOT_FOUND; + } + + Map<String, Object> root = getRootObject(); + if (mErrorMessage.length() > 0) { + root.put("errorMessage", mErrorMessage); + } + + return new TemplateRepresentation(t, root, MediaType.TEXT_HTML); + + } catch (Exception e) { + LOG.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.RENDER_ERROR; + } + } + + @Override + protected Representation post(Representation rep) { + if (mHelper == null) { + mErrorMessage += "F1 support is not enabled! "; + return get(); + } + + Form form = new Form(rep); + + String firstname = form.getFirstValue("firstname"); + String lastname = form.getFirstValue("lastname"); + String email = form.getFirstValue("email"); + + if (isEmpty(firstname)) { + mErrorMessage += "First Name is a required field. "; + } + if (isEmpty(lastname)) { + mErrorMessage += "Last Name is a required field. "; + } + if (isEmpty(email)) { + mErrorMessage += "Email is a required field. "; + } + + if (mErrorMessage.length() > 0) { + return get(); + } + + try { + if (!mHelper.createAccount(firstname, lastname, email, mLoginPageUrl)) { + mErrorMessage = "An account with that address already exists."; + return get(); + } + + getResponse().redirectSeeOther(mVerificationPage); + return new StringRepresentation("Redirecting to " + mVerificationPage); + + } catch (OAuthException e) { + return new ErrorPage(e.getStatus().getDescription()); + } + } + + private boolean isEmpty(String s) { + return s == null || s.trim().length() == 0; + } +} diff --git a/src/main/java/com/p4square/grow/frontend/NewBelieverResource.java b/src/main/java/com/p4square/grow/frontend/NewBelieverResource.java new file mode 100644 index 0000000..8fe078a --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/NewBelieverResource.java @@ -0,0 +1,72 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import freemarker.template.Template; + +import org.restlet.data.CookieSetting; +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.representation.Representation; +import org.restlet.ext.freemarker.TemplateRepresentation; + +import org.apache.log4j.Logger; + +import com.p4square.fmfacade.FreeMarkerPageResource; + +/** + * This resource displays the transitional page between chapters. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class NewBelieverResource extends FreeMarkerPageResource { + private static final Logger LOG = Logger.getLogger(NewBelieverResource.class); + + public static final String COOKIE_NAME = "seeker"; + + private GrowFrontend mGrowFrontend; + + @Override + public void doInit() { + super.doInit(); + + mGrowFrontend = (GrowFrontend) getApplication(); + } + + /** + * Display the New Believer page. + * + * The New Believer page creates a cookie to remember the user, + * explains what's going on, and then asks the user to go to the login + * page. + * + * When the user hits the {@link AccountRedirectResource} the cookie + * is read and the user is moved ahead to the training section. + */ + @Override + protected Representation get() { + Template t = mGrowFrontend.getTemplate("templates/newbeliever.ftl"); + + try { + if (t == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return ErrorPage.TEMPLATE_NOT_FOUND; + } + + // Set the new believer cookie + CookieSetting cookie = new CookieSetting(COOKIE_NAME, "true"); + cookie.setPath("/"); + getRequest().getCookies().add(cookie); + getResponse().getCookieSettings().add(cookie); + + return new TemplateRepresentation(t, getRootObject(), MediaType.TEXT_HTML); + + } catch (Exception e) { + LOG.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.RENDER_ERROR; + } + } +} diff --git a/src/main/java/com/p4square/grow/frontend/NotFoundException.java b/src/main/java/com/p4square/grow/frontend/NotFoundException.java new file mode 100644 index 0000000..dfa2a4c --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/NotFoundException.java @@ -0,0 +1,13 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.io.IOException; + +public class NotFoundException extends IOException { + public NotFoundException(final String message) { + super(message); + } +} diff --git a/src/main/java/com/p4square/grow/frontend/ProgressReporter.java b/src/main/java/com/p4square/grow/frontend/ProgressReporter.java new file mode 100644 index 0000000..2f36832 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/ProgressReporter.java @@ -0,0 +1,30 @@ +package com.p4square.grow.frontend; + +import org.restlet.security.User; + +import java.util.Date; + +/** + * A ProgressReporter is used to record a User's progress in a Church Management System. + */ +public interface ProgressReporter { + + /** + * Report that the User completed the assessment. + * + * @param user The user who completed the assessment. + * @param level The assessment level. + * @param date The completion date. + * @param results Result information (e.g. json of the results). + */ + void reportAssessmentComplete(User user, String level, Date date, String results); + + /** + * Report that the User completed the chapter. + * + * @param user The user who completed the chapter. + * @param chapter The chapter completed. + * @param date The completion date. + */ + void reportChapterComplete(User user, String chapter, Date date); +} diff --git a/src/main/java/com/p4square/grow/frontend/SurveyPageResource.java b/src/main/java/com/p4square/grow/frontend/SurveyPageResource.java new file mode 100644 index 0000000..3575fe3 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/SurveyPageResource.java @@ -0,0 +1,343 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.io.IOException; + +import java.util.Map; +import java.util.HashMap; + +import freemarker.template.Template; + +import org.restlet.data.Form; +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.ext.freemarker.TemplateRepresentation; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.resource.ServerResource; + +import org.apache.log4j.Logger; + +import com.p4square.fmfacade.json.JsonRequestClient; +import com.p4square.fmfacade.json.JsonResponse; +import com.p4square.fmfacade.json.ClientException; + +import com.p4square.fmfacade.FreeMarkerPageResource; + +import com.p4square.grow.config.Config; +import com.p4square.grow.model.Question; +import com.p4square.grow.model.UserRecord; +import com.p4square.grow.provider.DelegateProvider; +import com.p4square.grow.provider.JsonEncodedProvider; +import com.p4square.grow.provider.Provider; + +/** + * SurveyPageResource handles rendering the survey and processing user's answers. + * + * This resource expects the user to be authenticated and the ClientInfo User object + * to be populated. Each question is requested from the backend along with the + * user's previous answer. Each answer is sent to the backend and the user is redirected + * to the next question. After the last question the user is sent to his results. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SurveyPageResource extends FreeMarkerPageResource { + private static final Logger LOG = Logger.getLogger(SurveyPageResource.class); + + private Config mConfig; + private Template mSurveyTemplate; + private JsonRequestClient mJsonClient; + private Provider<String, Question> mQuestionProvider; + private Provider<String, UserRecord> mUserRecordProvider; + + // Fields pertaining to this request. + private String mQuestionId; + private String mUserId; + + @Override + public void doInit() { + super.doInit(); + + GrowFrontend growFrontend = (GrowFrontend) getApplication(); + mConfig = growFrontend.getConfig(); + mSurveyTemplate = growFrontend.getTemplate("templates/survey.ftl"); + if (mSurveyTemplate == null) { + LOG.fatal("Could not find survey template."); + setStatus(Status.SERVER_ERROR_INTERNAL); + } + + mJsonClient = new JsonRequestClient(getContext().getClientDispatcher()); + mQuestionProvider = new DelegateProvider<String, String, Question>( + new JsonRequestProvider<Question>(getContext().getClientDispatcher(), + Question.class)) { + @Override + public String makeKey(String questionId) { + return getBackendEndpoint() + "/assessment/question/" + questionId; + } + }; + + mUserRecordProvider = new DelegateProvider<String, String, UserRecord>( + new JsonRequestProvider<UserRecord>(getContext().getClientDispatcher(), + UserRecord.class)) { + @Override + public String makeKey(String userid) { + return getBackendEndpoint() + "/accounts/" + userid; + } + }; + + mQuestionId = getAttribute("questionId"); + mUserId = getRequest().getClientInfo().getUser().getIdentifier(); + } + + /** + * Return a page with a survey question. + */ + @Override + protected Representation get() { + try { + // Get the current question. + if (mQuestionId == null) { + // Get user's current question + mQuestionId = getCurrentQuestionId(); + + if (mQuestionId != null) { + Question lastQuestion = getQuestion(mQuestionId); + return redirectToNextQuestion(lastQuestion, getAnswer(mQuestionId)); + } + } + + // If we don't have a current question, get the first one. + if (mQuestionId == null) { + mQuestionId = "first"; + } + + Question question = getQuestion(mQuestionId); + if (question == null) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return new ErrorPage("Could not find the question."); + } + + // Set the real question id if a meta-id was used (i.e. first) + mQuestionId = question.getId(); + + // Get any previous answer to the question + String selectedAnswer = getAnswer(mQuestionId); + + Map root = getRootObject(); + root.put("question", question); + root.put("selectedAnswerId", selectedAnswer); + + // Get the question count and compute progress + { + JsonResponse response = backendGet("/assessment/question/count"); + if (response.getStatus().isSuccess()) { + Map countData = response.getMap(); + if (countData != null) { + response = backendGet("/accounts/" + mUserId + "/assessment"); + if (response.getStatus().isSuccess()) { + Integer completed = (Integer) response.getMap().get("totalAnswers"); + Integer total = (Integer) countData.get("count"); + + if (completed != null && total != null && total != 0) { + root.put("percentComplete", String.valueOf((int) (100.0 * completed) / total)); + } + } + } + } + } + + return new TemplateRepresentation(mSurveyTemplate, root, MediaType.TEXT_HTML); + + } catch (Exception e) { + LOG.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.RENDER_ERROR; + } + } + + /** + * Record a survey answer and redirect to the next question. + */ + @Override + protected Representation post(Representation entity) { + final Form form = new Form(entity); + final String answerId = form.getFirstValue("answer"); + final String direction = form.getFirstValue("direction"); + boolean justGoBack = false; // FIXME: Ugly hack + + if (mQuestionId == null || answerId == null || answerId.length() == 0) { + if ("previous".equals(direction)) { + // Just go back + justGoBack = true; + + } else { + // Something is wrong. + setStatus(Status.CLIENT_ERROR_BAD_REQUEST); + return new ErrorPage("Question or answer messing."); + } + } + + try { + // Find the question + Question question = getQuestion(mQuestionId); + if (question == null) { + // User is answering a question which doesn't exist + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return new ErrorPage("Question not found."); + } + + // Store answer + if (!justGoBack) { + Map<String, String> answer = new HashMap<String, String>(); + answer.put("answerId", answerId); + JsonResponse response = backendPut("/accounts/" + mUserId + + "/assessment/answers/" + mQuestionId, answer); + + if (!response.getStatus().isSuccess()) { + // Something went wrong talking to the backend, error out. + LOG.fatal("Error recording survey answer " + response.getStatus()); + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.BACKEND_ERROR; + } + } + + // Find the next question or finish the assessment. + if ("previous".equals(direction)) { + return redirectToPreviousQuestion(question); + + } else { + return redirectToNextQuestion(question, answerId); + } + + } catch (Exception e) { + LOG.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.RENDER_ERROR; + } + } + + private Question getQuestion(String id) { + try { + return mQuestionProvider.get(id); + + } catch (IOException e) { + LOG.warn("Error fetching question.", e); + return null; + } + } + + private String getAnswer(String questionId) { + try { + JsonResponse response = backendGet("/accounts/" + mUserId + "/assessment/answers/" + questionId); + if (response.getStatus().isSuccess()) { + return (String) response.getMap().get("answerId"); + } + + } catch (ClientException e) { + LOG.warn("Error fetching answer to question " + questionId, e); + } + + return null; + } + + private Representation redirectToNextQuestion(Question question, String answerid) { + String nextQuestionId = question.getNextQuestion(answerid); + + if (nextQuestionId == null) { + // Just finished the last question. Update the user's account + try { + UserRecord account = null; + try { + account = mUserRecordProvider.get(mUserId); + } catch (NotFoundException e) { + // User record doesn't exist, so create a new one. + account = new UserRecord(getRequest().getClientInfo().getUser()); + } + account.setLanding("training"); + mUserRecordProvider.put(mUserId, account); + } catch (IOException e) { + LOG.warn("IOException updating landing for " + mUserId, e); + } + + String nextPage = mConfig.getString("dynamicRoot", ""); + nextPage += "/account/assessment/results"; + getResponse().redirectSeeOther(nextPage); + return new StringRepresentation("Redirecting to " + nextPage); + } + + return redirectToQuestion(nextQuestionId); + } + + private Representation redirectToPreviousQuestion(Question question) { + String nextQuestionId = question.getPreviousQuestion(); + + if (nextQuestionId == null) { + nextQuestionId = (String) question.getId(); + } + + return redirectToQuestion(nextQuestionId); + } + + private Representation redirectToQuestion(String id) { + String nextPage = mConfig.getString("dynamicRoot", ""); + nextPage += "/account/assessment/question/" + id; + getResponse().redirectSeeOther(nextPage); + return new StringRepresentation("Redirecting to " + nextPage); + } + + private String getCurrentQuestionId() { + String id = null; + try { + JsonResponse response = backendGet("/accounts/" + mUserId + "/assessment"); + + if (response.getStatus().isSuccess()) { + return (String) response.getMap().get("lastAnswered"); + + } else { + LOG.warn("Failed to get assessment results: " + response.getStatus()); + } + + } catch (ClientException e) { + LOG.error("Exception getting assessment results.", e); + } + + return null; + } + + /** + * @return The backend endpoint URI + */ + private String getBackendEndpoint() { + return mConfig.getString("backendUri", "riap://component/backend"); + } + + /** + * Helper method to send a GET to the backend. + */ + private JsonResponse backendGet(final String uri) { + LOG.debug("Sending backend GET " + uri); + + final JsonResponse response = mJsonClient.get(getBackendEndpoint() + uri); + final Status status = response.getStatus(); + if (!status.isSuccess() && !Status.CLIENT_ERROR_NOT_FOUND.equals(status)) { + LOG.warn("Error making backend request for '" + uri + "'. status = " + response.getStatus().toString()); + } + + return response; + } + + protected JsonResponse backendPut(final String uri, final Map data) { + LOG.debug("Sending backend PUT " + uri); + + final JsonResponse response = mJsonClient.put(getBackendEndpoint() + uri, data); + final Status status = response.getStatus(); + if (!status.isSuccess() && !Status.CLIENT_ERROR_NOT_FOUND.equals(status)) { + LOG.warn("Error making backend request for '" + uri + "'. status = " + response.getStatus().toString()); + } + + return response; + } +} diff --git a/src/main/java/com/p4square/grow/frontend/TrainingPageResource.java b/src/main/java/com/p4square/grow/frontend/TrainingPageResource.java new file mode 100644 index 0000000..a1e7789 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/TrainingPageResource.java @@ -0,0 +1,268 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import freemarker.template.Template; + +import org.restlet.data.CookieSetting; +import org.restlet.data.Form; +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.ext.freemarker.TemplateRepresentation; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.resource.ServerResource; + +import org.apache.log4j.Logger; + +import com.p4square.fmfacade.json.JsonRequestClient; +import com.p4square.fmfacade.json.JsonResponse; + +import com.p4square.fmfacade.FreeMarkerPageResource; + +import com.p4square.grow.config.Config; +import com.p4square.grow.model.TrainingRecord; +import com.p4square.grow.model.VideoRecord; +import com.p4square.grow.model.Playlist; +import com.p4square.grow.provider.TrainingRecordProvider; +import com.p4square.grow.provider.Provider; + +/** + * TrainingPageResource handles rendering the training page. + * + * This resource expects the user to be authenticated and the ClientInfo User object + * to be populated. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class TrainingPageResource extends FreeMarkerPageResource { + private static final Logger LOG = Logger.getLogger(TrainingPageResource.class); + + private static final String[] CHAPTERS = { "introduction", "seeker", "believer", "disciple", "teacher", "leader" }; + private static final Comparator<Map<String, Object>> VIDEO_COMPARATOR = new Comparator<Map<String, Object>>() { + @Override + public int compare(Map<String, Object> left, Map<String, Object> right) { + String leftNumberStr = (String) left.get("number"); + String rightNumberStr = (String) right.get("number"); + + if (leftNumberStr == null || rightNumberStr == null) { + return -1; + } + + double leftNumber = Double.valueOf(leftNumberStr); + double rightNumber = Double.valueOf(rightNumberStr); + + return Double.compare(leftNumber, rightNumber); + } + }; + + private Config mConfig; + private Template mTrainingTemplate; + private JsonRequestClient mJsonClient; + + private Provider<String, TrainingRecord> mTrainingRecordProvider; + private FeedData mFeedData; + + // Fields pertaining to this request. + protected String mChapter; + protected String mUserId; + + @Override + public void doInit() { + super.doInit(); + + GrowFrontend growFrontend = (GrowFrontend) getApplication(); + mConfig = growFrontend.getConfig(); + mTrainingTemplate = growFrontend.getTemplate("templates/training.ftl"); + if (mTrainingTemplate == null) { + LOG.fatal("Could not find training template."); + setStatus(Status.SERVER_ERROR_INTERNAL); + } + + mJsonClient = new JsonRequestClient(getContext().getClientDispatcher()); + mTrainingRecordProvider = new TrainingRecordProvider<String>(new JsonRequestProvider<TrainingRecord>(getContext().getClientDispatcher(), TrainingRecord.class)) { + @Override + public String makeKey(String userid) { + return getBackendEndpoint() + "/accounts/" + userid + "/training"; + } + }; + + mFeedData = new FeedData(getContext(), mConfig); + + mChapter = getAttribute("chapter"); + mUserId = getRequest().getClientInfo().getUser().getIdentifier(); + } + + /** + * Return a page of videos. + */ + @Override + protected Representation get() { + try { + // Get the training summary + TrainingRecord trainingRecord = mTrainingRecordProvider.get(mUserId); + if (trainingRecord == null) { + setStatus(Status.SERVER_ERROR_INTERNAL); + return new ErrorPage("Could not retrieve TrainingRecord."); + } + + Playlist playlist = trainingRecord.getPlaylist(); + Map<String, Boolean> chapters = playlist.getChapterStatuses(); + Map<String, Boolean> allowedChapters = new LinkedHashMap<String, Boolean>(); + + // The user is not allowed to view chapters after his highest completed chapter. + // In this loop we find which chapters are allowed and check if the user tried + // to skip ahead. + boolean allowUserToSkip = mConfig.getBoolean("allowUserToSkip", false) || getQueryValue("magicskip") != null; + String defaultChapter = null; + boolean userTriedToSkip = false; + int overallProgress = 0; + + boolean foundRequired = false; + for (String chapterId : getChaptersInOrder()) { + boolean allowed = true; + + Boolean completed = chapters.get(chapterId); + if (completed != null) { + if (!foundRequired) { + if (!completed) { + // The first incomplete chapter is the highest allowed chapter. + foundRequired = true; + defaultChapter = chapterId; + } + + } else { + allowed = allowUserToSkip; + + if (!allowUserToSkip && chapterId.equals(mChapter)) { + userTriedToSkip = true; + } + } + + allowedChapters.put(chapterId, allowed); + + if (completed) { + overallProgress++; + } + } + } + + // Overall progress is the percentage of chapters complete + overallProgress = (int) ((double) overallProgress / getChaptersInOrder().length * 100); + + if (defaultChapter == null) { + // Everything is completed... send them back to introduction. + defaultChapter = "introduction"; + } + + if (mChapter == null || userTriedToSkip) { + // No chapter was specified or the user tried to skip ahead. + // Either case, redirect. + String nextPage = mConfig.getString("dynamicRoot", ""); + nextPage += "/account/training/" + defaultChapter; + getResponse().redirectSeeOther(nextPage); + return new StringRepresentation("Redirecting to " + nextPage); + } + + + // Get videos for the chapter. + List<Map<String, Object>> videos = null; + { + JsonResponse response = backendGet("/training/" + mChapter); + if (!response.getStatus().isSuccess()) { + setStatus(Status.CLIENT_ERROR_NOT_FOUND); + return null; + } + videos = (List<Map<String, Object>>) response.getMap().get("videos"); + Collections.sort(videos, VIDEO_COMPARATOR); + } + + // Mark the completed videos as completed + int chapterProgress = 0; + for (Map<String, Object> video : videos) { + boolean completed = false; + VideoRecord record = playlist.find((String) video.get("id")); + LOG.info("VideoId: " + video.get("id")); + if (record != null) { + LOG.info("VideoRecord: " + record.getComplete()); + completed = record.getComplete(); + } + video.put("completed", completed); + + if (completed) { + chapterProgress++; + } + } + chapterProgress = chapterProgress * 100 / videos.size(); + + Map root = getRootObject(); + root.put("chapter", mChapter); + root.put("chapters", allowedChapters.keySet()); + root.put("isChapterAllowed", allowedChapters); + root.put("chapterProgress", chapterProgress); + root.put("overallProgress", overallProgress); + root.put("videos", videos); + root.put("allowUserToSkip", allowUserToSkip); + + // Determine if we should show the feed. + boolean showfeed = true; + + // Don't show the feed if the topic isn't allowed. + if (!FeedData.TOPICS.contains(mChapter)) { + showfeed = false; + } + + root.put("showfeed", showfeed); + if (showfeed) { + root.put("feeddata", mFeedData); + } + + return new TemplateRepresentation(mTrainingTemplate, root, MediaType.TEXT_HTML); + + } catch (Exception e) { + LOG.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.RENDER_ERROR; + } + } + + /** + * This method returns a list of chapters in the correct order. + */ + protected String[] getChaptersInOrder() { + return CHAPTERS; + } + + /** + * @return The backend endpoint URI + */ + private String getBackendEndpoint() { + return mConfig.getString("backendUri", "riap://component/backend"); + } + + /** + * Helper method to send a GET to the backend. + */ + private JsonResponse backendGet(final String uri) { + LOG.debug("Sending backend GET " + uri); + + final JsonResponse response = mJsonClient.get(getBackendEndpoint() + uri); + final Status status = response.getStatus(); + if (!status.isSuccess() && !Status.CLIENT_ERROR_NOT_FOUND.equals(status)) { + LOG.warn("Error making backend request for '" + uri + "'. status = " + response.getStatus().toString()); + } + + return response; + } + +} diff --git a/src/main/java/com/p4square/grow/frontend/VideosResource.java b/src/main/java/com/p4square/grow/frontend/VideosResource.java new file mode 100644 index 0000000..2099a77 --- /dev/null +++ b/src/main/java/com/p4square/grow/frontend/VideosResource.java @@ -0,0 +1,133 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.frontend; + +import java.util.HashMap; +import java.util.Map; + +import freemarker.template.Template; + +import org.restlet.data.Form; +import org.restlet.data.MediaType; +import org.restlet.data.Status; +import org.restlet.ext.jackson.JacksonRepresentation; +import org.restlet.representation.Representation; +import org.restlet.resource.ServerResource; + +import org.apache.log4j.Logger; + +import com.p4square.fmfacade.json.JsonRequestClient; +import com.p4square.fmfacade.json.JsonResponse; + +import com.p4square.grow.config.Config; + +/** + * VideosResource returns JSON blobs with video information and records watched + * videos. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class VideosResource extends ServerResource { + private static Logger cLog = Logger.getLogger(VideosResource.class); + + private Config mConfig; + private JsonRequestClient mJsonClient; + + // Fields pertaining to this request. + private String mChapter; + private String mVideoId; + private String mUserId; + + @Override + public void doInit() { + super.doInit(); + + GrowFrontend growFrontend = (GrowFrontend) getApplication(); + mConfig = growFrontend.getConfig(); + + mJsonClient = new JsonRequestClient(getContext().getClientDispatcher()); + + mChapter = getAttribute("chapter"); + mVideoId = getAttribute("videoId"); + mUserId = getRequest().getClientInfo().getUser().getIdentifier(); + } + + /** + * Fetch a video record from the backend. + */ + @Override + protected Representation get() { + try { + JsonResponse response = backendGet("/training/" + mChapter + "/videos/" + mVideoId); + + if (response.getStatus().isSuccess()) { + return new JacksonRepresentation<Map>(response.getMap()); + + } else { + setStatus(response.getStatus()); + return null; + } + + } catch (Exception e) { + cLog.fatal("Could not render page: " + e.getMessage(), e); + setStatus(Status.SERVER_ERROR_INTERNAL); + return null; + } + } + + /** + * Mark a video as completed. + */ + @Override + protected Representation post(Representation entity) { + Map<String, Object> data = new HashMap<String, Object>(); + data.put("complete", "true"); + JsonResponse response = backendPut("/accounts/" + mUserId + "/training/videos/" + mVideoId, data); + + if (!response.getStatus().isSuccess()) { + // Something went wrong talking to the backend, error out. + cLog.fatal("Error recording completed video " + response.getStatus()); + setStatus(Status.SERVER_ERROR_INTERNAL); + return ErrorPage.BACKEND_ERROR; + } + + setStatus(Status.SUCCESS_NO_CONTENT); + return null; + } + + /** + * @return The backend endpoint URI + */ + private String getBackendEndpoint() { + return mConfig.getString("backendUri", "riap://component/backend"); + } + + /** + * Helper method to send a GET to the backend. + */ + private JsonResponse backendGet(final String uri) { + cLog.debug("Sending backend GET " + uri); + + final JsonResponse response = mJsonClient.get(getBackendEndpoint() + uri); + final Status status = response.getStatus(); + if (!status.isSuccess() && !Status.CLIENT_ERROR_NOT_FOUND.equals(status)) { + cLog.warn("Error making backend request for '" + uri + "'. status = " + response.getStatus().toString()); + } + + return response; + } + + private JsonResponse backendPut(final String uri, final Map data) { + cLog.debug("Sending backend PUT " + uri); + + final JsonResponse response = mJsonClient.put(getBackendEndpoint() + uri, data); + final Status status = response.getStatus(); + if (!status.isSuccess() && !Status.CLIENT_ERROR_NOT_FOUND.equals(status)) { + cLog.warn("Error making backend request for '" + uri + "'. status = " + response.getStatus().toString()); + } + + return response; + } +} diff --git a/src/main/java/com/p4square/grow/model/Answer.java b/src/main/java/com/p4square/grow/model/Answer.java new file mode 100644 index 0000000..a818365 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/Answer.java @@ -0,0 +1,142 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +import org.apache.log4j.Logger; + +/** + * This is the model of an assessment question's answer. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class Answer { + private static final Logger LOG = Logger.getLogger(Answer.class); + + /** + * ScoreType determines how the answer will be scored. + * + */ + public static enum ScoreType { + /** + * This question has no effect on the score. + */ + NONE, + + /** + * The score of this question is part of the average. + */ + AVERAGE, + + /** + * The score of this question is the total score, no other questions + * matter after this point. + */ + TRUMP; + + @Override + public String toString() { + return name().toLowerCase(); + } + } + + private String mAnswerText; + private ScoreType mType; + private float mScoreFactor; + private String mNextQuestionId; + + public Answer() { + mType = ScoreType.AVERAGE; + } + + /** + * @return The text associated with the answer. + */ + public String getText() { + return mAnswerText; + } + + /** + * Set the text associated with the answer. + * @param text The new text. + */ + public void setText(String text) { + mAnswerText = text; + } + + /** + * @return the ScoreType for the Answer. + */ + public ScoreType getType() { + return mType; + } + + /** + * Set the ScoreType for the answer. + * @param type The new ScoreType. + */ + public void setType(ScoreType type) { + mType = type; + } + + /** + * @return the delta of the score if this answer is selected. + */ + public float getScore() { + if (mType == ScoreType.NONE) { + return 0; + } + + return mScoreFactor; + } + + /** + * Set the score delta for this answer. + * @param score The new delta. + */ + public void setScore(float score) { + mScoreFactor = score; + } + + /** + * @return the id of the next question if this answer is selected, or null + * if selecting this answer has no effect. + */ + public String getNextQuestion() { + return mNextQuestionId; + } + + /** + * Set the id of the next question when this answer is selected. + * @param id The next question id or null to proceed as usual. + */ + public void setNextQuestion(String id) { + mNextQuestionId = id; + } + + /** + * Adjust the running score for the selection of this answer. + * @param score The running score to adjust. + * @return true if scoring should continue, false if this answer trumps all. + */ + public boolean score(final Score score) { + switch (getType()) { + case TRUMP: + score.sum = getScore(); + score.count = 1; + return false; // Quit scoring. + + case AVERAGE: + LOG.debug("ScoreType.AVERAGE: { delta: \"" + getScore() + "\" }"); + score.sum += getScore(); + score.count++; + break; + + case NONE: + break; + } + + return true; // Continue scoring + } +} diff --git a/src/main/java/com/p4square/grow/model/Banner.java b/src/main/java/com/p4square/grow/model/Banner.java new file mode 100644 index 0000000..b786b36 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/Banner.java @@ -0,0 +1,20 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.model; + +/** + * Page Banner Data. + */ +public class Banner { + private String mHtml; + + public String getHtml() { + return mHtml; + } + + public void setHtml(final String html) { + mHtml = html; + } +} diff --git a/src/main/java/com/p4square/grow/model/Chapter.java b/src/main/java/com/p4square/grow/model/Chapter.java new file mode 100644 index 0000000..3a08e4c --- /dev/null +++ b/src/main/java/com/p4square/grow/model/Chapter.java @@ -0,0 +1,112 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Chapter is a list of VideoRecords in a Playlist. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class Chapter implements Cloneable { + private String mName; + private Map<String, VideoRecord> mVideos; + + public Chapter(String name) { + mName = name; + mVideos = new HashMap<String, VideoRecord>(); + } + + /** + * Private constructor for JSON decoding. + */ + private Chapter() { + this(null); + } + + /** + * @return The Chapter name. + */ + public String getName() { + return mName; + } + + /** + * Set the chapter name. + * + * @param name The name of the chapter. + */ + public void setName(final String name) { + mName = name; + } + + /** + * @return The VideoRecord for videoid or null if videoid is not in the chapter. + */ + public VideoRecord getVideoRecord(String videoid) { + return mVideos.get(videoid); + } + + /** + * @return A map of video ids to VideoRecords. + */ + @JsonAnyGetter + public Map<String, VideoRecord> getVideos() { + return mVideos; + } + + /** + * Set the VideoRecord for a video id. + * @param videoId the video id. + * @param video the VideoRecord. + */ + @JsonAnySetter + public void setVideoRecord(String videoId, VideoRecord video) { + mVideos.put(videoId, video); + } + + /** + * Remove the VideoRecord for a video id. + * @param videoId The id to remove. + */ + public void removeVideoRecord(String videoId) { + mVideos.remove(videoId); + } + + /** + * @return true if every required video has been completed. + */ + @JsonIgnore + public boolean isComplete() { + boolean complete = true; + + for (VideoRecord r : mVideos.values()) { + if (r.getRequired() && !r.getComplete()) { + return false; + } + } + + return complete; + } + + /** + * Deeply clone a chapter. + * + * @return a new Chapter object identical but independent of this one. + */ + public Chapter clone() throws CloneNotSupportedException { + Chapter c = new Chapter(mName); + for (Map.Entry<String, VideoRecord> videoEntry : mVideos.entrySet()) { + c.setVideoRecord(videoEntry.getKey(), videoEntry.getValue().clone()); + } + return c; + } +} diff --git a/src/main/java/com/p4square/grow/model/CircleQuestion.java b/src/main/java/com/p4square/grow/model/CircleQuestion.java new file mode 100644 index 0000000..71acc14 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/CircleQuestion.java @@ -0,0 +1,89 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +/** + * Circle Question. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class CircleQuestion extends Question { + private static final ScoringEngine ENGINE = new QuadScoringEngine(); + + private String mTopLeft; + private String mTopRight; + private String mBottomLeft; + private String mBottomRight; + + /** + * @return the Top Left label. + */ + public String getTopLeft() { + return mTopLeft; + } + + /** + * Set the Top Left label. + * @param s The new top left label. + */ + public void setTopLeft(String s) { + mTopLeft = s; + } + + /** + * @return the Top Right label. + */ + public String getTopRight() { + return mTopRight; + } + + /** + * Set the Top Right label. + * @param s The new top left label. + */ + public void setTopRight(String s) { + mTopRight = s; + } + + /** + * @return the Bottom Left label. + */ + public String getBottomLeft() { + return mBottomLeft; + } + + /** + * Set the Bottom Left label. + * @param s The new top left label. + */ + public void setBottomLeft(String s) { + mBottomLeft = s; + } + + /** + * @return the Bottom Right label. + */ + public String getBottomRight() { + return mBottomRight; + } + + /** + * Set the Bottom Right label. + * @param s The new top left label. + */ + public void setBottomRight(String s) { + mBottomRight = s; + } + + @Override + public boolean scoreAnswer(Score score, RecordedAnswer answer) { + return ENGINE.scoreAnswer(score, this, answer); + } + + @Override + public QuestionType getType() { + return QuestionType.CIRCLE; + } +} diff --git a/src/main/java/com/p4square/grow/model/ImageQuestion.java b/src/main/java/com/p4square/grow/model/ImageQuestion.java new file mode 100644 index 0000000..d94c32c --- /dev/null +++ b/src/main/java/com/p4square/grow/model/ImageQuestion.java @@ -0,0 +1,24 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +/** + * Image Question. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class ImageQuestion extends Question { + private static final ScoringEngine ENGINE = new SimpleScoringEngine(); + + @Override + public boolean scoreAnswer(Score score, RecordedAnswer answer) { + return ENGINE.scoreAnswer(score, this, answer); + } + + @Override + public QuestionType getType() { + return QuestionType.IMAGE; + } +} diff --git a/src/main/java/com/p4square/grow/model/Message.java b/src/main/java/com/p4square/grow/model/Message.java new file mode 100644 index 0000000..9d33320 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/Message.java @@ -0,0 +1,103 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +import java.util.Date; +import java.util.UUID; + +/** + * A feed message. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class Message { + private String mThreadId; + private String mId; + private UserRecord mAuthor; + private Date mCreated; + private String mMessage; + + /** + * @return a new message id. + */ + public static String generateId() { + return String.format("%x-%s", System.currentTimeMillis(), UUID.randomUUID().toString()); + } + + /** + * @return The id of the thread that the message belongs to. + */ + public String getThreadId() { + return mThreadId; + } + + /** + * Set the id of the thread that the message belongs to. + * @param id The new thread id. + */ + public void setThreadId(String id) { + mThreadId = id; + } + + /** + * @return The id the message. + */ + public String getId() { + return mId; + } + + /** + * Set the id of the message. + * @param id The new message id. + */ + public void setId(String id) { + mId = id; + } + + /** + * @return The author of the message. + */ + public UserRecord getAuthor() { + return mAuthor; + } + + /** + * Set the author of the message. + * @param author The new author. + */ + public void setAuthor(UserRecord author) { + mAuthor = author; + } + + /** + * @return The Date the message was created. + */ + public Date getCreated() { + return mCreated; + } + + /** + * Set the Date the message was created. + * @param date The new creation date. + */ + public void setCreated(Date date) { + mCreated = date; + } + + /** + * @return The message text. + */ + public String getMessage() { + return mMessage; + } + + /** + * Set the message text. + * @param text The message text. + */ + public void setMessage(String text) { + mMessage = text; + } +} diff --git a/src/main/java/com/p4square/grow/model/MessageThread.java b/src/main/java/com/p4square/grow/model/MessageThread.java new file mode 100644 index 0000000..9542a18 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/MessageThread.java @@ -0,0 +1,60 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +import java.util.UUID; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class MessageThread { + private String mId; + private Message mMessage; + + /** + * Create a new thread with a probably unique id. + * + * @return the new thread. + */ + public static MessageThread createNew() { + MessageThread t = new MessageThread(); + // IDs are keyed to sort lexicographically from latest to oldest. + t.setId(String.format("%016x-%s", Long.MAX_VALUE - System.currentTimeMillis(), + UUID.randomUUID().toString())); + + return t; + } + + /** + * @return The id the message. + */ + public String getId() { + return mId; + } + + /** + * Set the id of the message. + * @param id The new message id. + */ + public void setId(String id) { + mId = id; + } + + /** + * @return The original message. + */ + public Message getMessage() { + return mMessage; + } + + /** + * Set the original message. + * @param id The new message. + */ + public void setMessage(Message message) { + mMessage = message; + } +} diff --git a/src/main/java/com/p4square/grow/model/Playlist.java b/src/main/java/com/p4square/grow/model/Playlist.java new file mode 100644 index 0000000..3e77ada --- /dev/null +++ b/src/main/java/com/p4square/grow/model/Playlist.java @@ -0,0 +1,192 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Representation of a user's playlist. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class Playlist { + /** + * Map of Chapter ID to map of Video ID to VideoRecord. + */ + private Map<String, Chapter> mPlaylist; + + private Date mLastUpdated; + + /** + * Construct an empty playlist. + */ + public Playlist() { + mPlaylist = new HashMap<String, Chapter>(); + mLastUpdated = new Date(0); // Default to a prehistoric date if we don't have one. + } + + /** + * Find the VideoRecord for a video id. + */ + public VideoRecord find(String videoId) { + for (Chapter chapter : mPlaylist.values()) { + VideoRecord r = chapter.getVideoRecord(videoId); + + if (r != null) { + return r; + } + } + + return null; + } + + /** + * @param videoId The video to search for. + * @return the Chapter containing videoId. + */ + private Chapter findChapter(String videoId) { + for (Chapter chapter : mPlaylist.values()) { + VideoRecord r = chapter.getVideoRecord(videoId); + + if (r != null) { + return chapter; + } + } + + return null; + } + + /** + * @return The last modified date of the source playlist. + */ + public Date getLastUpdated() { + return mLastUpdated; + } + + /** + * Set the last updated date. + * @param date the new last updated date. + */ + public void setLastUpdated(Date date) { + mLastUpdated = date; + } + + /** + * Add a video to the playlist. + */ + public VideoRecord add(String chapterId, String videoId) { + Chapter chapter = mPlaylist.get(chapterId); + + if (chapter == null) { + chapter = new Chapter(chapterId); + mPlaylist.put(chapterId, chapter); + } + + VideoRecord r = new VideoRecord(); + chapter.setVideoRecord(videoId, r); + return r; + } + + /** + * Add a Chapter to the Playlist. + * @param chapterId The name of the chapter. + * @param chapter The Chapter object to add. + */ + @JsonAnySetter + public void addChapter(String chapterId, Chapter chapter) { + chapter.setName(chapterId); + mPlaylist.put(chapterId, chapter); + } + + /** + * @return a map of chapter id to chapter. + */ + @JsonAnyGetter + public Map<String, Chapter> getChaptersMap() { + return mPlaylist; + } + + /** + * @return The last chapter to be completed. + */ + @JsonIgnore + public Map<String, Boolean> getChapterStatuses() { + Map<String, Boolean> completed = new HashMap<String, Boolean>(); + + for (Map.Entry<String, Chapter> entry : mPlaylist.entrySet()) { + completed.put(entry.getKey(), entry.getValue().isComplete()); + } + + return completed; + } + + /** + * @return true if all required videos in the chapter have been watched. + */ + public boolean isChapterComplete(String chapterId) { + Chapter chapter = mPlaylist.get(chapterId); + if (chapter != null) { + return chapter.isComplete(); + } + + return false; + } + + /** + * Merge a playlist into this playlist. + * + * Merge is accomplished by adding all missing Chapters and VideoRecords to + * this playlist. + */ + public void merge(Playlist source) { + if (source.getLastUpdated().before(mLastUpdated)) { + // Already up to date. + return; + } + + for (Map.Entry<String, Chapter> entry : source.getChaptersMap().entrySet()) { + String chapterName = entry.getKey(); + Chapter theirChapter = entry.getValue(); + Chapter myChapter = mPlaylist.get(entry.getKey()); + + if (myChapter == null) { + // Add new chapter + myChapter = new Chapter(chapterName); + addChapter(chapterName, myChapter); + } + + // Check chapter for missing videos + for (Map.Entry<String, VideoRecord> videoEntry : theirChapter.getVideos().entrySet()) { + String videoId = videoEntry.getKey(); + VideoRecord myVideo = myChapter.getVideoRecord(videoId); + + if (myVideo == null) { + myVideo = find(videoId); + if (myVideo == null) { + // New Video + try { + myVideo = videoEntry.getValue().clone(); + myChapter.setVideoRecord(videoId, myVideo); + } catch (CloneNotSupportedException e) { + throw new RuntimeException(e); // Unexpected... + } + } else { + // Video moved + findChapter(videoId).removeVideoRecord(videoId); + myChapter.setVideoRecord(videoId, myVideo); + } + } + } + } + + mLastUpdated = source.getLastUpdated(); + } +} diff --git a/src/main/java/com/p4square/grow/model/Point.java b/src/main/java/com/p4square/grow/model/Point.java new file mode 100644 index 0000000..e9fc0ca --- /dev/null +++ b/src/main/java/com/p4square/grow/model/Point.java @@ -0,0 +1,79 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +/** + * Simple double based point class. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class Point { + /** + * Parse a comma separated x,y pair into a point. + * + * @return The point represented by the string. + * @throws IllegalArgumentException if the input is malformed. + */ + public static Point valueOf(String str) { + final int comma = str.indexOf(','); + if (comma == -1 || comma == 0 || comma == str.length() - 1) { + throw new IllegalArgumentException("Malformed point string"); + } + + final String sX = str.substring(0, comma); + final String sY = str.substring(comma + 1); + + return new Point(Double.valueOf(sX), Double.valueOf(sY)); + } + + private final double mX; + private final double mY; + + /** + * Create a new point with the given coordinates. + * + * @param x The x coordinate. + * @param y The y coordinate. + */ + public Point(double x, double y) { + mX = x; + mY = y; + } + + /** + * Compute the distance between this point and another. + * + * @param other The other point. + * @return The distance between this point and other. + */ + public double distance(Point other) { + final double dx = mX - other.mX; + final double dy = mY - other.mY; + + return Math.sqrt(dx*dx + dy*dy); + } + + /** + * @return The x coordinate. + */ + public double getX() { + return mX; + } + + /** + * @return The y coordinate. + */ + public double getY() { + return mY; + } + + /** + * @return The point represented as a comma separated pair. + */ + @Override + public String toString() { + return String.format("%.2f,%.2f", mX, mY); + } +} diff --git a/src/main/java/com/p4square/grow/model/QuadQuestion.java b/src/main/java/com/p4square/grow/model/QuadQuestion.java new file mode 100644 index 0000000..a7b4179 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/QuadQuestion.java @@ -0,0 +1,89 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +/** + * Two-dimensional Question. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class QuadQuestion extends Question { + private static final ScoringEngine ENGINE = new QuadScoringEngine(); + + private String mTop; + private String mRight; + private String mBottom; + private String mLeft; + + /** + * @return the top label. + */ + public String getTop() { + return mTop; + } + + /** + * Set the top label. + * @param s The new top label. + */ + public void setTop(String s) { + mTop = s; + } + + /** + * @return the right label. + */ + public String getRight() { + return mRight; + } + + /** + * Set the right label. + * @param s The new right label. + */ + public void setRight(String s) { + mRight = s; + } + + /** + * @return the bottom label. + */ + public String getBottom() { + return mBottom; + } + + /** + * Set the bottom label. + * @param s The new bottom label. + */ + public void setBottom(String s) { + mBottom = s; + } + + /** + * @return the left label. + */ + public String getLeft() { + return mLeft; + } + + /** + * Set the left label. + * @param s The new left label. + */ + public void setLeft(String s) { + mLeft = s; + } + + @Override + public boolean scoreAnswer(Score score, RecordedAnswer answer) { + return ENGINE.scoreAnswer(score, this, answer); + } + + @Override + public QuestionType getType() { + return QuestionType.QUAD; + } +} diff --git a/src/main/java/com/p4square/grow/model/QuadScoringEngine.java b/src/main/java/com/p4square/grow/model/QuadScoringEngine.java new file mode 100644 index 0000000..33403b5 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/QuadScoringEngine.java @@ -0,0 +1,49 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +import com.p4square.grow.model.Point; + +/** + * QuadScoringEngine expects the user's answer to be a Point string. We find + * the closest answer Point to the user's answer and treat that as the answer. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class QuadScoringEngine extends ScoringEngine { + + @Override + public boolean scoreAnswer(Score score, Question question, RecordedAnswer userAnswer) { + // Find all of the answer points. + Point[] answers = new Point[question.getAnswers().size()]; + { + int i = 0; + for (String answerStr : question.getAnswers().keySet()) { + answers[i++] = Point.valueOf(answerStr); + } + } + + // Parse the user's answer. + Point userPoint = Point.valueOf(userAnswer.getAnswerId()); + + // Find the closest answer point to the user's answer. + double minDistance = Double.MAX_VALUE; + int answerIndex = 0; + for (int i = 0; i < answers.length; i++) { + final double distance = userPoint.distance(answers[i]); + if (distance < minDistance) { + minDistance = distance; + answerIndex = i; + } + } + + LOG.debug("Quad " + question.getId() + ": Got answer " + + answers[answerIndex].toString() + " for user point " + userAnswer); + + // Get the answer and update the score. + final Answer answer = question.getAnswers().get(answers[answerIndex].toString()); + return answer.score(score); + } +} diff --git a/src/main/java/com/p4square/grow/model/Question.java b/src/main/java/com/p4square/grow/model/Question.java new file mode 100644 index 0000000..f4b9458 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/Question.java @@ -0,0 +1,165 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Model of an assessment question. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type") +@JsonSubTypes({ + @Type(value = TextQuestion.class, name = "text"), + @Type(value = ImageQuestion.class, name = "image"), + @Type(value = SliderQuestion.class, name = "slider"), + @Type(value = QuadQuestion.class, name = "quad"), + @Type(value = CircleQuestion.class, name = "circle"), +}) +public abstract class Question { + /** + * QuestionType indicates the type of Question. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ + public enum QuestionType { + TEXT, + IMAGE, + SLIDER, + QUAD, + CIRCLE; + + @Override + public String toString() { + return name().toLowerCase(); + } + } + + private String mQuestionId; + private QuestionType mType; + private String mQuestionText; + private Map<String, Answer> mAnswers; + + private String mPreviousQuestionId; + private String mNextQuestionId; + + public Question() { + mAnswers = new HashMap<String, Answer>(); + } + + /** + * @return the id String for this question. + */ + public String getId() { + return mQuestionId; + } + + /** + * Set the id String for this question. + * @param id New id + */ + public void setId(String id) { + mQuestionId = id; + } + + /** + * @return The Question text. + */ + public String getQuestion() { + return mQuestionText; + } + + /** + * Set the question text. + * @param value The new question text. + */ + public void setQuestion(String value) { + mQuestionText = value; + } + + /** + * @return The id String of the previous question or null if no previous question exists. + */ + public String getPreviousQuestion() { + return mPreviousQuestionId; + } + + /** + * Set the id string of the previous question. + * @param id Previous question id or null if there is no previous question. + */ + public void setPreviousQuestion(String id) { + mPreviousQuestionId = id; + } + + /** + * @return The id String of the next question or null if no next question exists. + */ + public String getNextQuestion() { + return mNextQuestionId; + } + + /** + * Set the id string of the next question. + * @param id next question id or null if there is no next question. + */ + public void setNextQuestion(String id) { + mNextQuestionId = id; + } + + /** + * @return a map of Answer id Strings to Answer objects. + */ + public Map<String, Answer> getAnswers() { + return mAnswers; + } + + /** + * Determine the id of the next question based on the answer to this + * question. + * + * @param answerid + * The id of the selected answer. + * @return a question id or null if this is the last question. + */ + public String getNextQuestion(String answerid) { + String nextQuestion = null; + + Answer a = mAnswers.get(answerid); + if (a != null) { + nextQuestion = a.getNextQuestion(); + } + + if (nextQuestion == null) { + nextQuestion = mNextQuestionId; + } + + return nextQuestion; + } + + /** + * Update the score based on the answer to this question. + * + * @param score The running score to update. + * @param answer The answer give to this question. + * @return true if scoring should continue, false if this answer trumps everything else. + */ + public abstract boolean scoreAnswer(Score score, RecordedAnswer answer); + + /** + * @return the QuestionType of this question. + */ + public abstract QuestionType getType(); + +} diff --git a/src/main/java/com/p4square/grow/model/RecordedAnswer.java b/src/main/java/com/p4square/grow/model/RecordedAnswer.java new file mode 100644 index 0000000..7d9905d --- /dev/null +++ b/src/main/java/com/p4square/grow/model/RecordedAnswer.java @@ -0,0 +1,34 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +/** + * Simple model for a user's assessment answer. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class RecordedAnswer { + private String mAnswerId; + + /** + * @return The user's answer. + */ + public String getAnswerId() { + return mAnswerId; + } + + /** + * Set the answer id field. + * @param id The new id. + */ + public void setAnswerId(String id) { + mAnswerId = id; + } + + @Override + public String toString() { + return mAnswerId; + } +} diff --git a/src/main/java/com/p4square/grow/model/Score.java b/src/main/java/com/p4square/grow/model/Score.java new file mode 100644 index 0000000..031c309 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/Score.java @@ -0,0 +1,119 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +/** + * Simple structure containing a score's sum and count. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class Score { + /** + * Return the decimal value for the given Score String. + * + * This method satisfies the invariant for Score x: + * numericScore(x.toString()) <= x.getScore() + */ + public static double numericScore(String score) { + score = score.toLowerCase(); + + if ("teacher".equals(score)) { + return 3.5; + } else if ("disciple".equals(score)) { + return 2.5; + } else if ("believer".equals(score)) { + return 1.5; + } else if ("seeker".equals(score)) { + return 0; + } else { + return Integer.MAX_VALUE; + } + } + + double sum; + int count; + + public Score() { + sum = 0; + count = 0; + } + + public Score(double sum, int count) { + this.sum = sum; + this.count = count; + } + + /** + * Copy Constructor. + */ + public Score(Score other) { + sum = other.sum; + count = other.count; + } + + /** + * @return The sum of all the points. + */ + public double getSum() { + return sum; + } + + /** + * @return The number of questions included in the score. + */ + public int getCount() { + return count; + } + + /** + * @return The final score. + */ + public double getScore() { + if (count == 0) { + return 0; + } + + return sum / count; + } + + /** + * @return the lowest score in the same category as this score. + */ + public double floor() { + final double score = getScore(); + + if (score >= 3.5) { + return 3.5; // teacher + + } else if (score >= 2.5) { + return 2.5; // disciple + + } else if (score >= 1.5) { + return 1.5; // believer + + } else { + return 0; // seeker + } + } + + @Override + public String toString() { + final double score = getScore(); + + if (score >= 3.5) { + return "teacher"; + + } else if (score >= 2.5) { + return "disciple"; + + } else if (score >= 1.5) { + return "believer"; + + } else { + return "seeker"; + } + } + +} diff --git a/src/main/java/com/p4square/grow/model/ScoringEngine.java b/src/main/java/com/p4square/grow/model/ScoringEngine.java new file mode 100644 index 0000000..8ff18b3 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/ScoringEngine.java @@ -0,0 +1,26 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +import org.apache.log4j.Logger; + +/** + * ScoringEngine computes the score for a question and a given answer. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public abstract class ScoringEngine { + protected static final Logger LOG = Logger.getLogger(ScoringEngine.class); + + /** + * Update the score based on the given question and answer. + * + * @param score The running score to update. + * @param question The question to compute the score for. + * @param answer The answer give to this question. + * @return true if scoring should continue, false if this answer trumps everything else. + */ + public abstract boolean scoreAnswer(Score score, Question question, RecordedAnswer answer); +} diff --git a/src/main/java/com/p4square/grow/model/SimpleScoringEngine.java b/src/main/java/com/p4square/grow/model/SimpleScoringEngine.java new file mode 100644 index 0000000..6ef2dbb --- /dev/null +++ b/src/main/java/com/p4square/grow/model/SimpleScoringEngine.java @@ -0,0 +1,26 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +/** + * SimpleScoringEngine expects the user's answer to a valid answer id and + * scores accordingly. + * + * If the answer id is not valid an Exception is thrown. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SimpleScoringEngine extends ScoringEngine { + + @Override + public boolean scoreAnswer(Score score, Question question, RecordedAnswer userAnswer) { + final Answer answer = question.getAnswers().get(userAnswer.getAnswerId()); + if (answer == null) { + throw new IllegalArgumentException("Not a valid answer."); + } + + return answer.score(score); + } +} diff --git a/src/main/java/com/p4square/grow/model/SliderQuestion.java b/src/main/java/com/p4square/grow/model/SliderQuestion.java new file mode 100644 index 0000000..f0861e3 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/SliderQuestion.java @@ -0,0 +1,24 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +/** + * Slider Question. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SliderQuestion extends Question { + private static final ScoringEngine ENGINE = new SliderScoringEngine(); + + @Override + public boolean scoreAnswer(Score score, RecordedAnswer answer) { + return ENGINE.scoreAnswer(score, this, answer); + } + + @Override + public QuestionType getType() { + return QuestionType.SLIDER; + } +} diff --git a/src/main/java/com/p4square/grow/model/SliderScoringEngine.java b/src/main/java/com/p4square/grow/model/SliderScoringEngine.java new file mode 100644 index 0000000..2961e95 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/SliderScoringEngine.java @@ -0,0 +1,35 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +/** + * SliderScoringEngine expects the user's answer to be a decimal value in the + * range [0, 1]. The value is scaled to the range [1, 4] and added to the + * score. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SliderScoringEngine extends ScoringEngine { + + @Override + public boolean scoreAnswer(Score score, Question question, RecordedAnswer userAnswer) { + int numberOfAnswers = question.getAnswers().size(); + if (numberOfAnswers == 0) { + throw new IllegalArgumentException("Question has no answers."); + } + + double answer = Double.valueOf(userAnswer.getAnswerId()); + if (answer < 0 || answer > 1) { + throw new IllegalArgumentException("Answer out of bounds."); + } + + double delta = Math.max(1, Math.ceil(answer * numberOfAnswers) / numberOfAnswers * 4); + + score.sum += delta; + score.count++; + + return true; + } +} diff --git a/src/main/java/com/p4square/grow/model/TextQuestion.java b/src/main/java/com/p4square/grow/model/TextQuestion.java new file mode 100644 index 0000000..88c2a34 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/TextQuestion.java @@ -0,0 +1,24 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +/** + * Text Question. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class TextQuestion extends Question { + private static final ScoringEngine ENGINE = new SimpleScoringEngine(); + + @Override + public boolean scoreAnswer(Score score, RecordedAnswer answer) { + return ENGINE.scoreAnswer(score, this, answer); + } + + @Override + public QuestionType getType() { + return QuestionType.TEXT; + } +} diff --git a/src/main/java/com/p4square/grow/model/TrainingRecord.java b/src/main/java/com/p4square/grow/model/TrainingRecord.java new file mode 100644 index 0000000..bc3ffa9 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/TrainingRecord.java @@ -0,0 +1,49 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +/** + * Representation of a user's training record. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class TrainingRecord { + private String mLastVideo; + private Playlist mPlaylist; + + public TrainingRecord() { + mPlaylist = new Playlist(); + } + + /** + * @return Video id of the last video watched. + */ + public String getLastVideo() { + return mLastVideo; + } + + /** + * Set the video id for the last video watched. + * @param video The new video id. + */ + public void setLastVideo(String video) { + mLastVideo = video; + } + + /** + * @return the user's Playlist. + */ + public Playlist getPlaylist() { + return mPlaylist; + } + + /** + * Set the user's playlist. + * @param playlist The new playlist. + */ + public void setPlaylist(Playlist playlist) { + mPlaylist = playlist; + } +} diff --git a/src/main/java/com/p4square/grow/model/UserRecord.java b/src/main/java/com/p4square/grow/model/UserRecord.java new file mode 100644 index 0000000..4399282 --- /dev/null +++ b/src/main/java/com/p4square/grow/model/UserRecord.java @@ -0,0 +1,183 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.model; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import org.apache.commons.codec.binary.Hex; + +import org.restlet.security.User; + +/** + * A simple user representation without any secrets. + */ +public class UserRecord { + private String mId; + private String mFirstName; + private String mLastName; + private String mEmail; + private String mLanding; + private boolean mNewBeliever; + + // Backend Access + private String mBackendPasswordHash; + + /** + * Create an empty UserRecord. + */ + public UserRecord() { + } + + /** + * Create a new UserRecord with the information from a User. + */ + public UserRecord(final User user) { + mId = user.getIdentifier(); + mFirstName = user.getFirstName(); + mLastName = user.getLastName(); + mEmail = user.getEmail(); + } + + /** + * @return The user's identifier. + */ + public String getId() { + return mId; + } + + /** + * Set the user's identifier. + * @param value The new id. + */ + public void setId(final String value) { + mId = value; + } + + /** + * @return The user's email. + */ + public String getEmail() { + return mEmail; + } + + /** + * Set the user's email. + * @param value The new email. + */ + public void setEmail(final String value) { + mEmail = value; + } + + /** + * @return The user's first name. + */ + public String getFirstName() { + return mFirstName; + } + + /** + * Set the user's first name. + * @param value The new first name. + */ + public void setFirstName(final String value) { + mFirstName = value; + } + + /** + * @return The user's last name. + */ + public String getLastName() { + return mLastName; + } + + /** + * Set the user's last name. + * @param value The new last name. + */ + public void setLastName(final String value) { + mLastName = value; + } + + /** + * @return The user's landing page. + */ + public String getLanding() { + return mLanding; + } + + /** + * Set the user's landing page. + * @param value The new landing page. + */ + public void setLanding(final String value) { + mLanding = value; + } + + /** + * @return true if the user came from the New Believer's landing. + */ + public boolean getNewBeliever() { + return mNewBeliever; + } + + /** + * Set the user's new believer flag. + * @param value The new flag. + */ + public void setNewBeliever(final boolean value) { + mNewBeliever = value; + } + + /** + * @return The user's backend password hash, null if he doesn't have + * access. + */ + public String getBackendPasswordHash() { + return mBackendPasswordHash; + } + + /** + * Set the user's backend password hash. + * @param value The new backend password hash or null to remove + * access. + */ + public void setBackendPasswordHash(final String value) { + mBackendPasswordHash = value; + } + + /** + * Set the user's backend password to the clear-text value given. + * @param value The new backend password. + */ + public void setBackendPassword(final String value) { + try { + mBackendPasswordHash = hashPassword(value); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + /** + * Hash the given secret. + */ + public static String hashPassword(final String secret) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + + // Convert the char[] to byte[] + // FIXME This approach is incorrectly truncating multibyte + // characters. + byte[] b = new byte[secret.length()]; + for (int i = 0; i < secret.length(); i++) { + b[i] = (byte) secret.charAt(i); + } + + md.update(b); + + byte[] hash = md.digest(); + return new String(Hex.encodeHex(hash)); + } +} diff --git a/src/main/java/com/p4square/grow/model/VideoRecord.java b/src/main/java/com/p4square/grow/model/VideoRecord.java new file mode 100644 index 0000000..ec99d0d --- /dev/null +++ b/src/main/java/com/p4square/grow/model/VideoRecord.java @@ -0,0 +1,85 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.model; + +import java.util.Date; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Simple bean containing video completion data. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class VideoRecord implements Cloneable { + private Boolean mComplete; + private Boolean mRequired; + private Date mCompletionDate; + + public VideoRecord() { + mComplete = null; + mRequired = null; + mCompletionDate = null; + } + + public boolean getComplete() { + if (mComplete == null) { + return false; + } + return mComplete; + } + + public void setComplete(boolean complete) { + mComplete = complete; + } + + @JsonIgnore + public boolean isCompleteSet() { + return mComplete != null; + } + + public boolean getRequired() { + if (mRequired == null) { + return true; + } + return mRequired; + } + + public void setRequired(boolean complete) { + mRequired = complete; + } + + @JsonIgnore + public boolean isRequiredSet() { + return mRequired != null; + } + + public Date getCompletionDate() { + return mCompletionDate; + } + + public void setCompletionDate(Date date) { + mCompletionDate = date; + } + + /** + * Convenience method to mark a video complete. + */ + public void complete() { + mComplete = true; + mCompletionDate = new Date(); + } + + /** + * @return an identical clone of this record. + */ + public VideoRecord clone() throws CloneNotSupportedException { + VideoRecord r = (VideoRecord) super.clone(); + r.mComplete = mComplete; + r.mRequired = mRequired; + r.mCompletionDate = mCompletionDate; + return r; + } +} diff --git a/src/main/java/com/p4square/grow/provider/CollectionProvider.java b/src/main/java/com/p4square/grow/provider/CollectionProvider.java new file mode 100644 index 0000000..e4e9040 --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/CollectionProvider.java @@ -0,0 +1,59 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import java.io.IOException; +import java.util.Map; + +/** + * ListProvider is the logical extension of Provider for dealing with lists of + * items. + * + * @param C The type of the collection key. + * @param K The type of the item key. + * @param V The type of the value. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public interface CollectionProvider<C, K, V> { + /** + * Retrieve a specific object from the collection. + * + * @param collection The collection key. + * @param key The key for the object in the collection. + * @return The object or null if not found. + */ + V get(C collection, K key) throws IOException; + + /** + * Retrieve a collection. + * + * The returned map will never be null. + * + * @param collection The collection key. + * @return A Map of keys to values. + */ + Map<K, V> query(C collection) throws IOException; + + /** + * Retrieve a portion of a collection. + * + * The returned map will never be null. + * + * @param collection The collection key. + * @param limit Max number of items to return. + * @return A Map of keys to values. + */ + Map<K, V> query(C collection, int limit) throws IOException; + + /** + * Persist the object with the given key. + * + * @param collection The collection key. + * @param key The key for the object in the collection. + * @param obj The object to persist. + */ + void put(C collection, K key, V obj) throws IOException; +} diff --git a/src/main/java/com/p4square/grow/provider/DelegateCollectionProvider.java b/src/main/java/com/p4square/grow/provider/DelegateCollectionProvider.java new file mode 100644 index 0000000..cf697ba --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/DelegateCollectionProvider.java @@ -0,0 +1,69 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public abstract class DelegateCollectionProvider<C, DC, K, DK, V> + implements CollectionProvider<C, K, V> { + + private CollectionProvider<DC, DK, V> mProvider; + + public DelegateCollectionProvider(final CollectionProvider<DC, DK, V> provider) { + mProvider = provider; + } + + public V get(C collection, K key) throws IOException { + return mProvider.get(makeCollectionKey(collection), makeKey(key)); + } + + public Map<K, V> query(C collection) throws IOException { + return query(collection, -1); + } + + public Map<K, V> query(C collection, int limit) throws IOException { + Map<DK, V> delegateResult = mProvider.query(makeCollectionKey(collection), limit); + Map<K, V> result = new LinkedHashMap<>(); + for (Map.Entry<DK, V> entry : delegateResult.entrySet()) { + result.put(unmakeKey(entry.getKey()), entry.getValue()); + } + + return result; + } + + public void put(C collection, K key, V obj) throws IOException { + mProvider.put(makeCollectionKey(collection), makeKey(key), obj); + } + + /** + * Make a collection key for the delegated provider. + * + * @param input The pre-transform key. + * @return the post-transform key. + */ + protected abstract DC makeCollectionKey(final C input); + + /** + * Make a key for the delegated provider. + * + * @param input The pre-transform key. + * @return the post-transform key. + */ + protected abstract DK makeKey(final K input); + + /** + * Transform a key for the delegated provider to an input key. + * + * @param input The post-transform key. + * @return the pre-transform key. + */ + protected abstract K unmakeKey(final DK input); +} diff --git a/src/main/java/com/p4square/grow/provider/DelegateProvider.java b/src/main/java/com/p4square/grow/provider/DelegateProvider.java new file mode 100644 index 0000000..42dcc63 --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/DelegateProvider.java @@ -0,0 +1,40 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import java.io.IOException; + +/** + * DelegateProvider wraps an existing Provider an transforms the key from + * type K to type D. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public abstract class DelegateProvider<K, D, V> implements Provider<K, V> { + + private Provider<D, V> mProvider; + + public DelegateProvider(final Provider<D, V> provider) { + mProvider = provider; + } + + @Override + public V get(final K key) throws IOException { + return mProvider.get(makeKey(key)); + } + + @Override + public void put(final K key, final V obj) throws IOException { + mProvider.put(makeKey(key), obj); + } + + /** + * Make a Key for the delegated provider. + * + * @param input The pre-transform key. + * @return the post-transform key. + */ + protected abstract D makeKey(final K input); +} diff --git a/src/main/java/com/p4square/grow/provider/JsonEncodedProvider.java b/src/main/java/com/p4square/grow/provider/JsonEncodedProvider.java new file mode 100644 index 0000000..500f761 --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/JsonEncodedProvider.java @@ -0,0 +1,83 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import java.io.IOException; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +/** + * Provider provides a simple interface for loading and persisting + * objects. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public abstract class JsonEncodedProvider<V> { + public static final ObjectMapper MAPPER = new ObjectMapper(); + static { + MAPPER.configure(SerializationFeature.WRITE_ENUMS_USING_TO_STRING, true); + MAPPER.configure(DeserializationFeature.READ_ENUMS_USING_TO_STRING, true); + MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + protected final Class<V> mClazz; + protected final JavaType mType; + + public JsonEncodedProvider(Class<V> clazz) { + mClazz = clazz; + mType = null; + } + + public JsonEncodedProvider(JavaType type) { + mType = type; + mClazz = null; + } + + /** + * Encode the object as JSON. + * + * @param obj The object to encode. + * @return The JSON encoding of obj. + * @throws IOException if the object cannot be encoded. + */ + protected String encode(V obj) throws IOException { + if (mClazz == String.class) { + return (String) obj; + } + + return MAPPER.writeValueAsString(obj); + } + + /** + * Decode the JSON string as an object. + * + * @param blob The JSON data to decode. + * @return The decoded object or null if blob is null. + * @throws IOException If an object cannot be decoded. + */ + protected V decode(String blob) throws IOException { + if (blob == null) { + return null; + } + + if (mClazz == String.class) { + return (V) blob; + } + + V obj; + if (mClazz != null) { + obj = MAPPER.readValue(blob, mClazz); + + } else { + obj = MAPPER.readValue(blob, mType); + } + + return obj; + } +} + diff --git a/src/main/java/com/p4square/grow/provider/MapCollectionProvider.java b/src/main/java/com/p4square/grow/provider/MapCollectionProvider.java new file mode 100644 index 0000000..4c5cef6 --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/MapCollectionProvider.java @@ -0,0 +1,74 @@ +/* + * Copyright 2015 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import java.io.IOException; +import java.util.Iterator; +import java.util.Map; +import java.util.HashMap; + +/** + * In-memory CollectionProvider implementation, useful for tests. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class MapCollectionProvider<C, K, V> implements CollectionProvider<C, K, V> { + private final Map<C, Map<K, V>> mMap; + + public MapCollectionProvider() { + mMap = new HashMap<>(); + } + + @Override + public synchronized V get(C collection, K key) throws IOException { + Map<K, V> map = mMap.get(collection); + if (map != null) { + return map.get(key); + } + + return null; + } + + @Override + public synchronized Map<K, V> query(C collection) throws IOException { + Map<K, V> map = mMap.get(collection); + if (map == null) { + map = new HashMap<K, V>(); + } + + return map; + } + + @Override + public synchronized Map<K, V> query(C collection, int limit) throws IOException { + Map<K, V> map = query(collection); + + if (map.size() > limit) { + Map<K, V> smallMap = new HashMap<>(); + + Iterator<Map.Entry<K, V>> iterator = map.entrySet().iterator(); + for (int i = 0; i < limit; i++) { + Map.Entry<K, V> entry = iterator.next(); + smallMap.put(entry.getKey(), entry.getValue()); + } + + return smallMap; + + } else { + return map; + } + } + + @Override + public synchronized void put(C collection, K key, V obj) throws IOException { + Map<K, V> map = mMap.get(collection); + if (map == null) { + map = new HashMap<K, V>(); + mMap.put(collection, map); + } + + map.put(key, obj); + } +} diff --git a/src/main/java/com/p4square/grow/provider/MapProvider.java b/src/main/java/com/p4square/grow/provider/MapProvider.java new file mode 100644 index 0000000..40f8107 --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/MapProvider.java @@ -0,0 +1,28 @@ +/* + * Copyright 2015 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import java.io.IOException; +import java.util.Map; +import java.util.HashMap; + +/** + * In-memory Provider implementation, useful for tests. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class MapProvider<K, V> implements Provider<K, V> { + private final Map<K, V> mMap = new HashMap<K, V>(); + + @Override + public V get(K key) throws IOException { + return mMap.get(key); + } + + @Override + public void put(K key, V obj) throws IOException { + mMap.put(key, obj); + } +} diff --git a/src/main/java/com/p4square/grow/provider/Provider.java b/src/main/java/com/p4square/grow/provider/Provider.java new file mode 100644 index 0000000..ca6af25 --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/Provider.java @@ -0,0 +1,31 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import java.io.IOException; + +/** + * Provider provides a simple interface for loading and persisting + * objects. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public interface Provider<K, V> { + /** + * Retrieve the object with the given key. + * + * @param key The key for the object. + * @return The object or null if not found. + */ + V get(K key) throws IOException; + + /** + * Persist the object with the given key. + * + * @param key The key for the object. + * @param obj The object to persist. + */ + void put(K key, V obj) throws IOException; +} diff --git a/src/main/java/com/p4square/grow/provider/ProvidesAssessments.java b/src/main/java/com/p4square/grow/provider/ProvidesAssessments.java new file mode 100644 index 0000000..62ba8f6 --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/ProvidesAssessments.java @@ -0,0 +1,20 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import com.p4square.grow.model.RecordedAnswer; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public interface ProvidesAssessments { + /** + * Provides a collection of user assessments. + * The collection key is the user id. + * The key is the question id. + */ + CollectionProvider<String, String, String> getAnswerProvider(); +} diff --git a/src/main/java/com/p4square/grow/provider/ProvidesQuestions.java b/src/main/java/com/p4square/grow/provider/ProvidesQuestions.java new file mode 100644 index 0000000..b43f649 --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/ProvidesQuestions.java @@ -0,0 +1,19 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import com.p4square.grow.model.Question; + +/** + * Indicates the ability to provide a Question Provider. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public interface ProvidesQuestions { + /** + * @return A Provider of Questions keyed by question id. + */ + Provider<String, Question> getQuestionProvider(); +} diff --git a/src/main/java/com/p4square/grow/provider/ProvidesStrings.java b/src/main/java/com/p4square/grow/provider/ProvidesStrings.java new file mode 100644 index 0000000..5d9976e --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/ProvidesStrings.java @@ -0,0 +1,19 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.provider; + +/** + * Indicates the ability to provide a String provider. + * + * Strings are typically configuration settings stored as a String. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public interface ProvidesStrings { + /** + * @return A Provider of Questions keyed by question id. + */ + Provider<String, String> getStringProvider(); +}
\ No newline at end of file diff --git a/src/main/java/com/p4square/grow/provider/ProvidesTrainingRecords.java b/src/main/java/com/p4square/grow/provider/ProvidesTrainingRecords.java new file mode 100644 index 0000000..586e649 --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/ProvidesTrainingRecords.java @@ -0,0 +1,27 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import java.io.IOException; + +import com.p4square.grow.model.TrainingRecord; +import com.p4square.grow.model.Playlist; + +/** + * Indicates the ability to provide a TrainingRecord Provider. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public interface ProvidesTrainingRecords { + /** + * @return A Provider of Questions keyed by question id. + */ + Provider<String, TrainingRecord> getTrainingRecordProvider(); + + /** + * @return the Default Playlist. + */ + Playlist getDefaultPlaylist() throws IOException; +} diff --git a/src/main/java/com/p4square/grow/provider/ProvidesUserRecords.java b/src/main/java/com/p4square/grow/provider/ProvidesUserRecords.java new file mode 100644 index 0000000..d77c878 --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/ProvidesUserRecords.java @@ -0,0 +1,19 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import com.p4square.grow.model.UserRecord; + +/** + * Indicates the ability to provide a UserRecord Provider. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public interface ProvidesUserRecords { + /** + * @return A Provider of Questions keyed by question id. + */ + Provider<String, UserRecord> getUserRecordProvider(); +} diff --git a/src/main/java/com/p4square/grow/provider/ProvidesVideos.java b/src/main/java/com/p4square/grow/provider/ProvidesVideos.java new file mode 100644 index 0000000..3d055d3 --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/ProvidesVideos.java @@ -0,0 +1,16 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.provider; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public interface ProvidesVideos { + /** + * @return A Provider of Questions keyed by question id. + */ + CollectionProvider<String, String, String> getVideoProvider(); +} diff --git a/src/main/java/com/p4square/grow/provider/TrainingRecordProvider.java b/src/main/java/com/p4square/grow/provider/TrainingRecordProvider.java new file mode 100644 index 0000000..44dba87 --- /dev/null +++ b/src/main/java/com/p4square/grow/provider/TrainingRecordProvider.java @@ -0,0 +1,41 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.provider; + +import java.io.IOException; + +import com.p4square.grow.model.TrainingRecord; + +/** + * TrainingRecordProvider wraps an existing Provider to get and put TrainingRecords. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public abstract class TrainingRecordProvider<K> implements Provider<String, TrainingRecord> { + + private Provider<K, TrainingRecord> mProvider; + + public TrainingRecordProvider(Provider<K, TrainingRecord> provider) { + mProvider = provider; + } + + @Override + public TrainingRecord get(String key) throws IOException { + return mProvider.get(makeKey(key)); + } + + @Override + public void put(String key, TrainingRecord obj) throws IOException { + mProvider.put(makeKey(key), obj); + } + + /** + * Make a Key for a TrainingRecord.. + * + * @param userId The user id. + * @return a key for the TrainingRecord of userid. + */ + protected abstract K makeKey(String userId); +} diff --git a/src/main/java/com/p4square/grow/tools/AssessmentStats.java b/src/main/java/com/p4square/grow/tools/AssessmentStats.java new file mode 100644 index 0000000..ca83411 --- /dev/null +++ b/src/main/java/com/p4square/grow/tools/AssessmentStats.java @@ -0,0 +1,218 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.grow.tools; + + +import java.util.Map; +import java.util.HashMap; +import java.util.Queue; +import java.util.List; +import java.util.LinkedList; +import java.io.IOException; + +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.p4square.grow.model.Answer; +import com.p4square.grow.model.Question; +import com.p4square.grow.model.RecordedAnswer; +import com.p4square.grow.model.Score; +import com.p4square.grow.provider.Provider; +import com.p4square.grow.provider.JsonEncodedProvider; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class AssessmentStats { + public static void main(String... args) throws Exception { + if (args.length == 0) { + System.out.println("Usage: AssessmentStats directory firstQuestionId"); + System.exit(1); + } + + Map<String, Question> questions; + questions = loadQuestions(args[0], args[1]); + + // Find the highest possible score + List<AnswerPath> scores = findHighestFromId(questions, args[1]); + + // Print Results + System.out.printf("Found %d different paths.\n", scores.size()); + int i = 0; + for (AnswerPath path : scores) { + Score s = path.mScore; + System.out.printf("Path %d: %f points, %d questions. Score: %f (%s)\n", + i++, s.getSum(), s.getCount(), s.getScore(), s.toString()); + System.out.println(" " + path.mPath); + System.out.println(" " + path.mScores); + } + } + + private static Map<String, Question> loadQuestions(String baseDir, String firstId) throws IOException { + FileQuestionProvider provider = new FileQuestionProvider(baseDir); + + // Questions to find... + Queue<String> queue = new LinkedList<>(); + queue.offer(firstId); + + Map<String, Question> questions = new HashMap<>(); + + + while (!queue.isEmpty()) { + Question q = provider.get(queue.poll()); + questions.put(q.getId(), q); + + if (q.getNextQuestion() != null) { + queue.offer(q.getNextQuestion()); + + } + + for (Answer a : q.getAnswers().values()) { + if (a.getNextQuestion() != null) { + queue.offer(a.getNextQuestion()); + } + } + + // Quick Sanity check + if (q.getPreviousQuestion() != null) { + if (questions.get(q.getPreviousQuestion()) == null) { + throw new IllegalStateException("Haven't seen previous question??"); + } + } + } + + return questions; + } + + private static List<AnswerPath> findHighestFromId(Map<String, Question> questions, String id) { + List<AnswerPath> scores = new LinkedList<>(); + doFindHighestFromId(questions, id, scores, new AnswerPath()); + return scores; + } + + private static void doFindHighestFromId(Map<String, Question> questions, String id, List<AnswerPath> scores, AnswerPath path) { + if (id == null) { + // End of the road! Save the score and return. + scores.add(path); + return; + } + + Question q = questions.get(id); + + // Find the best answer following this path and find other paths. + Score maxScore = path.mScore; + double max = 0; + + int answerCount = 1; + for (Map.Entry<String, Answer> entry : q.getAnswers().entrySet()) { + Answer a = entry.getValue(); + RecordedAnswer userAnswer = new RecordedAnswer(); + + if (q.getType() == Question.QuestionType.SLIDER) { + // Special Case + userAnswer.setAnswerId(String.valueOf((float) answerCount / q.getAnswers().size())); + + } else { + userAnswer.setAnswerId(entry.getKey()); + } + + Score tempScore = new Score(path.mScore); // Always start with the initial score. + boolean endOfRoad = !q.scoreAnswer(tempScore, userAnswer); + double thisScore = tempScore.getSum() - path.mScore.getSum(); + + if (endOfRoad) { + // End of Road is a fork too. Record and pick another answer. + AnswerPath fork = new AnswerPath(path); + fork.update(id, tempScore); + scores.add(fork); + + } else if (a.getNextQuestion() != null) { + // Found a new path, follow it. + // Remember to count this answer in the score. + AnswerPath fork = new AnswerPath(path); + fork.update(id, tempScore); + doFindHighestFromId(questions, a.getNextQuestion(), scores, fork); + + } else if (thisScore > max) { + // Found a higher option that isn't a new path. + maxScore = tempScore; + max = thisScore; + } + + answerCount++; + } + + path.update(id, maxScore); + doFindHighestFromId(questions, q.getNextQuestion(), scores, path); + } + + private static class FileQuestionProvider extends JsonEncodedProvider<Question> implements Provider<String, Question> { + private String mBaseDir; + + public FileQuestionProvider(String directory) { + super(Question.class); + mBaseDir = directory; + } + + @Override + public Question get(String key) throws IOException { + Path qfile = FileSystems.getDefault().getPath(mBaseDir, key + ".json"); + byte[] blob = Files.readAllBytes(qfile); + return decode(new String(blob)); + } + + @Override + public void put(String key, Question obj) throws IOException { + throw new UnsupportedOperationException("Not Implemented"); + } + } + + private static class AnswerPath { + String mPath; + String mScores; + Score mScore; + + public AnswerPath() { + mPath = null; + mScores = null; + mScore = new Score(); + } + + public AnswerPath(AnswerPath other) { + mPath = other.mPath; + mScores = other.mScores; + mScore = other.mScore; + } + + public void update(String questionId, Score newScore) { + String value; + + if (mScore.getCount() == newScore.getCount()) { + value = "n/a"; + + } else { + double delta = newScore.getSum() - mScore.getSum(); + if (delta < 0) { + value = "TRUMP"; + } else { + value = String.valueOf(delta); + } + } + + if (mPath == null) { + mPath = questionId; + mScores = value; + + } else { + mPath += ", " + questionId; + mScores += " + " + value; + } + + mScore = newScore; + } + } +} diff --git a/src/main/java/com/p4square/grow/tools/AttributeBackfillTool.java b/src/main/java/com/p4square/grow/tools/AttributeBackfillTool.java new file mode 100644 index 0000000..d7fd2ff --- /dev/null +++ b/src/main/java/com/p4square/grow/tools/AttributeBackfillTool.java @@ -0,0 +1,268 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.tools; + +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import org.restlet.Client; +import org.restlet.Context; +import org.restlet.data.Protocol; + +import com.p4square.f1oauth.Attribute; +import com.p4square.f1oauth.F1API; +import com.p4square.f1oauth.F1Access; +import com.p4square.f1oauth.F1Exception; +import com.p4square.restlet.oauth.OAuthUser; + +import com.p4square.grow.backend.dynamo.DynamoDatabase; +import com.p4square.grow.backend.dynamo.DynamoKey; + +import com.p4square.grow.config.Config; + +import com.p4square.grow.model.Chapter; +import com.p4square.grow.model.Playlist; +import com.p4square.grow.model.TrainingRecord; +import com.p4square.grow.model.VideoRecord; +import com.p4square.grow.provider.JsonEncodedProvider; + +/** + * This utility is used to backfill F1 Attributes from the GROW database into F1. + * + * This tool currently reads from Dynamo directly. It should probably access the + * backend or use the {@link com.p4square.grow.backend.GrowData} abstraction instead. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class AttributeBackfillTool { + + private static Config mConfig; + private static F1API mF1API; + private static DynamoDatabase mDatabase; + + public static void usage() { + System.out.println("java com.p4square.grow.tools.AttributeBackfillTool <command>...\n"); + System.out.println("Commands:"); + System.out.println("\t--domain <domain> Set config domain"); + System.out.println("\t--dev Set config domain to dev"); + System.out.println("\t--config <file> Merge in config file"); + System.out.println("\t--assessments Backfill All Assessments"); + System.out.println("\t--training Backfill All Training Records"); + } + + public static void main(String... args) { + if (args.length == 0) { + usage(); + System.exit(1); + } + + mConfig = new Config(); + + try { + mConfig.updateConfig(AttributeTool.class.getResourceAsStream("/grow.properties")); + + int offset = 0; + while (offset < args.length) { + if ("--domain".equals(args[offset])) { + mConfig.setDomain(args[offset + 1]); + mF1API = null; + mDatabase = null; + offset += 2; + + } else if ("--dev".equals(args[offset])) { + mConfig.setDomain("dev"); + mF1API = null; + mDatabase = null; + offset += 1; + + } else if ("--config".equals(args[offset])) { + mConfig.updateConfig(args[offset + 1]); + mF1API = null; + mDatabase = null; + offset += 2; + + } else if ("--assessments".equals(args[offset])) { + offset = assessments(args, ++offset); + + } else if ("--training".equals(args[offset])) { + offset = training(args, ++offset); + + } else { + throw new IllegalArgumentException("Unknown command " + args[offset]); + } + } + } catch (Exception e) { + e.printStackTrace(); + System.exit(2); + } + } + + private static F1API getF1API() throws Exception { + if (mF1API == null) { + Context context = new Context(); + Client client = new Client(context, Arrays.asList(Protocol.HTTP, Protocol.HTTPS)); + context.setClientDispatcher(client); + + F1Access f1Access = new F1Access(context, + mConfig.getString("f1ConsumerKey"), + mConfig.getString("f1ConsumerSecret"), + mConfig.getString("f1BaseUrl"), + mConfig.getString("f1ChurchCode"), + F1Access.UserType.WEBLINK); + + // Gather Username and Password + String username = System.console().readLine("F1 Username: "); + char[] password = System.console().readPassword("F1 Password: "); + + OAuthUser user = f1Access.getAccessToken(username, new String(password)); + Arrays.fill(password, ' '); // Lost cause, but I'll still try. + + mF1API = f1Access.getAuthenticatedApi(user); + } + + return mF1API; + } + + private static DynamoDatabase getDatabase() { + if (mDatabase == null) { + mDatabase = new DynamoDatabase(mConfig); + } + + return mDatabase; + } + + private static int assessments(String[] args, int offset) throws Exception { + final F1API f1 = getF1API(); + final DynamoDatabase db = getDatabase(); + + DynamoKey key = DynamoKey.newKey("assessments", null); + + while (key != null) { + Map<DynamoKey, Map<String, String>> rows = db.getAll(key); + + key = null; + + for (Map.Entry<DynamoKey, Map<String, String>> row : rows.entrySet()) { + key = row.getKey(); + + String userId = key.getHashKey(); + + String summaryString = row.getValue().get("summary"); + if (summaryString == null || summaryString.length() == 0) { + System.out.printf("%s assessment incomplete\n", userId); + continue; + } + + try { + Map summary = JsonEncodedProvider.MAPPER.readValue(summaryString, Map.class); + + String result = (String) summary.get("result"); + if (result == null) { + System.out.printf("%s assessment incomplete\n", userId); + continue; + } + + String attributeName = "Assessment Complete - " + result; + + // Check if the user already has the attribute. + List<Attribute> attributes = f1.getAttribute(userId, attributeName); + + if (attributes.size() == 0) { + Attribute attribute = new Attribute(attributeName); + attribute.setStartDate(new Date()); + attribute.setComment(summaryString); + + if (f1.addAttribute(userId, attribute)) { + System.out.printf("%s attribute added\n", userId); + } else { + System.out.printf("%s failed to add attribute\n", userId); + } + } else { + System.out.printf("%s already has attribute\n", userId); + } + } catch (Exception e) { + System.out.printf("%s exception: %s\n", userId, e.getMessage()); + } + } + } + + return offset; + } + + private static int training(String[] args, int offset) throws Exception { + final F1API f1 = getF1API(); + final DynamoDatabase db = getDatabase(); + + DynamoKey key = DynamoKey.newKey("training", null); + + while (key != null) { + Map<DynamoKey, Map<String, String>> rows = db.getAll(key); + + key = null; + + for (Map.Entry<DynamoKey, Map<String, String>> row : rows.entrySet()) { + key = row.getKey(); + + String userId = key.getHashKey(); + + String valueString = row.getValue().get("value"); + if (valueString == null || valueString.length() == 0) { + System.out.printf("%s empty training record\n", userId); + continue; + } + + try { + TrainingRecord record = + JsonEncodedProvider.MAPPER.readValue(valueString, TrainingRecord.class); + Playlist playlist = record.getPlaylist(); + +chapters: + for (Map.Entry<String, Chapter> entry : playlist.getChaptersMap().entrySet()) { + Chapter chapter = entry.getValue(); + + // Find completion date + Date complete = new Date(0); + for (VideoRecord vr : chapter.getVideos().values()) { + if (!vr.getComplete()) { + continue chapters; + } + + Date recordCompletion = vr.getCompletionDate(); + if (recordCompletion != null && complete.before(recordCompletion)) { + complete = vr.getCompletionDate(); + } + } + + String attributeName = "Training Complete - " + entry.getKey(); + + // Check if the user already has the attribute. + List<Attribute> attributes = f1.getAttribute(userId, attributeName); + + if (attributes.size() == 0) { + Attribute attribute = new Attribute(attributeName); + attribute.setStartDate(complete); + + if (f1.addAttribute(userId, attribute)) { + System.out.printf("%s added %s\n", userId, attributeName); + } else { + System.out.printf("%s failed to add %s\n", userId, attributeName); + } + } else { + System.out.printf("%s already has %s\n", userId, attributeName); + } + } + + } catch (Exception e) { + System.out.printf("%s exception: %s\n", userId, e.getMessage()); + e.printStackTrace(); + } + } + } + + return offset; + } +} diff --git a/src/main/java/com/p4square/grow/tools/AttributeTool.java b/src/main/java/com/p4square/grow/tools/AttributeTool.java new file mode 100644 index 0000000..8e0540a --- /dev/null +++ b/src/main/java/com/p4square/grow/tools/AttributeTool.java @@ -0,0 +1,184 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.grow.tools; + +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import org.restlet.Client; +import org.restlet.Context; +import org.restlet.data.Protocol; + +import com.p4square.grow.config.Config; +import com.p4square.f1oauth.Attribute; +import com.p4square.f1oauth.F1Access; +import com.p4square.f1oauth.F1API; +import com.p4square.f1oauth.F1Exception; +import com.p4square.restlet.oauth.OAuthUser; + +/** + * Tool for manipulating F1 Attributes. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class AttributeTool { + + private static Config mConfig; + private static F1API mF1API; + + public static void usage() { + System.out.println("java com.p4square.grow.tools.AttributeTool <command>...\n"); + System.out.println("Commands:"); + System.out.println("\t--domain <domain> Set config domain"); + System.out.println("\t--dev Set config domain to dev"); + System.out.println("\t--config <file> Merge in config file"); + System.out.println("\t--list List all attributes"); + System.out.println("\t--assign <userId> <attribute> <comment> Assign an attribute"); + System.out.println("\t--getall <userId> Get an attribute"); + System.out.println("\t--get <userId> <attribute> Get an attribute"); + } + + public static void main(String... args) { + if (args.length == 0) { + usage(); + System.exit(1); + } + + mConfig = new Config(); + + try { + mConfig.updateConfig(AttributeTool.class.getResourceAsStream("/grow.properties")); + + int offset = 0; + while (offset < args.length) { + if ("--domain".equals(args[offset])) { + mConfig.setDomain(args[offset + 1]); + mF1API = null; + offset += 2; + + } else if ("--dev".equals(args[offset])) { + mConfig.setDomain("dev"); + mF1API = null; + offset += 1; + + } else if ("--config".equals(args[offset])) { + mConfig.updateConfig(args[offset + 1]); + mF1API = null; + offset += 2; + + } else if ("--list".equals(args[offset])) { + offset = list(args, ++offset); + + } else if ("--assign".equals(args[offset])) { + offset = assign(args, ++offset); + + } else if ("--getall".equals(args[offset])) { + offset = getall(args, ++offset); + + } else if ("--get".equals(args[offset])) { + offset = get(args, ++offset); + + } else { + throw new IllegalArgumentException("Unknown command " + args[offset]); + } + } + } catch (Exception e) { + e.printStackTrace(); + System.exit(2); + } + } + + private static F1API getF1API() throws Exception { + if (mF1API == null) { + Context context = new Context(); + Client client = new Client(context, Arrays.asList(Protocol.HTTP, Protocol.HTTPS)); + context.setClientDispatcher(client); + + F1Access f1Access = new F1Access(context, + mConfig.getString("f1ConsumerKey"), + mConfig.getString("f1ConsumerSecret"), + mConfig.getString("f1BaseUrl"), + mConfig.getString("f1ChurchCode"), + F1Access.UserType.WEBLINK); + + // Gather Username and Password + String username = System.console().readLine("F1 Username: "); + char[] password = System.console().readPassword("F1 Password: "); + + OAuthUser user = f1Access.getAccessToken(username, new String(password)); + Arrays.fill(password, ' '); // Lost cause, but I'll still try. + + mF1API = f1Access.getAuthenticatedApi(user); + } + + return mF1API; + } + + private static int list(String[] args, int offset) throws Exception { + final F1API f1 = getF1API(); + + final Map<String, String> attributes = f1.getAttributeList(); + System.out.printf("%7s %s\n", "ID", "Name"); + for (Map.Entry<String, String> entry : attributes.entrySet()) { + System.out.printf("%7s %s\n", entry.getValue(), entry.getKey()); + } + + return offset; + } + + private static int assign(String[] args, int offset) throws Exception { + final String userId = args[offset++]; + final String attributeName = args[offset++]; + final String comment = args[offset++]; + + final F1API f1 = getF1API(); + + Attribute attribute = new Attribute(attributeName); + attribute.setStartDate(new Date()); + attribute.setComment(comment); + + if (f1.addAttribute(userId, attribute)) { + System.out.println("Added attribute " + attributeName + " for " + userId); + } else { + System.out.println("Failed to add attribute " + attributeName + " for " + userId); + } + + return offset; + } + + private static int getall(String[] args, int offset) throws Exception { + final String userId = args[offset++]; + + doGet(userId, null); + + return offset; + } + + private static int get(String[] args, int offset) throws Exception { + final String userId = args[offset++]; + final String attributeName = args[offset++]; + + doGet(userId, attributeName); + + return offset; + } + + private static void doGet(final String userId, final String attributeName) throws Exception { + final F1API f1 = getF1API(); + + List<Attribute> attributes = f1.getAttribute(userId, attributeName); + for (Attribute attribute : attributes) { + System.out.printf("%s %s %s %s %s\n%s\n\n", + userId, + attribute.getAttributeName(), + attribute.getId(), + attribute.getStartDate(), + attribute.getEndDate(), + attribute.getComment()); + } + } +} diff --git a/src/main/java/com/p4square/restlet/metrics/MetricRouter.java b/src/main/java/com/p4square/restlet/metrics/MetricRouter.java new file mode 100644 index 0000000..d4da270 --- /dev/null +++ b/src/main/java/com/p4square/restlet/metrics/MetricRouter.java @@ -0,0 +1,61 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.restlet.metrics; + +import com.codahale.metrics.Counter; +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; + +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.routing.TemplateRoute; +import org.restlet.routing.Router; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class MetricRouter extends Router { + + private final MetricRegistry mMetricRegistry; + + public MetricRouter(Context context, MetricRegistry metrics) { + super(context); + mMetricRegistry = metrics; + } + + @Override + protected void doHandle(Restlet next, Request request, Response response) { + String baseName; + if (next instanceof TemplateRoute) { + TemplateRoute temp = (TemplateRoute) next; + baseName = MetricRegistry.name("MetricRouter", temp.getTemplate().getPattern()); + } else { + baseName = MetricRegistry.name("MetricRouter", "unknown"); + } + + final Timer.Context aggTimer = mMetricRegistry.timer("MetricRouter.time").time(); + final Timer.Context timer = mMetricRegistry.timer(baseName + ".time").time(); + + try { + super.doHandle(next, request, response); + } finally { + timer.stop(); + aggTimer.stop(); + + // Record status code + boolean success = !response.getStatus().isError(); + if (success) { + mMetricRegistry.counter("MetricRouter.success").inc(); + mMetricRegistry.counter(baseName + ".response.success").inc(); + } else { + mMetricRegistry.counter("MetricRouter.failure").inc(); + mMetricRegistry.counter(baseName + ".response.failure").inc(); + } + } + } +} diff --git a/src/main/java/com/p4square/restlet/metrics/MetricsApplication.java b/src/main/java/com/p4square/restlet/metrics/MetricsApplication.java new file mode 100644 index 0000000..6caf742 --- /dev/null +++ b/src/main/java/com/p4square/restlet/metrics/MetricsApplication.java @@ -0,0 +1,43 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.restlet.metrics; + +import java.util.concurrent.TimeUnit; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.json.MetricsModule; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.restlet.Application; +import org.restlet.Restlet; +import org.restlet.resource.Finder; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class MetricsApplication extends Application { + static final ObjectMapper MAPPER; + static { + MAPPER = new ObjectMapper(); + MAPPER.registerModule(new MetricsModule(TimeUnit.SECONDS, TimeUnit.MILLISECONDS, true)); + } + + private final MetricRegistry mMetricRegistry; + + public MetricsApplication(MetricRegistry metrics) { + mMetricRegistry = metrics; + } + + public MetricRegistry getMetricRegistry() { + return mMetricRegistry; + } + + @Override + public Restlet createInboundRoot() { + return new Finder(getContext(), MetricsResource.class); + } +} diff --git a/src/main/java/com/p4square/restlet/metrics/MetricsResource.java b/src/main/java/com/p4square/restlet/metrics/MetricsResource.java new file mode 100644 index 0000000..e2ab14d --- /dev/null +++ b/src/main/java/com/p4square/restlet/metrics/MetricsResource.java @@ -0,0 +1,32 @@ +/* + * Copyright 2014 Jesse Morgan + */ + +package com.p4square.restlet.metrics; + +import com.codahale.metrics.MetricRegistry; + +import org.restlet.ext.jackson.JacksonRepresentation; +import org.restlet.representation.Representation; +import org.restlet.resource.ServerResource; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class MetricsResource extends ServerResource { + + private MetricRegistry mMetricRegistry; + + @Override + public void doInit() { + mMetricRegistry = ((MetricsApplication) getApplication()).getMetricRegistry(); + } + + @Override + protected Representation get() { + JacksonRepresentation<MetricRegistry> rep = new JacksonRepresentation<>(mMetricRegistry); + rep.setObjectMapper(MetricsApplication.MAPPER); + return rep; + } +} diff --git a/src/main/java/com/p4square/restlet/oauth/OAuthAuthenticator.java b/src/main/java/com/p4square/restlet/oauth/OAuthAuthenticator.java new file mode 100644 index 0000000..c33bb5a --- /dev/null +++ b/src/main/java/com/p4square/restlet/oauth/OAuthAuthenticator.java @@ -0,0 +1,95 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.restlet.oauth; + +import org.apache.log4j.Logger; + +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.security.Authenticator; +import org.restlet.security.User; + +/** + * Authenticator which makes an OAuth request to authenticate the user. + * + * If this Authenticator is made optional than no requests are made to the + * service provider. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class OAuthAuthenticator extends Authenticator { + private static Logger LOG = Logger.getLogger(OAuthAuthenticator.class); + + private static final String OAUTH_TOKEN = "oauth_token"; + private static final String COOKIE_NAME = "oauth_secret"; + + private final OAuthHelper mHelper; + + /** + * Create a new Authenticator. + * + * @param Context the current context. + * @param optional If true, unauthenticated users are allowed to continue. + * @param helper The OAuthHelper which will help with the requests. + */ + public OAuthAuthenticator(Context context, boolean optional, OAuthHelper helper) { + super(context, false, optional, null); + + mHelper = helper; + } + + protected boolean authenticate(Request request, Response response) { + /* + * The authentication workflow has three steps: + * 1. Get RequestToken + * 2. Authenticate the user + * 3. Get AccessToken + * + * The authentication workflow is broken into two stages. In the first, + * we generate the RequestToken (step 1) and redirect the user to the + * authentication page. When the user comes back, we will request the + * AccessToken (step 2). + * + * We determine which half we are in by the presence of the oauth_token + * parameter in the query string. + */ + + final String token = request.getResourceRef().getQueryAsForm().getFirstValue(OAUTH_TOKEN); + final String secret = request.getCookies().getFirstValue(COOKIE_NAME); + + try { + if (token == null) { + if (isOptional()) { + return false; + } + + // 1. Get RequestToken + Token requestToken = mHelper.getRequestToken(); + + if (requestToken == null) { + return false; + } + + // 2. Redirect user + // TODO Encrypt cookie + response.getCookieSettings().add(COOKIE_NAME, requestToken.getSecret()); + response.redirectSeeOther(mHelper.getLoginUrl(requestToken, request.getResourceRef().toString())); + return false; + + } else { + // 3. Get AccessToken + Token requestToken = new Token(token, secret); + User user = mHelper.getAccessToken(requestToken); + request.getClientInfo().setUser(user); + return true; + } + + } catch (OAuthException e) { + LOG.debug("Authentication failed: " + e); + return false; + } + } +} diff --git a/src/main/java/com/p4square/restlet/oauth/OAuthAuthenticatorHelper.java b/src/main/java/com/p4square/restlet/oauth/OAuthAuthenticatorHelper.java new file mode 100644 index 0000000..76ff044 --- /dev/null +++ b/src/main/java/com/p4square/restlet/oauth/OAuthAuthenticatorHelper.java @@ -0,0 +1,177 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.restlet.oauth; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; + +import java.net.URLEncoder; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import java.util.Collections; +import java.util.Random; + +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.ChallengeRequest; +import org.restlet.data.ChallengeResponse; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.CharacterSet; +import org.restlet.data.Form; +import org.restlet.data.Method; +import org.restlet.data.Parameter; +import org.restlet.data.Reference; +import org.restlet.engine.header.ChallengeWriter; +import org.restlet.engine.header.Header; +import org.restlet.engine.security.AuthenticatorHelper; +import org.restlet.engine.util.Base64; +import org.restlet.util.Series; + +/** + * Authentication helper for signing OAuth Requests. + * + * This implementation is limited to one consumer token/secret per restlet + * engine. In practice this means you will only be able to interact with one + * service provider unless you loaded/unloaded the AuthenticationHelper for + * each request. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class OAuthAuthenticatorHelper extends AuthenticatorHelper { + private static final String SIGNATURE_METHOD = "HMAC-SHA1"; + private static final String JAVA_SIGNATURE_METHOD = "HmacSHA1"; + private static final String ENCODING = "UTF-8"; + + private final Random mRandom; + private final Token mConsumerToken; + + /** + * Package-private constructor. + * + * This class should only be instantiated by OAuthHelper. + */ + OAuthAuthenticatorHelper(Token consumerToken) { + super(ChallengeScheme.HTTP_OAUTH, true, false); + + mRandom = new Random(); + mConsumerToken = consumerToken; + } + + @Override + public void formatRequest(ChallengeWriter cw, ChallengeRequest cr, + Response response, Series<Header> httpHeaders) throws IOException { + + throw new UnsupportedOperationException("OAuth Requests are not implemented"); + } + + @Override + public void formatResponse(ChallengeWriter cw, ChallengeResponse response, + Request request, Series<Header> httpHeaders) { + + try { + Series<Parameter> authParams = new Series<Parameter>(Parameter.class); + + String nonce = String.valueOf(mRandom.nextInt()); + String timestamp = String.valueOf(System.currentTimeMillis() / 1000); + + authParams.add(new Parameter("oauth_consumer_key", mConsumerToken.getToken())); + authParams.add(new Parameter("oauth_nonce", nonce)); + authParams.add(new Parameter("oauth_signature_method", SIGNATURE_METHOD)); + authParams.add(new Parameter("oauth_timestamp", timestamp)); + authParams.add(new Parameter("oauth_version", "1.0")); + + String accessToken = response.getIdentifier(); + if (accessToken != null) { + authParams.add(new Parameter("oauth_token", accessToken)); + } + + // Generate Signature + String signature = generateSignature(response, request, authParams); + authParams.add(new Parameter("oauth_signature", signature)); + + // Write Header + for (Parameter p : authParams) { + cw.appendQuotedChallengeParameter(encode(p.getName()), encode(p.getValue())); + } + + } catch (IOException e) { + throw new RuntimeException(e); + + } catch (InvalidKeyException e) { + throw new RuntimeException(e); + + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + /** + * Helper method to generate an OAuth Signature. + */ + private String generateSignature(ChallengeResponse response, Request request, + Series<Parameter> authParams) + throws NoSuchAlgorithmException, InvalidKeyException, IOException, + UnsupportedEncodingException { + + // HTTP Request Method + String httpMethod = request.getMethod().getName(); + + // Request Url + Reference url = request.getResourceRef(); + String requestUrl = encode(url.getScheme() + ":" + url.getHierarchicalPart()); + + // Normalized parameters + Series<Parameter> params = new Series<Parameter>(Parameter.class); + + // OAUTH Params + params.addAll(authParams); + + // Query Params + Form query = url.getQueryAsForm(); + params.addAll(query); + + // Sort it + Collections.sort(params); + + StringBuilder normalizedParamsBuilder = new StringBuilder(); + for (Parameter p : params) { + normalizedParamsBuilder.append('&'); + normalizedParamsBuilder.append(p.encode(CharacterSet.UTF_8)); + } + String normalizedParams = encode(normalizedParamsBuilder.substring(1)); // remove the first & + + // Generate signature base + String sigBase = httpMethod + "&" + requestUrl + "&" + normalizedParams.toString(); + + // Sign the signature base + Mac mac = Mac.getInstance(JAVA_SIGNATURE_METHOD); + + String accessTokenSecret = ""; + if (response.getIdentifier() != null) { + accessTokenSecret = new String(response.getSecret()); + } + + byte[] keyBytes = (encode(mConsumerToken.getSecret()) + "&" + encode(accessTokenSecret)).getBytes(ENCODING); + SecretKey key = new SecretKeySpec(keyBytes, JAVA_SIGNATURE_METHOD); + mac.init(key); + + byte[] signature = mac.doFinal(sigBase.getBytes(ENCODING)); + + return Base64.encode(signature, false).trim(); + } + + /** + * Helper method to URL Encode Strings. + */ + private String encode(String input) throws UnsupportedEncodingException { + return URLEncoder.encode(input, ENCODING); + } +} diff --git a/src/main/java/com/p4square/restlet/oauth/OAuthException.java b/src/main/java/com/p4square/restlet/oauth/OAuthException.java new file mode 100644 index 0000000..dd326d3 --- /dev/null +++ b/src/main/java/com/p4square/restlet/oauth/OAuthException.java @@ -0,0 +1,25 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.restlet.oauth; + +import org.restlet.data.Status; + +/** + * Exception throw when the service provider returns an error. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class OAuthException extends Exception { + private final Status mStatus; + + public OAuthException(Status status) { + super("Service provider failed request: " + status.getDescription()); + mStatus = status; + } + + public Status getStatus() { + return mStatus; + } +} diff --git a/src/main/java/com/p4square/restlet/oauth/OAuthHelper.java b/src/main/java/com/p4square/restlet/oauth/OAuthHelper.java new file mode 100644 index 0000000..67dd238 --- /dev/null +++ b/src/main/java/com/p4square/restlet/oauth/OAuthHelper.java @@ -0,0 +1,149 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.restlet.oauth; + +import java.net.URLEncoder; + +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.data.ChallengeResponse; +import org.restlet.data.ChallengeScheme; +import org.restlet.data.Form; +import org.restlet.data.Method; +import org.restlet.data.Reference; +import org.restlet.data.Status; +import org.restlet.engine.Engine; +import org.restlet.representation.Representation; + +/** + * Helper Class for OAuth 1.0 Authentication. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public abstract class OAuthHelper { + private final Restlet mDispatcher; + private final Token mConsumerToken; + + /** + * Create a new OAuth Helper. + * As currently implemented, there can only be one OAuthHelper per Restlet + * Engine since this class registers its own provider for the OAuth + * authentication protocol. + * + * FIXME: This could be improved by making OAuthAuthenticationHelper and + * maybe Token aware of multiple service providers. + * + * @param context The restlet context which provides a ClientDispatcher. + * @param consumerKey The OAuth consumer key for this application. + * @param consumerSecret the OAuth consumer secret for this application. + */ + public OAuthHelper(Context context, String consumerKey, String consumerSecret) { + mDispatcher = context.getClientDispatcher(); + mConsumerToken = new Token(consumerKey, consumerSecret); + + Engine.getInstance().getRegisteredAuthenticators().add(new OAuthAuthenticatorHelper(mConsumerToken)); + } + + /** + * @return the URL for the initial RequestToken request. + */ + protected abstract String getRequestTokenUrl(); + + /** + * Request a RequestToken. + * + * @return a Token containing the RequestToken. + * @throws OAuthException if the request fails. + */ + public Token getRequestToken() throws OAuthException { + Request request = new Request(Method.GET, getRequestTokenUrl()); + request.setChallengeResponse(new ChallengeResponse(ChallengeScheme.HTTP_OAUTH)); + + Response response = mDispatcher.handle(request); + + return processTokenRequest(response); + } + + /** + * @return the URL to redirect the user to for Authentication. + */ + public abstract String getLoginUrl(Token requestToken, String callback); + + /** + * @return the URL for the AccessToken request. + */ + protected abstract String getAccessTokenUrl(); + + /** + * Request an AccessToken for a previously authenticated RequestToken. + * + * @return an OAuthUser object containing the AccessToken. + * @throws OAuthException if the request fails. + */ + public OAuthUser getAccessToken(Token requestToken) throws OAuthException { + Request request = new Request(Method.GET, getAccessTokenUrl()); + request.setChallengeResponse(requestToken.getChallengeResponse()); + + return processAccessTokenRequest(request); + } + + /** + * Helper method to decode the token returned from an OAuth Request. + * + * @param response The Response object from the Request. + * @return the Token from the oauth_token and oauth_token_secret parameters. + * @throws OAuthException is the server reported an error. + */ + protected Token processTokenRequest(Response response) throws OAuthException { + Status status = response.getStatus(); + Representation entity = response.getEntity(); + + try { + if (status.isSuccess()) { + Form form = new Form(entity); + String token = form.getFirstValue("oauth_token"); + String secret = form.getFirstValue("oauth_token_secret"); + + return new Token(token, secret); + + } else { + throw new OAuthException(status); + } + } finally { + entity.release(); + } + } + + /** + * Helper method to create an OAuthUser from the AccessToken request. + * + * The User's identifier is set to the Content-Location header, if present. + * + * @param response The Response to the AccessToken Request. + * @return An OAuthUser object wrapping the AccessToken. + * @throws OAuthException if the request failed. + */ + public OAuthUser processAccessTokenRequest(Request request) throws OAuthException { + Response response = getResponse(request); + Token accessToken = processTokenRequest(response); + + Reference ref = response.getEntity().getLocationRef(); + if (ref != null) { + return new OAuthUser(ref.toString(), accessToken); + + } else { + return new OAuthUser(accessToken); + } + } + + /** + * Helper method to get a Response for a Request. + */ + public Response getResponse(Request request) { + return mDispatcher.handle(request); + } +} diff --git a/src/main/java/com/p4square/restlet/oauth/OAuthUser.java b/src/main/java/com/p4square/restlet/oauth/OAuthUser.java new file mode 100644 index 0000000..11dbac1 --- /dev/null +++ b/src/main/java/com/p4square/restlet/oauth/OAuthUser.java @@ -0,0 +1,50 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.restlet.oauth; + +import org.restlet.data.ChallengeResponse; +import org.restlet.security.User; + +/** + * Simple User object which also contains an OAuth AccessToken. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class OAuthUser extends User { + private final Token mToken; + private final String mContentLocation; + + public OAuthUser(Token token) { + this(null, token); + } + + public OAuthUser(String location, Token token) { + super(); + mToken = token; + mContentLocation = location; + } + + /** + * @return the Location associated with the user. + */ + public String getLocation() { + return mContentLocation; + } + + /** + * @return The AccessToken. + */ + public Token getToken() { + return mToken; + } + + /** + * Convenience method for getToken().getChallengeResponse(). + * @return A ChallengeResponse based upon the access token. + */ + public ChallengeResponse getChallengeResponse() { + return mToken.getChallengeResponse(); + } +} diff --git a/src/main/java/com/p4square/restlet/oauth/Token.java b/src/main/java/com/p4square/restlet/oauth/Token.java new file mode 100644 index 0000000..51a9087 --- /dev/null +++ b/src/main/java/com/p4square/restlet/oauth/Token.java @@ -0,0 +1,52 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.restlet.oauth; + +import org.restlet.data.ChallengeResponse; +import org.restlet.data.ChallengeScheme; + +/** + * Token wraps the two Strings which make up an OAuth Token: the public + * component and the private component. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class Token { + private final String mToken; + private final String mSecret; + + public Token(String token, String secret) { + mToken = token; + mSecret = secret; + } + + /** + * @return the public component. + */ + public String getToken() { + return mToken; + } + + /** + * @return the secret component. + */ + public String getSecret() { + return mSecret; + } + + @Override + public String toString() { + return mToken + "&" + mSecret; + } + + /** + * Generate a ChallengeResponse based on this Token. + * + * @return a ChallengeResponse object using the OAUTH ChallengeScheme. + */ + public ChallengeResponse getChallengeResponse() { + return new ChallengeResponse(ChallengeScheme.HTTP_OAUTH, mToken, mSecret); + } +} diff --git a/src/main/java/com/p4square/session/Session.java b/src/main/java/com/p4square/session/Session.java new file mode 100644 index 0000000..1bb65f5 --- /dev/null +++ b/src/main/java/com/p4square/session/Session.java @@ -0,0 +1,59 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.session; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.restlet.security.User; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class Session { + static final long LIFETIME = 86400000; + + private final String mSessionId; + private final User mUser; + private final Map<String, String> mData; + private long mExpires; + + Session(User user) { + mUser = user; + mSessionId = UUID.randomUUID().toString(); + mExpires = System.currentTimeMillis() + LIFETIME; + mData = new HashMap<String, String>(); + } + + void touch() { + mExpires = System.currentTimeMillis() + LIFETIME; + } + + boolean isExpired() { + return System.currentTimeMillis() > mExpires; + } + + public String getId() { + return mSessionId; + } + + public Object get(String key) { + return mData.get(key); + } + + public void put(String key, String value) { + mData.put(key, value); + } + + public User getUser() { + return mUser; + } + + public Map<String, String> getMap() { + return mData; + } +} diff --git a/src/main/java/com/p4square/session/SessionAuthenticator.java b/src/main/java/com/p4square/session/SessionAuthenticator.java new file mode 100644 index 0000000..794e1a8 --- /dev/null +++ b/src/main/java/com/p4square/session/SessionAuthenticator.java @@ -0,0 +1,36 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.session; + +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.security.Authenticator; +import org.restlet.security.User; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SessionAuthenticator /*extends Authenticator*/ { + /* + @Override + protected boolean authenticate(Request request, Response response) { + // Check for authentication cookie + final String cookie = request.getCookies().getFirstValue(COOKIE_NAME); + if (cookie != null) { + cLog.debug("Got cookie: " + cookie); + // TODO Decrypt user info + User user = new User(cookie); + request.getClientInfo().setUser(user); + return true; + } + + // Challenge the user if not authenticated + response.redirectSeeOther(mLoginPage); + return false; + } + */ +} diff --git a/src/main/java/com/p4square/session/SessionCheckingAuthenticator.java b/src/main/java/com/p4square/session/SessionCheckingAuthenticator.java new file mode 100644 index 0000000..489d6a0 --- /dev/null +++ b/src/main/java/com/p4square/session/SessionCheckingAuthenticator.java @@ -0,0 +1,39 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.session; + +import org.apache.log4j.Logger; + +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.security.Authenticator; + +/** + * Authenticator which succeeds if a valid Session exists. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SessionCheckingAuthenticator extends Authenticator { + private static final Logger LOG = Logger.getLogger(SessionCheckingAuthenticator.class); + + public SessionCheckingAuthenticator(Context context, boolean optional) { + super(context, optional); + } + + protected boolean authenticate(Request request, Response response) { + Session s = Sessions.getInstance().get(request); + + if (s != null) { + LOG.debug("Found session for user " + s.getUser()); + request.getClientInfo().setUser(s.getUser()); + return true; + + } else { + return false; + } + } + +} diff --git a/src/main/java/com/p4square/session/SessionCookieAuthenticator.java b/src/main/java/com/p4square/session/SessionCookieAuthenticator.java new file mode 100644 index 0000000..0074b77 --- /dev/null +++ b/src/main/java/com/p4square/session/SessionCookieAuthenticator.java @@ -0,0 +1,59 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.session; + +import org.apache.log4j.Logger; + +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.security.Authenticator; + +/** + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SessionCookieAuthenticator extends Authenticator { + private static final Logger LOG = Logger.getLogger(SessionCookieAuthenticator.class); + + private static final String COOKIE_NAME = "S"; + + private final Sessions mSessions; + + public SessionCookieAuthenticator(Context context, boolean optional, Sessions sessions) { + super(context, optional); + + mSessions = sessions; + } + + protected boolean authenticate(Request request, Response response) { + final String cookie = request.getCookies().getFirstValue(COOKIE_NAME); + + if (request.getClientInfo().isAuthenticated()) { + // Request is already authenticated... create session if it doesn't exist. + if (cookie == null) { + Session s = mSessions.create(request.getClientInfo().getUser()); + response.getCookieSettings().add(COOKIE_NAME, s.getId()); + } + + return true; + + } else { + // Check for authentication cookie + if (cookie != null) { + LOG.debug("Got cookie: " + cookie); + + Session s = mSessions.get(cookie); + if (s != null) { + request.getClientInfo().setUser(s.getUser()); + return true; + } + } + + return false; + } + } + +} diff --git a/src/main/java/com/p4square/session/SessionCreatingAuthenticator.java b/src/main/java/com/p4square/session/SessionCreatingAuthenticator.java new file mode 100644 index 0000000..3ec14b4 --- /dev/null +++ b/src/main/java/com/p4square/session/SessionCreatingAuthenticator.java @@ -0,0 +1,46 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.session; + +import org.apache.log4j.Logger; + +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.security.Authenticator; +import org.restlet.security.User; + +/** + * Authenticator which creates a Session for the request and adds a cookie + * to the response. + * + * The Request MUST be Authenticated and MUST have a User object associated. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class SessionCreatingAuthenticator extends Authenticator { + private static final Logger LOG = Logger.getLogger(SessionCreatingAuthenticator.class); + + public SessionCreatingAuthenticator(Context context) { + super(context, true); + } + + protected boolean authenticate(Request request, Response response) { + if (Sessions.getInstance().get(request) != null) { + return true; + } + + User user = request.getClientInfo().getUser(); + + if (request.getClientInfo().isAuthenticated() && user != null) { + Sessions.getInstance().create(request, response); + LOG.debug(response); + return true; + } + + return false; + } + +} diff --git a/src/main/java/com/p4square/session/Sessions.java b/src/main/java/com/p4square/session/Sessions.java new file mode 100644 index 0000000..9f9dda0 --- /dev/null +++ b/src/main/java/com/p4square/session/Sessions.java @@ -0,0 +1,155 @@ +/* + * Copyright 2013 Jesse Morgan + */ + +package com.p4square.session; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; + +import org.restlet.Response; +import org.restlet.Request; +import org.restlet.data.CookieSetting; +import org.restlet.security.User; + +/** + * Singleton Session Manager. + * + * @author Jesse Morgan <jesse@jesterpm.net> + */ +public class Sessions { + private static final String COOKIE_NAME = "S"; + private static final int DELETE = 0; + + private static final Sessions THE = new Sessions(); + public static Sessions getInstance() { + return THE; + } + + private final Map<String, Session> mSessions; + private final Timer mCleanupTimer; + + private Sessions() { + mSessions = new ConcurrentHashMap<String, Session>(); + + mCleanupTimer = new Timer("sessionCleaner", true); + mCleanupTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + for (Session s : mSessions.values()) { + if (s.isExpired()) { + mSessions.remove(s.getId()); + } + } + } + }, Session.LIFETIME, Session.LIFETIME); + } + + /** + * Get a session by ID. + * + * @param sessionid + * The Session id + * @return The Session if found and not expired, null otherwise. + */ + public Session get(String sessionid) { + Session s = mSessions.get(sessionid); + + if (s != null && !s.isExpired()) { + s.touch(); + return s; + } + + return null; + } + + /** + * Get the Session associated with the Request. + * + * @param request + * The request to fetch a session for. + * @return A session or null if no session is found. + */ + public Session get(Request request) { + final String cookie = request.getCookies().getFirstValue(COOKIE_NAME); + + if (cookie != null) { + return get(cookie); + } + + return null; + } + + /** + * Create a new Session for the given User object. + * + * @param user + * The User to associate with the Session. + * @return The new Session object. + */ + public Session create(User user) { + if (user == null) { + throw new IllegalArgumentException("Can not create session for null user."); + } + + Session s = new Session(user); + mSessions.put(s.getId(), s); + + return s; + } + + /** + * Delete a Session. + * + * @param sessionid + * The id of the Session to remove. + */ + public void delete(String sessionid) { + mSessions.remove(sessionid); + } + + /** + * Create a new Session and add the Session cookie to the response. + * + * @param request + * The request to create the Session for. + * @param response + * The response to add the session cookie to. + * @return The new Session. + */ + public Session create(Request request, Response response) { + Session s = create(request.getClientInfo().getUser()); + + CookieSetting cookie = new CookieSetting(COOKIE_NAME, s.getId()); + cookie.setPath("/"); + + request.getCookies().add(cookie); + response.getCookieSettings().add(cookie); + + return s; + } + + /** + * Remove a Session and delete the cookies. + * + * @param request + * The request with the session cookie to remove + * @param response + * The response to remove the session cookie from. + */ + public void delete(Request request, Response response) { + final String sessionid = request.getCookies().getFirstValue(COOKIE_NAME); + + delete(sessionid); + + CookieSetting cookie = new CookieSetting(COOKIE_NAME, ""); + cookie.setPath("/"); + cookie.setMaxAge(DELETE); + + request.getCookies().add(cookie); + response.getCookieSettings().add(cookie); + } + +} |