This is a Note Taking Serverless Application Implemented using Amazon Web Services and Serverless Framework.
-
Application Backend consists of six Lambda Functions
get
,create
,list
,update
,delete
,billing
connected to DynamoDB database and S3 attachment bucket. -
Billing API implemented using third party API which is Stripe.
-
Authentication is implemented using AWS Cognito service.
-
Application FrontEnd is a React Application.
This application will allow creating/removing/updating/fetching Notes. Each Note item can optionally have an attachment image. Each user only has access to Notes that he/she has created.
The application should store NOTEs, and each NOTE contains the following fields:
userId
: user identities are federated through the Cognito Identity Pool, we will use the identity id as the user id of the authenticated usernoteId
: a unique uuidcontent
: parsed from request bodyattachment
: parsed from request bodycreatedAt
: current Unix timestamp
This Function will take the note object as the input and store it in the database with a new id. The note object will contain the content field (the content of the note) and an attachment field (the URL to the uploaded file).
import uuid from "uuid";
import * as dynamoDbLib from "./libs/dynamodb-lib";
import { success, failure } from "./libs/response-lib";
export async function main(event, context) {
const data = JSON.parse(event.body);
const params = {
TableName: process.env.tableName,
Item: {
userId: event.requestContext.identity.cognitoIdentityId,
noteId: uuid.v1(),
content: data.content,
attachment: data.attachment,
createdAt: Date.now()
}
};
try {
await dynamoDbLib.call("put", params);
return success(params.Item);
} catch (e) {
return failure({ status: false });
}
}
We are also using the async/await pattern here to refactor our Lambda function. This allows us to return once we are done processing; instead of using the callback function.
This Function will retrieve a note given its Id.
import * as dynamoDbLib from "./libs/dynamodb-lib";
import { success, failure } from "./libs/response-lib";
export async function main(event, context) {
const params = {
TableName: process.env.tableName,
// 'Key' defines the partition key and sort key of the item to be retrieved
// - 'userId': Identity Pool identity id of the authenticated user
// - 'noteId': path parameter
Key: {
userId: event.requestContext.identity.cognitoIdentityId,
noteId: event.pathParameters.id
}
};
try {
const result = await dynamoDbLib.call("get", params);
if (result.Item) {
// Return the retrieved item
return success(result.Item);
} else {
return failure({ status: false, error: "Item not found." });
}
} catch (e) {
return failure({ status: false });
}
}
This Function returns a list of all the notes a user has.
import * as dynamoDbLib from "./libs/dynamodb-lib";
import { success, failure } from "./libs/response-lib";
export async function main(event, context) {
const params = {
TableName: process.env.tableName,
// 'KeyConditionExpression' defines the condition for the query
// - 'userId = :userId': only return items with matching 'userId'
// partition key
// 'ExpressionAttributeValues' defines the value in the condition
// - ':userId': defines 'userId' to be Identity Pool identity id
// of the authenticated user
KeyConditionExpression: "userId = :userId",
ExpressionAttributeValues: {
":userId": event.requestContext.identity.cognitoIdentityId
}
};
try {
const result = await dynamoDbLib.call("query", params);
// Return the matching list of items in response body
return success(result.Items);
} catch (e) {
return failure({ status: false });
}
}
This Function allows a user to update a note with a new note object given its id.
import * as dynamoDbLib from "./libs/dynamodb-lib";
import { success, failure } from "./libs/response-lib";
export async function main(event, context) {
const data = JSON.parse(event.body);
const params = {
TableName: process.env.tableName,
// 'Key' defines the partition key and sort key of the item to be updated
// - 'userId': Identity Pool identity id of the authenticated user
// - 'noteId': path parameter
Key: {
userId: event.requestContext.identity.cognitoIdentityId,
noteId: event.pathParameters.id
},
// 'UpdateExpression' defines the attributes to be updated
// 'ExpressionAttributeValues' defines the value in the update expression
UpdateExpression: "SET content = :content, attachment = :attachment",
ExpressionAttributeValues: {
":attachment": data.attachment || null,
":content": data.content || null
},
// 'ReturnValues' specifies if and how to return the item's attributes,
// where ALL_NEW returns all attributes of the item after the update; you
// can inspect 'result' below to see how it works with different settings
ReturnValues: "ALL_NEW"
};
try {
await dynamoDbLib.call("update", params);
return success({ status: true });
} catch (e) {
return failure({ status: false });
}
}
This Function allows a user to delete a given note.
import * as dynamoDbLib from "./libs/dynamodb-lib";
import { success, failure } from "./libs/response-lib";
export async function main(event, context) {
const params = {
TableName: process.env.tableName,
// 'Key' defines the partition key and sort key of the item to be removed
// - 'userId': Identity Pool identity id of the authenticated user
// - 'noteId': path parameter
Key: {
userId: event.requestContext.identity.cognitoIdentityId,
noteId: event.pathParameters.id
}
};
try {
await dynamoDbLib.call("delete", params);
return success({ status: true });
} catch (e) {
return failure({ status: false });
}
}
This Function takes a Stripe token and the number of notes the user wants to store.
import stripePackage from "stripe";
import { calculateCost } from "./libs/billing-lib";
import { success, failure } from "./libs/response-lib";
export async function main(event, context) {
const { storage, source } = JSON.parse(event.body);
const amount = calculateCost(storage);
const description = "Scratch charge";
// Load our secret key from the environment variables
const stripe = stripePackage(process.env.stripeSecretKey);
try {
await stripe.charges.create({
source,
amount,
description,
currency: "usd"
});
return success({ status: true });
} catch (e) {
return failure({ message: e.message });
}
}
This will manage building the response objects for both success and failure cases with the proper HTTP status code and headers.
export function success(body) {
return buildResponse(200, body);
}
export function failure(body) {
return buildResponse(500, body);
}
function buildResponse(statusCode, body) {
return {
statusCode: statusCode,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Credentials": true
},
body: JSON.stringify(body)
};
}
Here we are using the promise form of the DynamoDB methods. Promises are a method for managing asynchronous code that serve as an alternative to the standard callback function syntax. It will make our code a lot easier to read.
import AWS from "aws-sdk";
export function call(action, params) {
const dynamoDb = new AWS.DynamoDB.DocumentClient();
return dynamoDb[action](params).promise();
}
This is basically saying that if a user wants to store 10 or fewer notes, we’ll charge them $4 per note. For 11 to 100 notes, we’ll charge $2 and any more than 100 is $1 per note. Since Stripe expects us to provide the amount in pennies (the currency’s smallest unit) we multiply the result by 100. Clearly, our serverless infrastructure might be cheap but our service isn’t!
export function calculateCost(storage) {
const rate = storage <= 10
? 4
: storage <= 100
? 2
: 1;
return rate * storage * 100;
}
The client folder contains a web application that can use the serverless Backend API.
This frontend work with our serverless application once it is deployed, you don't need to make any changes to the code. The only file that you need to edit is the config.js
file in the client
folder. This file configures your client application and contains an API endpoint and Cognito and Stripe configuration:
{
STRIPE_KEY: "pk_test_v1amvR35uoCNduJfkqGB8RLD",
s3: {
REGION: "us-east-1",
BUCKET: "notes-app-2-api-dev-attachmentsbucket-75e5vn6bes5i"
},
apiGateway: {
REGION: "us-east-1",
URL: "https://ne0yjhvize.execute-api.us-east-1.amazonaws.com/dev"
},
cognito: {
REGION: "us-east-1",
USER_POOL_ID: "us-east-1_PwXXGkPcL",
APP_CLIENT_ID: "579t51q82cihqaqr48kaf31n8r",
IDENTITY_POOL_ID: "us-east-1:b29694a1-f18c-457c-bbcc-af1f1b89cfab"
}
To implement authentication in this project we are using AWS Cognito.
Amazon Cognito Federated Identities enables developers to create unique identities for your users and authenticate them with federated identity providers. With a federated identity, you can obtain temporary, limited-privilege AWS credentials to securely access other AWS services such as Amazon DynamoDB, Amazon S3, and Amazon API Gateway.
We will be using our User Pool as the identity provider. We could also use Facebook, Google, or our own custom identity provider. Once a user is authenticated via our User Pool, the Identity Pool will attach an IAM Role to the user. We will define a policy for this IAM Role to grant access to the S3 bucket and our API. This is the Amazon way of securing your resources.
Say you were creating a new web or mobile app and you were thinking about how to handle user registration, authentication, and account recovery. This is where Cognito User Pools would come in. Cognito User Pool handles all of this and as a developer you just need to use the SDK to retrieve user related information.
Cognito Identity Pool (or Cognito Federated Identities) on the other hand is a way to authorize your users to use the various AWS services. Say you wanted to allow a user to have access to your S3 bucket so that they could upload a file; you could specify that while creating an Identity Pool. And to create these levels of access, the Identity Pool has its own concept of an identity (or user). The source of these identities (or users) could be a Cognito User Pool or even Facebook or Google.
To deploy an application run the following commands:
cd backend
npm install
sls deploy -v
To run a client application first edit the client/src/config.js
file to set correct parameters. And then run the following commands:
cd client
npm install
npm run start
This should start a development server with the React application that will interact with the serverless NOTEs application.
In order to complete this project I have used Serverless Stack which is an open source comprehensive guide to creating full-stack serverless applications.