summaryrefslogtreecommitdiff
path: root/src/main/java/com/p4square/grow/backend
diff options
context:
space:
mode:
authorJesse Morgan <jesse@jesterpm.net>2016-04-09 15:53:24 -0700
committerJesse Morgan <jesse@jesterpm.net>2016-04-09 15:53:24 -0700
commit371ccae3d1f31ec38f4af77fb7fcd175d49b3cd5 (patch)
tree38c4f1e8828f9af9c4b77a173bee0d312b321698 /src/main/java/com/p4square/grow/backend
parentbbf907e51dfcf157bdee24dead1d531122aa25db (diff)
parent3102d8bce3426d9cf41aeaf201c360d342677770 (diff)
Merge pull request #10 from PuyallupFoursquare/maven
Switching from Ivy+Ant to Maven.
Diffstat (limited to 'src/main/java/com/p4square/grow/backend')
-rw-r--r--src/main/java/com/p4square/grow/backend/BackendVerifier.java92
-rw-r--r--src/main/java/com/p4square/grow/backend/CassandraGrowData.java172
-rw-r--r--src/main/java/com/p4square/grow/backend/DynamoGrowData.java180
-rw-r--r--src/main/java/com/p4square/grow/backend/GrowBackend.java211
-rw-r--r--src/main/java/com/p4square/grow/backend/GrowData.java36
-rw-r--r--src/main/java/com/p4square/grow/backend/db/CassandraCollectionProvider.java109
-rw-r--r--src/main/java/com/p4square/grow/backend/db/CassandraDatabase.java212
-rw-r--r--src/main/java/com/p4square/grow/backend/db/CassandraKey.java34
-rw-r--r--src/main/java/com/p4square/grow/backend/db/CassandraProviderImpl.java37
-rw-r--r--src/main/java/com/p4square/grow/backend/db/CassandraTrainingRecordProvider.java71
-rw-r--r--src/main/java/com/p4square/grow/backend/dynamo/DbTool.java481
-rw-r--r--src/main/java/com/p4square/grow/backend/dynamo/DynamoCollectionProviderImpl.java109
-rw-r--r--src/main/java/com/p4square/grow/backend/dynamo/DynamoDatabase.java307
-rw-r--r--src/main/java/com/p4square/grow/backend/dynamo/DynamoKey.java56
-rw-r--r--src/main/java/com/p4square/grow/backend/dynamo/DynamoProviderImpl.java37
-rw-r--r--src/main/java/com/p4square/grow/backend/feed/FeedDataProvider.java33
-rw-r--r--src/main/java/com/p4square/grow/backend/feed/ThreadResource.java106
-rw-r--r--src/main/java/com/p4square/grow/backend/feed/TopicResource.java117
-rw-r--r--src/main/java/com/p4square/grow/backend/resources/AccountResource.java87
-rw-r--r--src/main/java/com/p4square/grow/backend/resources/BannerResource.java85
-rw-r--r--src/main/java/com/p4square/grow/backend/resources/SurveyResource.java115
-rw-r--r--src/main/java/com/p4square/grow/backend/resources/SurveyResultsResource.java253
-rw-r--r--src/main/java/com/p4square/grow/backend/resources/TrainingRecordResource.java235
-rw-r--r--src/main/java/com/p4square/grow/backend/resources/TrainingResource.java97
24 files changed, 3272 insertions, 0 deletions
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;
+ }
+ }
+}