diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..4249f36 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,12 @@ +module.exports = { + "extends": "airbnb-base", + "env": { + "node": true + }, + "rules": { + "strict": "off", + "no-console": "off", + "import/no-unresolved": "off", + "prefer-destructuring": "off" + } +} \ No newline at end of file diff --git a/README.md b/README.md index 834bfb5..1475bfb 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,82 @@ -# Restful Lambda API - post-service +# Serverless Q&A Template API -An [AWS Lambda](https://aws.amazon.com/lambda/) solution written using the [Serverless Toolkit](http://serverless.com) with a [DynamoDB](https://aws.amazon.com/dynamodb) backend. +A big data, serverless Q&A template API with robust CRUD functionality that can be easily built upon and used for everyday services such as discussion forums, comments and surveys. Deployed to AWS using the [Serverless Framework](http://serverless.com). -The service includes pagination, key/value searches plus a collection of common needs including but not limited too, order/by and limit clauses. +Includes pagination and global secondary indexes for retrieiving by user, thread or unique key and is multi-tenancy ready. Loosely inspired and modeled upon the [AWS Example Forum](http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/SampleData.CreateTables.html) and designed to be implemented as part of a distributed system. + +## Technology Stack +1. [AWS Lambda](https://aws.amazon.com/lambda/) +2. [AWS DynamoDB](https://aws.amazon.com/dynamodb) +3. [AWS API Gateway](https://aws.amazon.com/api-gateway) +3. [AWS Cloudwatch](https://aws.amazon.com/cloudwatch) +4. [Serverless Framework](http://serverless.com) +5. [NodeJs](https://nodejs.org/) ## Installation & Deployment +Deploying the Q&A service will provision and create the following resources. + +1. API Gateway entitled qa-service with 10 endpoints. +2. 10 * Lambda functions with associated Cloud Watch logs. +3. 2 * DynamoDB tables called Thread and Reply. -NOTE: To deploy from your desktop you must have an existing AWS account and command line access. +To deploy from your desktop you must have an existing AWS account with command line access. -Firstly, ensure you have installed the [Serverless Toolkit](http://serverless.com). +Firstly, install the [Serverless Framework](http://serverless.com). +``` npm install serverless -g +``` + +Secondly, install the [Serverless Framework](http://serverless.com) dependencies. + +``` + npm install +``` + +Next, install your Q&A service dependencies. + +``` + npm run-script buildapi +``` -Then, from the project root folder simply enter the following command to provision and deploy your sevice to AWS. +Lasty, deploy your Q&A service. +``` sls deploy +``` + +If you wish to load test data into your application you can run the loadData script. + +``` + ./loadData.sh +``` + +## Removal +To remove the solution from AWS at the command line + +``` + sls remove +``` + +NOTE: Will automatically remove any Lambda functions, Cloud Watch logs and API Gateway configurations. It will +not remove DynamoDb tables; They must be deleted manually. + +## Lambda Functions and EndPoints +Will create 10 Lambda functions accessible via [API Gateway](https://aws.amazon.com/api-gateway/) configured endpoints. -## EndPoints +NAME | LAMBDA | GATEWAY URL | VERB | DESCRIPTION +---- | ------ | --- | ---- | ----------- +CREATE | threadCreate | /threads | POST | Create a new item in permanent storage. +LIST | threadList | /threads | GET | Retrieve a paginated listing from permanent storage. +GET | threadGet | /threads/:id | GET | Retrieve a individual item using the ```threadid``` or ```userid``` passed in the query string. +UPDATE | threadUpdate| /threads/:id | PUT | Update details of a post by providing a full array of model data. +DELETE | threadDelete | /threads/:id | DELETE | Remove an item from permanent storage. +CREATE | replyCreate | /replies | POST | Create a new item in permanent storage. +LIST | replyList | /replies | GET | Retrieve a paginated listing from permanent storage. +GET | replyGet | /replies/:id | GET | Retrieve a individual item using the ```threadid``` or ```userid``` passed in the query string. +UPDATE | replyUpdate | /replies/:id | PUT | Update details of a post by providing a full array of model data. +DELETE | replyDelete | /replies/:id | DELETE | Remove an item from permanent storage. -NAME | URL | VERB | DESCRIPTION ----- | --- | ---- | ----------- -CREATE | /posts | POST | Create a new item in permanent storage -LIST | /posts | GET | Retrieve a paginated listing from permanent storage -GET | /posts/:id | GET | Retrieve a individual item using the id passed as a route parameter -UPDATE | /posts/:id | PUT | Update details of a post by providing a full array of model data -EDIT | /posts/:id | PATCH | Update details of a post by providing only those elements you wish to update -DELETE | /posts/:id | DELETE | Remove an item from permanent storage ## Issues -Please report any bugs on the [Issue Tracker](https://github.com/jacksoncharles/post-service/issues). \ No newline at end of file +Please report any feedback on the [Issue Tracker](https://github.com/jacksoncharles/serverless-qa-template/issues). \ No newline at end of file diff --git a/api/_classes/Dynamic.js b/api/_classes/Dynamic.js new file mode 100644 index 0000000..fedf85f --- /dev/null +++ b/api/_classes/Dynamic.js @@ -0,0 +1,161 @@ +'use strict'; + +const AWS = require('aws-sdk'); + +/** Set the promise library to the default global */ +AWS.config.setPromisesDependency(null); + +const dynamodb = new AWS.DynamoDB.DocumentClient(); + +var Errors = require("./Errors"); +var ValidationError = Errors.ValidationError; +var NotFoundError = Errors.NotFoundError; + +/** + * Wrapper for DynamoDb with basic CRUD functionality. + * + * @type {Class} + */ +module.exports = class Dynamic { + + constructor( parameters ) { + + /** Grab all the parameters and assign as class properties */ + Object.assign(this, parameters ); + } + + /** + * Save the current instance to permanent storage creating a new record or updating an existing record + * + * @return {Promise} + */ + save() { + + var self = this; + + /** @type {Object} Holds the parameters for the get request */ + const parameters = { + + TableName : self.constructor.table(), + Item : self.properties() + } + + // Save to permanent storage + return dynamodb.put( parameters ).promise().then( + + function(data) { + + /** @type {Object} Create a new instance of self and populate with the data */ + return self.constructor.model( parameters.Item ); + }, + function(error) { + + console.log('<<>>', error); + } + ); + } + + /** + * Retrieve an array of replies according to the parameters passed + * + * @return {array} Array of replies + */ + static find( id ) { + + var self = this; + + /** @type {Object} Holds the parameters for the get request */ + const parameters = { + + TableName : self.table(), + Key : { + Id : id + } + } + + /** Run a dynamodb get request passing-in our parameters */ + return dynamodb.get( parameters ).promise().then( + + // Successful response will be automatically resolve + // the promise using the 'complete' event + function(data) { + + /** @type {Object} Create a new instance of self and populate with the data */ + return self.model( data.Item ); + }, + function(error) { + + console.log('<<>>', error); + } + ); + } + + /** + * Retrieve an array of replies according to the parameters passed + * + * @return {array} Array of replies + */ + static list( parameters ) { + + var self = this; + + /** Run a dynamodb query passing-in Query.parameters */ + return dynamodb.query( parameters ).promise().then( + + // Successful response will be automatically resolve + // the promise using the 'complete' event + function(data) { + + let items = []; + + for ( let item of data.Items ) { + + items.push( self.model( item ) ); + } + + data['Items'] = items; + + /** All successful. Create a valid response */ + return data; + }, + function(error) { + + console.log('<<>>', error); + } + ); + } + + /** + * Retrieve an array of replies according to the parameters passed + * + * @return {Array} Array of replies. + */ + static destroy( id ) { + + var self = this; + + /** @type {Object} Holds the parameters for the get request */ + const parameters = { + + TableName : self.table(), + Key : { + Id : id + } + } + + /** Run a dynamodb get request passing-in our parameters */ + return dynamodb.delete( parameters ).promise().then( + + // Successful response will be automatically resolve + // the promise using the 'complete' event + function(data) { + + return JSON.stringify( data ); + }, + function(error) { + + console.log('<<>>', error); + } + ); + } +} \ No newline at end of file diff --git a/api/_classes/Errors.js b/api/_classes/Errors.js new file mode 100644 index 0000000..17e59fd --- /dev/null +++ b/api/_classes/Errors.js @@ -0,0 +1,20 @@ +'use strict'; + +/** + * + * + * @type {class} + */ +class NotFoundError extends Error {} + +/** + * + * + * @type {class} + */ +class ValidationError extends Error {} + +module.exports = { + NotFoundError : NotFoundError, + ValidationError : ValidationError +} \ No newline at end of file diff --git a/api/create.js b/api/create.js deleted file mode 100644 index 913c7af..0000000 --- a/api/create.js +++ /dev/null @@ -1,94 +0,0 @@ -'use strict'; - -const uuid = require('uuid'); -const AWS = require('aws-sdk'); - -AWS.config.setPromisesDependency(require('bluebird')); - -const dynamoDb = new AWS.DynamoDB.DocumentClient(); - -module.exports.create = (event, context, callback) => { - - const requestBody = JSON.parse(event.body); // User submitted data - - // Grab the individual elements of the post. - const userId = requestBody.userId; - const parentId = requestBody.parentId; - const correctAnswer = requestBody.correctAnswer; - const title = requestBody.title; - const body = requestBody.body; - - /** - * Save the post to permanent storage - * - * @param {object} post [New post] - * @return {array} [The newly created post] - */ - const submitPost = post => { - - console.log('Submitting post'); - - const postDetail = { - TableName: process.env.DYNAMODB_TABLE, - Item: post, - }; - - return dynamoDb.put(postDetail).promise() - .then(res => post); - }; - - /** - * Format the post ready for saving to permanent storage - * - * @param {Array} data [User submitted data] - * @return {Object} [Individual post formatted as an object] - */ - const formatPost = (data) => { - - const timestamp = new Date().getTime(); - return { - id: uuid.v1(), - userId: data.userId, - parentId: data.parentId, - correctAnswer: data.correctAnswer, - title: data.title, - body: data.body, - createdAt: timestamp, - updatedAt: timestamp, - dummyHashKey: 'OK' - }; - }; - - // Validate the submitted data - if ( - typeof title !== 'string' || - typeof body !== 'string' || - typeof userId !== 'number' - ) { - console.error('Validation Failed'); - callback(new Error('Couldn\'t submit post because of validation errors.')); - return; - } - - // Submit the post and respond accordingly - submitPost(formatPost(requestBody)) - .then(res => { - callback(null, { - statusCode: 200, - body: JSON.stringify({ - message: `Sucessfully submitted post`, - postId: res.id - }) - }); - }) - .catch(err => { - console.log(err); - callback(null, { - statusCode: 500, - body: JSON.stringify({ - message: `Unable to submit post` - }) - }) - }); - -}; \ No newline at end of file diff --git a/api/delete.js b/api/delete.js deleted file mode 100644 index d3eb6a8..0000000 --- a/api/delete.js +++ /dev/null @@ -1,41 +0,0 @@ -'use strict'; - -const AWS = require('aws-sdk'); // eslint-disable-line import/no-extraneous-dependencies - -const dynamoDb = new AWS.DynamoDB.DocumentClient(); - -module.exports.delete = (event, context, callback) => { - - const params = { - TableName: process.env.DYNAMODB_TABLE, - Key: { - id: event.pathParameters.id, - }, - }; - - // delete the post from the database - dynamoDb.delete(params, (error) => { - - // handle potential errors - if (error) { - - console.error('=== error ===', error); - - callback(null, { - statusCode: error.statusCode || 501, - headers: { 'Content-Type': 'text/plain' }, - body: 'Couldn\'t remove the post item.', - }); - return; - } - - // create a response - const response = { - statusCode: 200, - body: JSON.stringify({}), - }; - - callback(null, response); - - }); -}; \ No newline at end of file diff --git a/api/get.js b/api/get.js deleted file mode 100644 index f5806d8..0000000 --- a/api/get.js +++ /dev/null @@ -1,41 +0,0 @@ -'use strict'; - -const AWS = require('aws-sdk'); // eslint-disable-line import/no-extraneous-dependencies - -const dynamoDb = new AWS.DynamoDB.DocumentClient(); - -module.exports.get = (event, context, callback) => { - - const params = { - TableName: process.env.DYNAMODB_TABLE, - Key: { - id: event.pathParameters.id, - }, - }; - - // fetch post from the database - dynamoDb.get(params, (error, result) => { - - // handle potential errors - if (error) { - - console.error('=== error ===', error); - - callback(null, { - statusCode: error.statusCode || 501, - headers: { 'Content-Type': 'text/plain' }, - body: 'Couldn\'t fetch the post item.', - }); - return; - } - - // create a response - const response = { - - statusCode: 200, - body: JSON.stringify(result.Item), - }; - - callback(null, response); - }); -}; \ No newline at end of file diff --git a/api/list.js b/api/list.js deleted file mode 100644 index a7d153e..0000000 --- a/api/list.js +++ /dev/null @@ -1,95 +0,0 @@ -'use strict'; - -const AWS = require('aws-sdk'); // eslint-disable-line import/no-extraneous-dependencies -const dynamoDb = new AWS.DynamoDB.DocumentClient(); - -/* -var params = { - TableName: process.env.DYNAMODB_TABLE, - Limit: 10 -}; - -var params = { - - TableName: process.env.DYNAMODB_TABLE, - IndexName: "UpdatedAtIndex", - ScanIndexForward: true -}; -*/ - -const params = { - TableName: process.env.DYNAMODB_TABLE, - Limit: 5, - IndexName: 'UpdatedAtIndex', - KeyConditionExpression: 'dummyHashKey = :x', - ExpressionAttributeValues: { - ':x': 'OK' - }, - ProjectionExpression: "id, userId, parentId, correctAnswer, title, body, createdAt, updatedAt" -} - - -module.exports.list = (event, context, callback) => { - - // Do we have any parameters? - /* - if( event.queryStringParameters !== null && typeof event.queryStringParameters === 'object' ) { - - // Do we have a limit clause? - if( event.queryStringParameters.hasOwnProperty('limit') ) params.Limit = event.queryStringParameters.limit; - - // Do we have a page number? - } - */ - dynamoDb.query(params, function(error, data) { - - // Handle potential errors - if (error) { - - console.error(error); - callback(null, { - statusCode: error.statusCode || 501, - headers: { 'Content-Type': 'text/plain' }, - body: 'Couldn\'t fetch the posts.', - }); - - return; - } - - // Create a response - const response = { - statusCode: 200, - body: JSON.stringify(data), - }; - - callback(null, response); - - }); - - // Fetch all posts from the database - /* - dynamoDb.scan(params, (error, result) => { - - // Handle potential errors - if (error) { - - console.error(error); - callback(null, { - statusCode: error.statusCode || 501, - headers: { 'Content-Type': 'text/plain' }, - body: 'Couldn\'t fetch the posts.', - }); - - return; - } - - // Create a response - const response = { - statusCode: 200, - body: JSON.stringify(result.Items), - }; - - callback(null, response); - }); - */ -}; \ No newline at end of file diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..f3244a8 --- /dev/null +++ b/api/package.json @@ -0,0 +1,18 @@ +{ + "name": "forumapi", + "version": "0.0.1", + "description": "Serverless module dependencies", + "author": "me", + "license": "MIT", + "private": true, + "repository": { + "type": "git", + "url": "git://github.com/" + }, + "keywords": [], + "devDependencies": {}, + "dependencies": { + "validator": "^9.1.2", + "uuid": "^3.1.0" + } +} \ No newline at end of file diff --git a/api/replies/_classes/Reply.js b/api/replies/_classes/Reply.js new file mode 100644 index 0000000..5c06dcb --- /dev/null +++ b/api/replies/_classes/Reply.js @@ -0,0 +1,75 @@ +'use strict'; + +const validator = require('validator'); + +const Dynamic = require('./../../_classes/Dynamic'); + +const Errors = require('./../../_classes/Errors'); + +const ValidationError = Errors.ValidationError; + + +/** + * Reply class. Each instance maps to one document in permanent storage and extends the + * Dynamic wrapper class. + * + * @type {class} + */ +module.exports = class Reply extends Dynamic { + /** + * Return a string containing the name of the table in perment storage + * for the model. + * + * @return {String} - Name of the string + */ + static table() { + return 'Reply'; + } + + /** + * Return a new instance of this + * + * @param {Object} parameters - Properties to be assigned to the newly created object + * + * @return {Object} New instance of the Reply object + */ + static model(parameters) { + return new Reply(parameters); + } + + /** + * Return an object that represents the properties of this class + * + * @return {Object} - Class properties that can be saved to dynamodb + */ + properties() { + return { + Id: this.Id, + ThreadId: this.ThreadId, + UserId: this.UserId, + UserName: this.UserName, + Message: this.Message, + CreatedDateTime: this.CreatedDateTime, + UpdatedDateTime: this.UpdatedDateTime, + }; + } + + /** + * Validate the class properties and throw an exception if necessary + * + * @return {this} - Instance of this + */ + validate() { + const errors = []; // Create an empty array to hold any validation errors + + if (typeof this.Id === 'undefined' || validator.isEmpty(this.Id)) errors.push({ Id: 'must provide a unique string for Id' }); + if (typeof this.Message === 'undefined' || validator.isEmpty(this.Message)) errors.push({ Message: 'must provide a value for Message' }); + if (typeof this.ThreadId === 'undefined' || validator.isEmpty(this.ThreadId)) errors.push({ ThreadId: 'must provide a value for ThreadId' }); + if (typeof this.UserId === 'undefined' || validator.isEmpty(this.UserId)) errors.push({ UserId: 'must provide a value for UserId' }); + if (typeof this.UserName === 'undefined' || validator.isEmpty(this.UserName)) errors.push({ UserName: 'must provide a value for UserName' }); + + if (errors.length) throw new ValidationError(JSON.stringify(errors)); + + return this; + } +}; diff --git a/api/replies/_classes/ReplyQueryBuilder.js b/api/replies/_classes/ReplyQueryBuilder.js new file mode 100644 index 0000000..f0e0708 --- /dev/null +++ b/api/replies/_classes/ReplyQueryBuilder.js @@ -0,0 +1,171 @@ +'use strict'; + +const validator = require('validator'); + +const Errors = require('./../../_classes/Errors'); + +const ValidationError = Errors.ValidationError; + +/** + * Responsible for turning parameters passeed are turned in DynamoDb parameters by building + * this.parameters object using data passed inside this.events.queryStringParameters + * + * @type {class} + */ +module.exports = class ReplyQueryBuilder { + constructor(criterion) { + /** @type {Object} Key/value pairs used to build our DynamoDb parameters. */ + this.criterion = criterion; + + /** + * Used to hold the dynamodb query parameters built using values + * within property this.event + * + * @todo : Change TableName to value of process.env.DYNAMODB_REPLY_TABLE + * + * @type {object} + */ + this.params = { + TableName: 'Reply', + }; + + /** + * Used to hold any validation errors. + * + * @type {array} + */ + this.failures = []; + } + + /** + * Getter + * + * @return {object} parameters + */ + get parameters() { + return this.params; + } + + /** + * Setter + * + * @return {object} parameters + */ + set parameters(params) { + this.params = params; + } + + /** + * Getter + * + * @return {array} errors + */ + get errors() { + return this.failures; + } + + /** + * Setter + * + * @return {array} failures + */ + set errors(failures) { + this.failures = failures; + } + + /** + * Validates the parameters passed inside this.criterion object + * + * @return {boolean} + */ + validate() { + this.errors = []; // Reset the errors array before running the validation logic + + if (this.criterion !== null && typeof this.criterion === 'object') { + if (Object.prototype.hasOwnProperty.call(this.criterion, 'threadid') === false && + Object.prototype.hasOwnProperty.call(this.criterion, 'userid') === false + ) { + this.errors.push({ message: 'You must provide a threadid or userid parameter' }); + } + + if (Object.prototype.hasOwnProperty.call(this.criterion, 'threadid')) { + if (validator.isAlphanumeric(this.criterion.threadid) === false) { + this.errors.push({ message: 'Your threadid parameter must be an alphanumeric string' }); + } + } + + if (Object.prototype.hasOwnProperty.call(this.criterion, 'userid')) { + if (validator.isNumeric(this.criterion.userid) === false) { + this.errors.push({ message: 'Your userid parameter must be numeric' }); + } + } + } else { + this.errors.push({ message: 'You must supply a threadid or userid' }); + } + + if (this.errors.length) { + throw new ValidationError(JSON.stringify(this.errors)); + } + + return this; + } + + /** + * If "threadid" has been passed inside this.event this method will build + * upon this.parameters object + * + * @return this + */ + buildThreadIndex() { + if (Object.prototype.hasOwnProperty.call(this.criterion, 'threadid')) { + this.parameters.IndexName = 'ThreadIndex'; + this.parameters.KeyConditionExpression = 'ThreadId = :searchstring'; + this.parameters.ExpressionAttributeValues = { + ':searchstring': this.criterion.threadid, + }; + } + + return this; + } + + /** + * If "userid" has been passed inside this.event this method will build upon + * this.parameters object. + * + * @return this + */ + buildUserIndex() { + if (Object.prototype.hasOwnProperty.call(this.criterion, 'userid')) { + this.parameters.IndexName = 'UserIndex'; + this.parameters.KeyConditionExpression = 'UserId = :searchstring'; + this.parameters.ExpressionAttributeValues = { + ':searchstring': this.criterion.userid, + }; + } + + return this; + } + + /** + * If pagination parameters have been passed inside this.event this + * method will build upon this.parameters object. + * + * @return this + */ + buildPagination() { + if (Object.prototype.hasOwnProperty.call(this.criterion, 'threadid') && + Object.prototype.hasOwnProperty.call(this.criterion, 'createddatetime') + ) { + this.parameters.ExclusiveStartKey = { + ThreadId: this.criterion.threadid, + DateTime: this.criterion.createddatetime, + }; + } + + if (Object.prototype.hasOwnProperty.call(this.criterion, 'limit')) { + this.parameters.Limit = this.criterion.limit; + } + + return this; + } +}; diff --git a/api/replies/replyCreate.js b/api/replies/replyCreate.js new file mode 100644 index 0000000..f5d248d --- /dev/null +++ b/api/replies/replyCreate.js @@ -0,0 +1,64 @@ +'use strict'; + +const uuidv1 = require('uuid/v1'); + +const Reply = require('./_classes/Reply'); + +const Errors = require('./../_classes/Errors'); + +const ValidationError = Errors.ValidationError; + +/** + * Handler for the lambda function. + * + * @param {Object} event - AWS Lambda uses this parameter to pass in event + * data to the handler. + * @param {Object} context - AWS Lambda uses this parameter to provide your + * handler the runtime information of the Lambda + * function that is executing. + * @param {Function} callback - Optional parameter used to pass a callback. + * + * @return JSON JSON encoded response. + */ +module.exports.replyCreate = (event, context, callback) => { + try { + const parameters = JSON.parse(event.body); + parameters.Id = uuidv1(); + + const reply = new Reply(parameters); + + reply + .validate() + .save() + .then((data) => { + const response = { + statusCode: 200, + body: JSON.stringify(data), + }; + + return callback(null, response); + }) + .catch((error) => { + console.log('<<>>', error); + + callback(null, { + statusCode: 500, + body: JSON.stringify({ message: error.message }), + }); + }); + } catch (error) { + if (error instanceof ValidationError) { + callback(null, { + statusCode: 422, + body: error.message, + }); + } else { + console.log('<<>>', error); + + callback(null, { + statusCode: 500, + body: JSON.stringify(error), + }); + } + } +}; diff --git a/api/replies/replyDelete.js b/api/replies/replyDelete.js new file mode 100644 index 0000000..e0bbaf3 --- /dev/null +++ b/api/replies/replyDelete.js @@ -0,0 +1,35 @@ +'use strict'; + +const Reply = require('./_classes/Reply'); + +/** + * Handler for the lambda function. + * + * @param {Object} event - AWS Lambda uses this parameter to pass in event + * data to the handler. + * @param {Object} context - AWS Lambda uses this parameter to provide your + * handler the runtime information of the Lambda + * function that is executing. + * @param {Function} callback - Optional parameter used to pass a callback + * + * @return JSON JSON encoded response. + */ +module.exports.replyDelete = (event, context, callback) => { + Reply.destroy(event.pathParameters.id) + .then((reply) => { + const response = { + statusCode: 204, + body: reply, + }; + + return callback(null, response); + }) + .catch((error) => { + console.log('<<>>', error); + + callback(null, { + statusCode: 500, + body: JSON.stringify(error), + }); + }); +}; diff --git a/api/replies/replyGet.js b/api/replies/replyGet.js new file mode 100644 index 0000000..1338896 --- /dev/null +++ b/api/replies/replyGet.js @@ -0,0 +1,35 @@ +'use strict'; + +const Reply = require('./_classes/Reply'); + +/** + * Handler for the lambda function. + * + * @param {Object} event - AWS Lambda uses this parameter to pass in event + * data to the handler. + * @param {Object} context - AWS Lambda uses this parameter to provide your + * handler the runtime information of the Lambda + * function that is executing. + * @param {Function} callback - Optional parameter used to pass a callback + * + * @return JSON JSON encoded response. + */ +module.exports.replyGet = (event, context, callback) => { + Reply.find(event.pathParameters.id) + .then((reply) => { + const response = { + statusCode: Object.keys(reply).length === 0 ? 404 : 200, + body: JSON.stringify(reply), + }; + + callback(null, response); + }) + .catch((error) => { + console.log('<<>>', error); + + callback(null, { + statusCode: 500, + body: error, + }); + }); +}; diff --git a/api/replies/replyList.js b/api/replies/replyList.js new file mode 100644 index 0000000..3352e46 --- /dev/null +++ b/api/replies/replyList.js @@ -0,0 +1,69 @@ +'use strict'; + +const Reply = require('./_classes/Reply'); + +const ReplyQueryBuilder = require('./_classes/ReplyQueryBuilder'); + +const Errors = require('./../_classes/Errors'); + +const ValidationError = Errors.ValidationError; + +/** + * Handler for the lambda function. + * + * @param {Object} event - AWS Lambda uses this parameter to pass in event + * data to the handler. + * @param {Object} context - AWS Lambda uses this parameter to provide your + * handler the runtime information of the Lambda + * function that is executing. + * @param {Function} callback - Optional parameter used to pass a callback + * + * @return JSON JSON encoded response. + */ +module.exports.replyList = (event, context, callback) => { + /** + * Instantiate an instance of QueryBuilder + * + * @type {QueryBuilder} + */ + const Query = new ReplyQueryBuilder(event.queryStringParameters); + + try { + Query + .validate() + .buildThreadIndex() + .buildUserIndex() + .buildPagination(); + + /** @type {model} Contains a list of items and optional pagination data */ + Reply.list(Query.parameters) + .then((replies) => { + const response = { + statusCode: replies.Items.length > 0 ? 200 : 204, + body: JSON.stringify(replies), + }; + + callback(null, response); + }) + .catch((error) => { + callback(null, { + statusCode: 500, + body: JSON.stringify({ message: error.message }), + }); + }); + } catch (error) { // Catch any errors thrown by the ReplyQueryBuilder class + if (error instanceof ValidationError) { + callback(null, { + statusCode: 422, + body: error.message, + }); + } else { + console.log('<<>>', error); + + callback(null, { + statusCode: 500, + body: JSON.stringify(error), + }); + } + } +}; diff --git a/api/replies/replyUpdate.js b/api/replies/replyUpdate.js new file mode 100644 index 0000000..8a47e65 --- /dev/null +++ b/api/replies/replyUpdate.js @@ -0,0 +1,64 @@ +'use strict'; + +const Reply = require('./_classes/Reply'); + +const Errors = require('./../_classes/Errors'); + +const ValidationError = Errors.ValidationError; + +/** + * Handler for the lambda function. + * + * @param {Object} event - AWS Lambda uses this parameter to pass in event + * data to the handler. + * @param {Object} context - AWS Lambda uses this parameter to provide your handler + * the runtime information of the Lambda function that is + * executing. + * @param {Function} callback - Optional parameter used to pass a callback + * + * @return JSON JSON encoded response. + */ +module.exports.replyUpdate = (event, context, callback) => { + try { + // Get the parameters passed in the body of the request + const parameters = JSON.parse(event.body); + + // Grab the value of hash key "id" passed in the route + parameters.Id = event.pathParameters.id; + + // Create a new instance of the reply object passing in our parameters + const reply = new Reply(parameters); + + reply + .validate() + .save() + .then((data) => { + const response = { + statusCode: 200, + body: JSON.stringify(data), + }; + + return callback(null, response); + }) + .catch((error) => { + callback(null, { + statusCode: 500, + body: JSON.stringify({ message: error.message }), + }); + }); + } catch (error) { + if (error instanceof ValidationError) { + callback(null, { + statusCode: 422, + body: error.message, + }); + } else { + console.log('<<>>', error); + + callback(null, { + statusCode: 500, + body: JSON.stringify(error), + }); + } + } +}; diff --git a/api/threads/_classes/Thread.js b/api/threads/_classes/Thread.js new file mode 100644 index 0000000..8135f72 --- /dev/null +++ b/api/threads/_classes/Thread.js @@ -0,0 +1,76 @@ +'use strict'; + +const validator = require('validator'); + +const Dynamic = require('./../../_classes/Dynamic'); + +const Errors = require('./../../_classes/Errors'); + +const ValidationError = Errors.ValidationError; + +/** + * Thread class. Each instance maps to one document in permanent storage and extends the + * Dynamic wrapper class. + * + * @type {class} + */ +module.exports = class Thread extends Dynamic { + /** + * Return a string containing the name of the table in perment storage + * for the model. + * + * @return {String} - Name of the string + */ + static table() { + return 'Thread'; + } + + /** + * Return a new instance of this + * + * @param {Object} parameters - Properties to be assigned to the newly created object + * + * @return {Object} New instance of the Thread object + */ + static model(parameters) { + return new Thread(parameters); + } + + /** + * Return an object that represents the properties of this class + * + * @return {Object} - Class properties that can be saved to dynamodb + */ + properties() { + return { + Id: this.Id, + ForumId: this.ForumId, + UserId: this.UserId, + UserName: this.UserName, + Title: this.Title, + Message: this.Message, + CreatedDateTime: this.CreatedDateTime, + UpdatedDateTime: this.UpdatedDateTime, + }; + } + + /** + * Validate the class properties and throw an exception if necessary + * + * @return {this} - Instance of this + */ + validate() { + const errors = []; + + if (typeof this.Id === 'undefined' || validator.isEmpty(this.Id)) errors.push({ Id: 'must provide a unique string for Id' }); + if (typeof this.ForumId === 'undefined' || validator.isEmpty(this.ForumId)) errors.push({ ForumId: 'must provide a value for ForumId' }); + if (typeof this.UserId === 'undefined' || validator.isEmpty(this.UserId)) errors.push({ UserId: 'must provide a value for UserId' }); + if (typeof this.UserName === 'undefined' || validator.isEmpty(this.UserName)) errors.push({ UserName: 'must provide a value for UserName' }); + if (typeof this.Title === 'undefined' || validator.isEmpty(this.Title)) errors.push({ Title: 'must provide a value for Title' }); + if (typeof this.Message === 'undefined' || validator.isEmpty(this.Message)) errors.push({ Message: 'must provide a value for Message' }); + + if (errors.length) throw new ValidationError(JSON.stringify(errors)); + + return this; + } +}; diff --git a/api/threads/_classes/ThreadQueryBuilder.js b/api/threads/_classes/ThreadQueryBuilder.js new file mode 100644 index 0000000..d97626f --- /dev/null +++ b/api/threads/_classes/ThreadQueryBuilder.js @@ -0,0 +1,171 @@ +'use strict'; + +const validator = require('validator'); + +const Errors = require('./../../_classes/Errors'); + +const ValidationError = Errors.ValidationError; + +/** + * Responsible for turning parameters passeed are turned in DynamoDb parameters by building + * this.parameters object using data passed inside this.events.queryStringParameters + * + * @type {class} + */ +module.exports = class ThreadQueryBuilder { + constructor(criterion) { + /** @type {Object} Key/value pairs used to build our DynamoDb parameters. */ + this.criterion = criterion; + + /** + * Used to hold the dynamodb query parameters built using values + * within property this.event + * + * @todo : Change TableName to value of process.env.DYNAMODB_THREAD_TABLE + * + * @type {object} + */ + this.params = { + TableName: 'Thread', + }; + + /** + * Used to hold any validation errors. + * + * @type {array} + */ + this.failures = []; + } + + /** + * Getter + * + * @return {object} parameters + */ + get parameters() { + return this.params; + } + + /** + * Setter + * + * @return {object} parameters + */ + set parameters(params) { + this.params = params; + } + + /** + * Getter + * + * @return {array} failures + */ + get errors() { + return this.failures; + } + + /** + * Setter + * + * @return {array} failures + */ + set errors(failures) { + this.failures = failures; + } + + /** + * Validates the parameters passed inside this.criterion object + * + * @return {boolean} + */ + validate() { + this.errors = []; // Empty the errors array before running the validation logic + + if (this.criterion !== null && typeof this.criterion === 'object') { + if (Object.prototype.hasOwnProperty.call(this.criterion, 'forumid') === false && + Object.prototype.hasOwnProperty.call(this.criterion, 'userid') === false + ) { + this.errors.push({ message: 'You must provide a forumid or userid parameter' }); + } + + if (Object.prototype.hasOwnProperty.call(this.criterion, 'forumid')) { + if (validator.isAlphanumeric(this.criterion.forumid) === false) { + this.errors.push({ message: 'Your forumid parameter must be an alphanumeric string' }); + } + } + + if (Object.prototype.hasOwnProperty.call(this.criterion, 'userid')) { + if (validator.isNumeric(this.criterion.userid) === false) { + this.errors.push({ message: 'Your userid parameter must be numeric' }); + } + } + } else { + this.errors.push({ message: 'You must supply a forumid or userid' }); + } + + if (this.errors.length) { + throw new ValidationError(JSON.stringify(this.errors)); + } + + return this; + } + + /** + * If "forumid" has been passed inside this.event this method will build upon + * this.parameters object. + * + * @return this + */ + buildForumIndex() { + if (Object.prototype.hasOwnProperty.call(this.criterion, 'forumid')) { + this.parameters.IndexName = 'ForumIndex'; + this.parameters.KeyConditionExpression = 'ForumId = :searchstring'; + this.parameters.ExpressionAttributeValues = { + ':searchstring': this.criterion.forumid, + }; + } + + return this; + } + + /** + * If "userid" has been passed inside this.event this method will build upon + * this.parameters object. + * + * @return this + */ + buildUserIndex() { + if (Object.prototype.hasOwnProperty.call(this.criterion, 'userid')) { + this.parameters.IndexName = 'UserIndex'; + this.parameters.KeyConditionExpression = 'UserId = :searchstring'; + this.parameters.ExpressionAttributeValues = { + ':searchstring': this.criterion.userid, + }; + } + + return this; + } + + /** + * If pagination parameters have been passed inside this.event this method + * will build upon this.parameters object. + * + * @return this + */ + buildPagination() { + if (Object.prototype.hasOwnProperty.call(this.criterion, 'forumid') && + Object.prototype.hasOwnProperty.call(this.criterion, 'createddatetime') + ) { + this.parameters.ExclusiveStartKey = { + ForumId: this.criterion.forumid, + DateTime: this.criterion.createddatetime, + }; + } + + if (Object.prototype.hasOwnProperty.call(this.criterion, 'limit')) { + this.parameters.Limit = this.criterion.limit; + } + + return this; + } +}; diff --git a/api/threads/threadCreate.js b/api/threads/threadCreate.js new file mode 100644 index 0000000..463116b --- /dev/null +++ b/api/threads/threadCreate.js @@ -0,0 +1,57 @@ +'use strict'; + +const uuidv1 = require('uuid/v1'); + +const Thread = require('./_classes/Thread'); + +const Errors = require('./../_classes/Errors'); + +const ValidationError = Errors.ValidationError; + +/** + * Handler for the lambda function. + * + * @param {Object} event - AWS Lambda uses this parameter to pass in event + * data to the handler. + * @param {Object} context - AWS Lambda uses this parameter to provide your + * handler the runtime information of the Lambda + * function that is executing. + * @param {Function} callback - Optional parameter used to pass a callback + * + * @return JSON JSON encoded response. + */ +module.exports.threadCreate = (event, context, callback) => { + try { + const parameters = JSON.parse(event.body); + parameters.Id = uuidv1(); + + const thread = new Thread(parameters); + + thread + .validate() + .save() + .then(data => callback(null, { + statusCode: 200, + body: JSON.stringify(data), + })) + .catch((error) => { + callback(null, { + statusCode: 500, + body: JSON.stringify({ message: error.message }), + }); + }); + } catch (error) { + if (error instanceof ValidationError) { + callback(null, { + statusCode: 422, + body: error.message, + }); + } else { + console.log('<<>>', error); + callback(null, { + statusCode: 500, + body: JSON.stringify(error), + }); + } + } +}; diff --git a/api/threads/threadDelete.js b/api/threads/threadDelete.js new file mode 100644 index 0000000..ae87c73 --- /dev/null +++ b/api/threads/threadDelete.js @@ -0,0 +1,35 @@ +'use strict'; + +const Thread = require('./_classes/Thread'); + +/** + * Handler for the lambda function. + * + * @param {Object} event - AWS Lambda uses this parameter to pass in event + * data to the handler. + * @param {Object} context - AWS Lambda uses this parameter to provide your + * handler the runtime information of the Lambda + * function that is executing. + * @param {Function} callback - Optional parameter used to pass a callback + * + * @return JSON JSON encoded response. + */ +module.exports.threadDelete = (event, context, callback) => { + Thread.destroy(event.pathParameters.id) + .then((thread) => { + const response = { + statusCode: 204, + body: thread, + }; + + return callback(null, response); + }) + .catch((error) => { + console.log('<<>>', error); + + callback(null, { + statusCode: 500, + body: JSON.stringify(error), + }); + }); +}; diff --git a/api/threads/threadGet.js b/api/threads/threadGet.js new file mode 100644 index 0000000..64ce223 --- /dev/null +++ b/api/threads/threadGet.js @@ -0,0 +1,35 @@ +'use strict'; + +const Thread = require('./_classes/Thread'); + +/** + * Handler for the lambda function. + * + * @param {Object} event - AWS Lambda uses this parameter to pass in event data + * to the handler. + * @param {Object} context - AWS Lambda uses this parameter to provide your + * handler the runtime information of the Lambda + * function that is executing. + * @param {Function} callback - Optional parameter used to pass a callback + * + * @return JSON JSON encoded response. + */ +module.exports.threadGet = (event, context, callback) => { + Thread.find(event.pathParameters.id) + .then((thread) => { + const response = { + statusCode: Object.keys(thread).length === 0 ? 404 : 200, + body: JSON.stringify(thread), + }; + + callback(null, response); + }) + .catch((error) => { + console.log('<<>>', error); + + callback(null, { + statusCode: 500, + body: error, + }); + }); +}; diff --git a/api/threads/threadList.js b/api/threads/threadList.js new file mode 100644 index 0000000..3340e39 --- /dev/null +++ b/api/threads/threadList.js @@ -0,0 +1,69 @@ +'use strict'; + +const Thread = require('./_classes/Thread'); + +const ThreadQueryBuilder = require('./_classes/ThreadQueryBuilder'); + +const Errors = require('./../_classes/Errors'); + +const ValidationError = Errors.ValidationError; + +/** + * Handler for the lambda function. + * + * @param {Object} event - AWS Lambda uses this parameter to pass in event + * data to the handler. + * @param {Object} context - AWS Lambda uses this parameter to provide your + * handler the runtime information of the Lambda + * function that is executing. + * @param {Function} callback - Optional parameter used to pass a callback + * + * @return JSON JSON encoded response. + */ +module.exports.threadList = (event, context, callback) => { + /** + * Instantiate an instance of QueryBuilder + * + * @type {QueryBuilder} + */ + const Query = new ThreadQueryBuilder(event.queryStringParameters); + + try { + Query + .validate() + .buildForumIndex() + .buildUserIndex() + .buildPagination(); + + /** @type {model} Contains a list of items and optional pagination data */ + Thread.list(Query.parameters) + .then((threads) => { + const response = { + statusCode: threads.Items.length > 0 ? 200 : 204, + body: JSON.stringify(threads), + }; + + callback(null, response); + }) + .catch((error) => { + callback(null, { + statusCode: 500, + body: JSON.stringify({ message: error.message }), + }); + }); + } catch (error) { // Catch any errors thrown by the ThreadQueryBuilder class + if (error instanceof ValidationError) { + callback(null, { + statusCode: 422, + body: error.message, + }); + } else { + console.log('<<>>', error); + + callback(null, { + statusCode: 500, + body: JSON.stringify(error), + }); + } + } +}; diff --git a/api/threads/threadUpdate.js b/api/threads/threadUpdate.js new file mode 100644 index 0000000..7c3ec01 --- /dev/null +++ b/api/threads/threadUpdate.js @@ -0,0 +1,64 @@ +'use strict'; + +const Thread = require('./_classes/Thread'); + +const Errors = require('./../_classes/Errors'); + +const ValidationError = Errors.ValidationError; + +/** + * Handler for the lambda function. + * + * @param {Object} event - AWS Lambda uses this parameter to pass in event data + * to the handler. + * @param {Object} context - AWS Lambda uses this parameter to provide your + * handler the runtime information of the Lambda + * function that is executing. + * @param {Function} callback - Optional parameter used to pass a callback + * + * @return JSON JSON encoded response. + */ +module.exports.threadUpdate = (event, context, callback) => { + try { + // Get the parameters passed in the body of the request + const parameters = JSON.parse(event.body); + + // Grab the value of hash key "id" passed in the route + parameters.Id = event.pathParameters.id; + + // Create a new instance of the thread object passing in our parameters + const thread = new Thread(parameters); + + thread + .validate() + .save() + .then((data) => { + const response = { + statusCode: 200, + body: JSON.stringify(data), + }; + + return callback(null, response); + }) + .catch((error) => { + callback(null, { + statusCode: 500, + body: JSON.stringify({ message: error.message }), + }); + }); + } catch (error) { + if (error instanceof ValidationError) { + callback(null, { + statusCode: 422, + body: error.message, + }); + } else { + console.log('<<>>', error); + + callback(null, { + statusCode: 500, + body: JSON.stringify(error), + }); + } + } +}; diff --git a/api/update.js b/api/update.js deleted file mode 100644 index 2cae50c..0000000 --- a/api/update.js +++ /dev/null @@ -1,63 +0,0 @@ -'use strict'; - -const AWS = require('aws-sdk'); // eslint-disable-line import/no-extraneous-dependencies - -const dynamoDb = new AWS.DynamoDB.DocumentClient(); - -module.exports.update = (event, context, callback) => { - - const timestamp = new Date().getTime(); - const data = JSON.parse(event.body); - - console.log( '=== data ===', data ); - console.log( '=== event ===', event ); - - // validation - if (typeof data.title !== 'string' || typeof data.body !== 'string') { - - console.error('Validation Failed'); - callback(null, { - statusCode: 400, - headers: { 'Content-Type': 'text/plain' }, - body: 'Couldn\'t update the post item.', - }); - - return; - } - - const params = { - TableName: process.env.DYNAMODB_TABLE, - Key: { - id: event.pathParameters.id, - }, - ExpressionAttributeValues: { - ':title': data.title, - ':body': data.body, - ':updated_at': timestamp, - }, - UpdateExpression: 'SET title = :title, body = :body, updated_at = :updated_at', - ReturnValues: 'ALL_NEW', - }; - - // update the post in the database - dynamoDb.update(params, (error, result) => { - - // handle potential errors - if (error) { - console.error('=== error ===', error); - callback(null, { - statusCode: error.statusCode || 501, - headers: { 'Content-Type': 'text/plain' }, - body: 'Couldn\'t fetch the post item.', - }); - return; - } - - // create a response - const response = { - statusCode: 200, - body: JSON.stringify(result.Attributes), - }; - callback(null, response); - }); -}; \ No newline at end of file diff --git a/data/Reply.json b/data/Reply.json new file mode 100644 index 0000000..346a5d4 --- /dev/null +++ b/data/Reply.json @@ -0,0 +1,247 @@ +{ + "Reply": [ + { + "PutRequest": { + "Item": { + "Id": { + "S": "1" + }, + "ThreadId": { + "S": "1" + }, + "UserId": { + "S": "1" + }, + "Message": { + "S": "Mauris sed vestibulum nibh, non viverra lacus. Vestibulum non lacus massa. Vivamus sodales nibh vel cursus dignissim. Maecenas tempus, ligula id pulvinar semper, neque nunc suscipit ex, a scelerisque justo sapien sit amet magna. Praesent ut dictum purus, vitae sollicitudin sapien. Nunc pretium eros eu nulla hendrerit molestie. Duis nisl nibh, auctor at finibus ac, aliquam nec tellus. Suspendisse sed fringilla ex, id ultrices diam. Vestibulum nulla orci, rhoncus ac efficitur semper, pretium sed nisl. Ut in lectus in augue malesuada laoreet eu sed ante. Etiam ac mattis dolor, sed varius tellus." + }, + "CreatedDateTime": { + "S": "2015-09-15T19:58:22.947Z" + }, + "UpdatedDateTime": { + "S": "2015-09-15T19:58:22.947Z" + }, + "UserName": { + "S": "UserNameOne" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "2" + }, + "ThreadId": { + "S": "2" + }, + "UserId": { + "S": "2" + }, + "Message": { + "S": "Donec volutpat, arcu vel egestas luctus, urna turpis bibendum lorem, quis vulputate leo justo vitae leo. Donec hendrerit dapibus turpis, et posuere mi rhoncus eu. Donec sit amet enim bibendum, convallis metus in, interdum sapien. Duis malesuada eu leo quis dictum. Duis auctor neque eros, sit amet venenatis lacus rhoncus vitae. Nam hendrerit nibh eget neque gravida, sit amet lobortis sem aliquet. Maecenas vitae nisi aliquam, venenatis nunc a, dictum lorem." + }, + "CreatedDateTime": { + "S": "2015-08-15T19:58:22.947Z" + }, + "UpdatedDateTime": { + "S": "2015-08-15T19:58:22.947Z" + }, + "UserName": { + "S": "UserNameTwo" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "3" + }, + "ThreadId": { + "S": "3" + }, + "UserId": { + "S": "1" + }, + "Message": { + "S": "In viverra justo diam, eget imperdiet libero vestibulum quis. In malesuada leo quis semper dignissim. Nulla auctor ullamcorper turpis, vitae blandit felis aliquet non. Praesent vel volutpat felis. Curabitur euismod nibh quam, pulvinar facilisis est pulvinar vitae. In est metus, commodo non commodo quis, facilisis a leo. Integer et semper lorem. Phasellus at cursus turpis. Quisque lacinia tincidunt enim id porta. Ut tempor augue at placerat imperdiet. Nullam non nisi dapibus, volutpat sem vel, dictum felis. Proin pharetra nisi id diam vulputate, non facilisis lacus commodo." + }, + "CreatedDateTime": { + "S": "2015-07-15T19:58:22.947Z" + }, + "UpdatedDateTime": { + "S": "2015-07-15T19:58:22.947Z" + }, + "UserName": { + "S": "UserNameThree" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "4" + }, + "ThreadId": { + "S": "1" + }, + "UserId": { + "S": "4" + }, + "Message": { + "S": "Mauris sed vestibulum nibh, non viverra lacus. Vestibulum non lacus massa. Vivamus sodales nibh vel cursus dignissim. Maecenas tempus, ligula id pulvinar semper, neque nunc suscipit ex, a scelerisque justo sapien sit amet magna. Praesent ut dictum purus, vitae sollicitudin sapien. Nunc pretium eros eu nulla hendrerit molestie. Duis nisl nibh, auctor at finibus ac, aliquam nec tellus. Suspendisse sed fringilla ex, id ultrices diam. Vestibulum nulla orci, rhoncus ac efficitur semper, pretium sed nisl. Ut in lectus in augue malesuada laoreet eu sed ante. Etiam ac mattis dolor, sed varius tellus." + }, + "CreatedDateTime": { + "S": "2015-09-15T19:51:22.947Z" + }, + "UpdatedDateTime": { + "S": "2015-09-15T19:51:22.947Z" + }, + "UserName": { + "S": "UserNameFour" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "5" + }, + "ThreadId": { + "S": "2" + }, + "UserId": { + "S": "1" + }, + "Message": { + "S": "Donec volutpat, arcu vel egestas luctus, urna turpis bibendum lorem, quis vulputate leo justo vitae leo. Donec hendrerit dapibus turpis, et posuere mi rhoncus eu. Donec sit amet enim bibendum, convallis metus in, interdum sapien. Duis malesuada eu leo quis dictum. Duis auctor neque eros, sit amet venenatis lacus rhoncus vitae. Nam hendrerit nibh eget neque gravida, sit amet lobortis sem aliquet. Maecenas vitae nisi aliquam, venenatis nunc a, dictum lorem." + }, + "CreatedDateTime": { + "S": "2015-08-15T19:53:22.947Z" + }, + "UpdatedDateTime": { + "S": "2015-08-15T19:53:22.947Z" + }, + "UserName": { + "S": "UserNameOne" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "6" + }, + "ThreadId": { + "S": "3" + }, + "UserId": { + "S": "2" + }, + "Message": { + "S": "In viverra justo diam, eget imperdiet libero vestibulum quis. In malesuada leo quis semper dignissim. Nulla auctor ullamcorper turpis, vitae blandit felis aliquet non. Praesent vel volutpat felis. Curabitur euismod nibh quam, pulvinar facilisis est pulvinar vitae. In est metus, commodo non commodo quis, facilisis a leo. Integer et semper lorem. Phasellus at cursus turpis. Quisque lacinia tincidunt enim id porta. Ut tempor augue at placerat imperdiet. Nullam non nisi dapibus, volutpat sem vel, dictum felis. Proin pharetra nisi id diam vulputate, non facilisis lacus commodo." + }, + "CreatedDateTime": { + "S": "2015-07-15T19:52:22.947Z" + }, + "UpdatedDateTime": { + "S": "2015-07-15T19:52:22.947Z" + }, + "UserName": { + "S": "UserNameTwo" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "7" + }, + "ThreadId": { + "S": "1" + }, + "UserId": { + "S": "5" + }, + "Message": { + "S": "Morbi lobortis fringilla tortor, non pellentesque purus porta et. Suspendisse potenti. Fusce varius nunc nec metus egestas, tincidunt finibus mi rutrum. Maecenas porta magna eu felis bibendum placerat. Maecenas pharetra ut est quis eleifend. Suspendisse dapibus tincidunt vehicula. Morbi consectetur scelerisque lectus id dignissim. Donec scelerisque lectus libero, vitae porttitor massa rutrum vitae." + }, + "CreatedDateTime": { + "S": "2015-09-15T19:54:22.947Z" + }, + "UpdatedDateTime": { + "S": "2015-09-15T19:54:22.947Z" + }, + "UserName": { + "S": "UserNameFive" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "8" + }, + "ThreadId": { + "S": "2" + }, + "UserId": { + "S": "5" + }, + "Message": { + "S": "Donec pretium sem tellus. In maximus nisl in feugiat pharetra. Nunc gravida malesuada massa at feugiat. Integer nec semper est, ut rhoncus leo. Fusce finibus ante ac ultricies dignissim. Mauris vulputate et risus finibus laoreet. Suspendisse potenti. Cras tristique lorem vitae lacinia egestas. Mauris nec facilisis erat. Curabitur non porta dui." + }, + "CreatedDateTime": { + "S": "2015-08-15T19:55:22.947Z" + }, + "UpdatedDateTime": { + "S": "2015-08-15T19:55:22.947Z" + }, + "UserName": { + "S": "UserNameFive" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "9" + }, + "ThreadId": { + "S": "3" + }, + "UserId": { + "S": "6" + }, + "Message": { + "S": "Duis mollis urna metus, eu posuere neque venenatis id. Aenean et consectetur leo. Sed vestibulum, arcu a mattis tristique, purus ipsum efficitur dui, in bibendum tortor est non eros. Donec pretium metus at felis finibus pretium. Mauris lobortis luctus ex ut feugiat. Aliquam pretium lacinia euismod. Nulla purus turpis, eleifend malesuada sagittis in, tincidunt posuere quam. Praesent in massa vel leo porta maximus id eget arcu." + }, + "CreatedDateTime": { + "S": "2015-07-15T19:56:22.947Z" + }, + "UpdatedDateTime": { + "S": "2015-07-15T19:56:22.947Z" + }, + "UserName": { + "S": "UserNameSix" + } + } + } + } + ] +} \ No newline at end of file diff --git a/data/Thread.json b/data/Thread.json new file mode 100644 index 0000000..9561dff --- /dev/null +++ b/data/Thread.json @@ -0,0 +1,184 @@ +{ + "Thread": [ + { + "PutRequest": { + "Item": { + "Id": { + "S": "1" + }, + "UserId": { + "S": "1" + }, + "ForumId": { + "S": "myforumid" + }, + "Title": { + "S": "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit..." + }, + "Message": { + "S": "Mauris sed vestibulum nibh, non viverra lacus. Vestibulum non lacus massa. Vivamus sodales nibh vel cursus dignissim. Maecenas tempus, ligula id pulvinar semper, neque nunc suscipit ex, a scelerisque justo sapien sit amet magna. Praesent ut dictum purus, vitae sollicitudin sapien. Nunc pretium eros eu nulla hendrerit molestie. Duis nisl nibh, auctor at finibus ac, aliquam nec tellus. Suspendisse sed fringilla ex, id ultrices diam. Vestibulum nulla orci, rhoncus ac efficitur semper, pretium sed nisl. Ut in lectus in augue malesuada laoreet eu sed ante. Etiam ac mattis dolor, sed varius tellus." + }, + "CreatedDateTime": { + "S": "2015-01-15T19:58:22.947Z" + }, + "UpdatedDateTime": { + "S": "2015-01-15T19:58:23.947Z" + }, + "UserName": { + "S": "UserNameOne" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "2" + }, + "UserId": { + "S": "2" + }, + "ForumId": { + "S": "myforumid" + }, + "Title": { + "S": "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit..." + }, + "Message": { + "S": "Mauris sed vestibulum nibh, non viverra lacus. Vestibulum non lacus massa. Vivamus sodales nibh vel cursus dignissim. Maecenas tempus, ligula id pulvinar semper, neque nunc suscipit ex, a scelerisque justo sapien sit amet magna. Praesent ut dictum purus, vitae sollicitudin sapien. Nunc pretium eros eu nulla hendrerit molestie. Duis nisl nibh, auctor at finibus ac, aliquam nec tellus. Suspendisse sed fringilla ex, id ultrices diam. Vestibulum nulla orci, rhoncus ac efficitur semper, pretium sed nisl. Ut in lectus in augue malesuada laoreet eu sed ante. Etiam ac mattis dolor, sed varius tellus." + }, + "CreatedDateTime": { + "S": "2015-02-15T19:58:22.947Z" + }, + "UpdatedDateTime": { + "S": "2015-02-15T19:58:24.947Z" + }, + "UserName": { + "S": "UserNameTwo" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "3" + }, + "UserId": { + "S": "3" + }, + "ForumId": { + "S": "myforumid" + }, + "Title": { + "S": "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit..." + }, + "Message": { + "S": "Mauris sed vestibulum nibh, non viverra lacus. Vestibulum non lacus massa. Vivamus sodales nibh vel cursus dignissim. Maecenas tempus, ligula id pulvinar semper, neque nunc suscipit ex, a scelerisque justo sapien sit amet magna. Praesent ut dictum purus, vitae sollicitudin sapien. Nunc pretium eros eu nulla hendrerit molestie. Duis nisl nibh, auctor at finibus ac, aliquam nec tellus. Suspendisse sed fringilla ex, id ultrices diam. Vestibulum nulla orci, rhoncus ac efficitur semper, pretium sed nisl. Ut in lectus in augue malesuada laoreet eu sed ante. Etiam ac mattis dolor, sed varius tellus." + }, + "CreatedDateTime": { + "S": "2015-03-15T19:58:22.947Z" + }, + "UpdatedDateTime": { + "S": "2015-03-15T19:58:32.947Z" + }, + "UserName": { + "S": "UserNameThree" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "4" + }, + "UserId": { + "S": "4" + }, + "ForumId": { + "S": "myforumid" + }, + "Title": { + "S": "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit..." + }, + "Message": { + "S": "Mauris sed vestibulum nibh, non viverra lacus. Vestibulum non lacus massa. Vivamus sodales nibh vel cursus dignissim. Maecenas tempus, ligula id pulvinar semper, neque nunc suscipit ex, a scelerisque justo sapien sit amet magna. Praesent ut dictum purus, vitae sollicitudin sapien. Nunc pretium eros eu nulla hendrerit molestie. Duis nisl nibh, auctor at finibus ac, aliquam nec tellus. Suspendisse sed fringilla ex, id ultrices diam. Vestibulum nulla orci, rhoncus ac efficitur semper, pretium sed nisl. Ut in lectus in augue malesuada laoreet eu sed ante. Etiam ac mattis dolor, sed varius tellus." + }, + "CreatedDateTime": { + "S": "2015-04-15T19:58:32.947Z" + }, + "UpdatedDateTime": { + "S": "2015-04-15T19:58:42.947Z" + }, + "UserName": { + "S": "UserNameFour" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "5" + }, + "UserId": { + "S": "5" + }, + "ForumId": { + "S": "myforumid" + }, + "Title": { + "S": "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit..." + }, + "Message": { + "S": "Mauris sed vestibulum nibh, non viverra lacus. Vestibulum non lacus massa. Vivamus sodales nibh vel cursus dignissim. Maecenas tempus, ligula id pulvinar semper, neque nunc suscipit ex, a scelerisque justo sapien sit amet magna. Praesent ut dictum purus, vitae sollicitudin sapien. Nunc pretium eros eu nulla hendrerit molestie. Duis nisl nibh, auctor at finibus ac, aliquam nec tellus. Suspendisse sed fringilla ex, id ultrices diam. Vestibulum nulla orci, rhoncus ac efficitur semper, pretium sed nisl. Ut in lectus in augue malesuada laoreet eu sed ante. Etiam ac mattis dolor, sed varius tellus." + }, + "CreatedDateTime": { + "S": "2015-04-15T19:58:52.947Z" + }, + "UpdatedDateTime": { + "S": "2015-04-15T19:58:52.947Z" + }, + "UserName": { + "S": "UserNameFive" + } + } + } + }, + { + "PutRequest": { + "Item": { + "Id": { + "S": "6" + }, + "UserId": { + "S": "6" + }, + "ForumId": { + "S": "myforumid" + }, + "Title": { + "S": "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit..." + }, + "Message": { + "S": "Mauris sed vestibulum nibh, non viverra lacus. Vestibulum non lacus massa. Vivamus sodales nibh vel cursus dignissim. Maecenas tempus, ligula id pulvinar semper, neque nunc suscipit ex, a scelerisque justo sapien sit amet magna. Praesent ut dictum purus, vitae sollicitudin sapien. Nunc pretium eros eu nulla hendrerit molestie. Duis nisl nibh, auctor at finibus ac, aliquam nec tellus. Suspendisse sed fringilla ex, id ultrices diam. Vestibulum nulla orci, rhoncus ac efficitur semper, pretium sed nisl. Ut in lectus in augue malesuada laoreet eu sed ante. Etiam ac mattis dolor, sed varius tellus." + }, + "CreatedDateTime": { + "S": "2015-04-15T19:58:53.947Z" + }, + "UpdatedDateTime": { + "S": "2015-04-15T19:58:53.947Z" + }, + "UserName": { + "S": "UserNameSix" + } + } + } + } + ] +} \ No newline at end of file diff --git a/loadData.sh b/loadData.sh new file mode 100755 index 0000000..eb58fbb --- /dev/null +++ b/loadData.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +aws dynamodb batch-write-item --request-items file://data/Reply.json +aws dynamodb batch-write-item --request-items file://data/Thread.json \ No newline at end of file diff --git a/package.json b/package.json index be7a72f..fd969d7 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,20 @@ { - "name": "post-api", + "name": "serverless-qa-template-api", "version": "1.0.0", - "description": "", + "description": "Big data, serverless Q&A template API. Written in NodeJS and deployed to AWS. Designed to be implemented as part of a distributed service. Exploits Lambda, API Gateway, Cloudwatch & Dynamodb.", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "buildapi": "cd api; npm install" }, "author": "", "license": "ISC", "dependencies": { - "bluebird": "^3.5.1", - "uuid": "^3.1.0" + "bluebird": "^3.5.1" + }, + "devDependencies": { + "eslint": "^4.14.0", + "eslint-config-airbnb-base": "^12.1.0", + "eslint-plugin-import": "^2.8.0" } } diff --git a/serverless.yml b/serverless.yml index ba76452..5608ac3 100644 --- a/serverless.yml +++ b/serverless.yml @@ -1,46 +1,15 @@ -# Welcome to Serverless! -# -# This file is the main config file for your service. -# It's very minimal at this point and uses default values. -# You can always add more config options for more control. -# We've included some commented out config examples here. -# Just uncomment any of them to get that config option. -# -# For full config options, check the docs: -# docs.serverless.com -# -# Happy Coding! - -service: post-api - -# You can pin your service to only deploy with a specific Serverless version -# Check out our docs for more details -# frameworkVersion: "=X.X.X" +service: qa-service provider: name: aws runtime: nodejs6.10 -# you can overwrite defaults here - stage: dev + stage: prod region: eu-west-1 -# you can add statements to the Lambda function's IAM Role here -# iamRoleStatements: -# - Effect: "Allow" -# Action: -# - "s3:ListBucket" -# Resource: { "Fn::Join" : ["", ["arn:aws:s3:::", { "Ref" : "ServerlessDeploymentBucket" } ] ] } -# - Effect: "Allow" -# Action: -# - "s3:PutObject" -# Resource: -# Fn::Join: -# - "" -# - - "arn:aws:s3:::" -# - "Ref" : "ServerlessDeploymentBucket" -# - "/*" environment: - DYNAMODB_TABLE: posts + DYNAMODB_THREAD_TABLE: Thread + DYNAMODB_REPLY_TABLE: Reply + iamRoleStatements: - Effect: Allow Action: @@ -55,143 +24,202 @@ provider: functions: - create: - handler: api/create.create + threadCreate: + handler: api/threads/threadCreate.threadCreate memorySize: 128 - description: Submit post information. + description: Submit thread. events: - http: - path: posts + path: threads method: post cors: true - list: - handler: api/list.list + threadList: + handler: api/threads/threadList.threadList memorySize: 128 - description: List posts + description: Retrieve a paginated list of threads. events: - http: - path: posts + path: threads method: get cors: true - update: - handler: api/update.update + threadUpdate: + handler: api/threads/threadUpdate.threadUpdate events: - http: - path: posts/{id} + path: threads/{id} method: put cors: true - get: - handler: api/get.get + threadGet: + handler: api/threads/threadGet.threadGet events: - http: - path: posts/{id} + path: threads/{id} method: get cors: true - delete: - handler: api/delete.delete + threadDelete: + handler: api/threads/threadDelete.threadDelete events: - http: - path: posts/{id} + path: threads/{id} method: delete cors: true -# you can define service wide environment variables here -# environment: -# variable1: value1 - -# you can add packaging information here -#package: -# include: -# - include-me.js -# - include-me-dir/** -# exclude: -# - exclude-me.js -# - exclude-me-dir/** + replyCreate: + handler: api/replies/replyCreate.replyCreate + memorySize: 128 + description: Submit post information. + events: + - http: + path: replies + method: post + cors: true -#functions: -# hello: -# handler: handler.hello + replyList: + handler: api/replies/replyList.replyList + memorySize: 128 + description: Retrieve a paginated list of replies by the value of Id + events: + - http: + path: replies + method: get + cors: true -# The following are a few example events you can configure -# NOTE: Please make sure to change your handler code to work with those events -# Check the event documentation for details -# events: -# - http: -# path: users/create -# method: get -# - s3: ${env:BUCKET} -# - schedule: rate(10 minutes) -# - sns: greeter-topic -# - stream: arn:aws:dynamodb:region:XXXXXX:table/foo/stream/1970-01-01T00:00:00.000 -# - alexaSkill -# - iot: -# sql: "SELECT * FROM 'some_topic'" -# - cloudwatchEvent: -# event: -# source: -# - "aws.ec2" -# detail-type: -# - "EC2 Instance State-change Notification" -# detail: -# state: -# - pending -# - cloudwatchLog: '/aws/lambda/hello' -# - cognitoUserPool: -# pool: MyUserPool -# trigger: PreSignUp + replyUpdate: + handler: api/replies/replyUpdate.replyUpdate + events: + - http: + path: replies/{id} + method: put + cors: true -# Define function environment variables here -# environment: -# variable2: value2 + replyGet: + handler: api/replies/replyGet.replyGet + events: + - http: + path: replies/{id} + method: get + cors: true -# you can add CloudFormation resource templates here -#resources: -# Resources: -# NewResource: -# Type: AWS::S3::Bucket -# Properties: -# BucketName: my-new-bucket -# Outputs: -# NewOutput: -# Description: "Description for the output" -# Value: "Some output value" + replyDelete: + handler: api/replies/replyDelete.replyDelete + events: + - http: + path: replies/{id} + method: delete + cors: true resources: Resources: - PostsDynamoDbTable: + ThreadDynamoDbTable: Type: 'AWS::DynamoDB::Table' DeletionPolicy: Retain Properties: AttributeDefinitions: - - AttributeName: "id" + AttributeName: "Id" AttributeType: "S" - - AttributeName: "updatedAt" - AttributeType: "N" + AttributeName: "ForumId" + AttributeType: "S" + - + AttributeName: "UserId" + AttributeType: "S" + - + AttributeName: "CreatedDateTime" + AttributeType: "S" - - AttributeName: "dummyHashKey" + AttributeName: "UpdatedDateTime" AttributeType: "S" KeySchema: - - AttributeName: "id" + AttributeName: "Id" KeyType: "HASH" GlobalSecondaryIndexes: - - IndexName: UpdatedAtIndex + IndexName: ForumIndex + KeySchema: + - + AttributeName: "ForumId" + KeyType: HASH + - + AttributeName: "UpdatedDateTime" + KeyType: RANGE + Projection: + ProjectionType: ALL + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 + - + IndexName: UserIndex + KeySchema: + - + AttributeName: "UserId" + KeyType: HASH + - + AttributeName: "CreatedDateTime" + KeyType: RANGE + Projection: + ProjectionType: ALL + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 + StreamSpecification: + StreamViewType: "NEW_AND_OLD_IMAGES" + TableName: Thread + ReplyDynamoDbTable: + Type: 'AWS::DynamoDB::Table' + DeletionPolicy: Retain + Properties: + AttributeDefinitions: + - + AttributeName: "Id" + AttributeType: "S" + - + AttributeName: "ThreadId" + AttributeType: "S" + - + AttributeName: "UserId" + AttributeType: "S" + - + AttributeName: "CreatedDateTime" + AttributeType: "S" + KeySchema: + - + AttributeName: "Id" + KeyType: "HASH" + GlobalSecondaryIndexes: + - + IndexName: ThreadIndex + KeySchema: + - + AttributeName: "ThreadId" + KeyType: HASH + - + AttributeName: "CreatedDateTime" + KeyType: RANGE + Projection: + ProjectionType: ALL + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 + - + IndexName: UserIndex KeySchema: - - AttributeName: "dummyHashKey" + AttributeName: "UserId" KeyType: HASH - - AttributeName: "updatedAt" + AttributeName: "CreatedDateTime" KeyType: RANGE Projection: - ProjectionType: KEYS_ONLY + ProjectionType: ALL ProvisionedThroughput: ReadCapacityUnits: 1 WriteCapacityUnits: 1 @@ -200,4 +228,4 @@ resources: WriteCapacityUnits: 1 StreamSpecification: StreamViewType: "NEW_AND_OLD_IMAGES" - TableName: ${self:provider.environment.DYNAMODB_TABLE} \ No newline at end of file + TableName: Reply \ No newline at end of file