This project is is for the Udacity Cloud Developer Nanodegree, and specifically course 5 Develop & Deploy Serverless App. In this course we learned both the theory of using serverless technologies with the practice of developing a complex serverless application. We also learned advanced serverless features such as implementing WebSockets and stream processing.
The task is to develop an todo serverless service for users to create, store, modify, and delete items from their todo list. The service allows for each todo item to have also a picture attached to it. A serverless REST APIs was built using API Gateway and AWS Lambda, a stack of serverless technologies on AWS. The data is stored in AWS DynamoDB and S3, and the application is secured with oAuth authentication. Finally, it is deployed to Amazon Web Services using Serverless framework.
This application allows creating/removing/updating/fetching TODO items. Each TODO item can optionally have an attachment image. Each user only has access to TODO items that he/she has created.
The application stores TODO items in an AWS DynamoDb database, and each TODO item contains the following fields:
userId
(string) - a unique id for a usertodoId
(string) - a unique id for an itemtimestamp
(string) - date and time when an item was createdname
(string) - name of a TODO item (e.g. "Change a light bulb")dueDate
(string) - date and time by which an item should be completeddone
(boolean) - true if an item was completed, false otherwiseattachmentUrl
(string) (optional) - a URL pointing to an image attached to a TODO item
To implement this project you need to have the following:
- An AWS account which you can create via the free tier at AWS Free Tier
- Install the AWS CLI on your development machine and initialize with your AWS credentials
- The Node Package Manager (NPM). You will need to download and install Node from https://nodejs.com/en/download. This will allow you to be able to run
npm
commands to install dependent libraries. - The following are two important components of our backend solution:
- installing Serverless framework with
npm install -g serverless
- installing the AWS SDK with
npm install --save-dev aws-sdk
- installing Serverless framework with
- The frontend is created with React which will need the following:
- Install React with
npm install react react-dom --save
- Install Babel compiler with
npm install babel-core babel-loader babel-preset-env babel-preset-react babel-webpack-plugin --save-dev
- Install React with
Following lambda functions will have to be implemented and configured in the backend serverless.yml file.
Auth
- this function should implement a custom authorizer for API Gateway that should be added to all other functions.GetTodos
- should return all TODOs for a current user.CreateTodo
- should create a new TODO for a current user.UpdateTodo
- should update a TODO item created by a current user.DeleteTodo
- should delete a TODO item created by a current user.GenerateUploadUrl
- returns a pre-signed URL that can be used to upload an attachment file for a TODO item.
The following was implemented as a solution to the project challenge.
We build out a CI/CD process using Travis CI that automatically builds and deploys our serverless service to AWS. To make development easier, our .travis.yml script encapsulates the branch in the deployment, so that our master
app is not affected by changes/features done in our dev
branch.
To allow for serveless to deploy on our behalf on AWS, the AWS_SECRET_ACCESS_KEY
and AWS_ACCESS_KEY_ID
have to be stored in the settings of our travis repository.
The project code base is mainly located within the src
folder. This folder is divided in:
functions
- containing code base and configuration for the lambda functionslibs
- containing shared code base between the lambdasresources
- containing the configurations for the service resources
.
├── src
│ ├── functions # Lambda configuration and source code folder
│ │ ├── auth # auth event functions
│ │ │ └── auth0Authorizer
│ │ │ ├── index.ts # lambda Serverless configuration
│ │ │ └── handler.ts # lambda source code
│ │ ├── http # http event functions
│ │ │ ├── createTodo
│ │ │ │ ├── index.ts # lambda Serverless configuration
│ │ │ │ ├── handler.ts # lambda source code
│ │ │ │ └── schema.ts # lambda input event JSON-Schema
│ │ │ ├── deleteTodo
│ │ │ │ ├── index.ts # lambda Serverless configuration
│ │ │ │ └── handler.ts # lambda source code
│ │ │ ├── generateUploadUrl
│ │ │ │ ├── index.ts # lambda Serverless configuration
│ │ │ │ ├── handler.ts # lambda source code
│ │ │ │ └── schema.ts # lambda input event JSON-Schema
│ │ │ ├── getTodos
│ │ │ │ ├── index.ts # lambda Serverless configuration
│ │ │ │ └── handler.ts # lambda source code
event JSON-Schema
│ │ │ └── updateTodo
│ │ │ ├── index.ts # lambda Serverless configuration
│ │ │ ├── handler.ts # lambda source code
│ │ │ └── schema.ts # lambda input event JSON-Schema
│ │ │
│ │ └── index.ts # Import/export of all lambda configurations
│ │
│ ├── libs # Lambda shared code
│ │ ├── database.ts # DynamoDB interface functions
│ │ ├── getUserId.ts # helper function to get UserId from JWT
│ │ ├── logger.ts # Winston logger interface functions
│ │ ├── storage.ts # S3 interface functions
│ │ ├── apiGateway.ts # API Gateway specific helpers
│ │ ├── handlerResolver.ts # Sharable library for resolving lambda handlers
│ │ └── lambda.ts # Lambda middleware
│ │
│ └── resources # Serverless resources configurations
│ ├── dynamoDb.ts # DynamoDB configuration
│ └── s3.ts # s3 configuration
├── serverless.ts # Serverless service file
└── tsconfig.paths.json # Typescript paths
In order not to expose secrets in our source code or serverless.yml file, the following strategy was used:
- Our libraries retrieve app specific endpoints/names from the
serverless.yml
environment variables. Example from database.ts below:const todosTable = process.env.TODOS_TABLE const todoIndex = process.env.TODO_ID_INDEX
- serverless.yml first looks up our secret using AWS Secrets Manager and stores it under custom section.
- Then it creates these environment variables by using the AWS secret values
Excerpt from serverless.yml below:
const serverlessConfiguration: AWS = {
...
custom: {
todoSecrets: "${ssm:/aws/reference/secretsmanager/todo/app}",
},
provider: {
...
environment: {
TODOS_TABLE: "${self:custom.todoSecrets.tableName}${self:provider.stage}",
TODO_ID_INDEX: "${self:custom.todoSecrets.todoIndex}${self:provider.stage}",
TODOS_S3_BUCKET: "${self:custom.todoSecrets.s3Endpoint}${self:provider.stage}",
JWKS: "${self:custom.todoSecrets.jwksUrl}"
},
Auth0 was used as an authentication and authorization platform. Auth0 was chosen due to it's easy (allows users to sign-in with their Google credentials), and also safety with asymmetrically encrypted JWT tokens.
On the client side, we stored the domain
and clientId
so that calls to auth0.WebAuth
will use them .
auth0 = new auth0.WebAuth({
domain: authConfig.domain,
clientID: authConfig.clientId,
redirectUri: authConfig.callbackUrl,
responseType: 'token id_token',
scope: 'openid'
});
On the backend side, we use RS256 and JWKS to verify the token passed by the client auth0Authorizer
First, we setup a DynamoDB NoSQL database on the cloud that will store our todo items.
The spec for this database (dynamoDb.ts) partitions the todo items by userId
and sorts by timestamp
.
A secondary index is created which indexes by todoId
to allow for querying by todoId
.
The lambda function that will serve the route GET \todos
is provided in handler.ts. To allow for easily porting to other cloud architectures, a Hexagonal - ports and adapters architecture is done, where all adapters for interacting with the database are performed by the library database.ts.
The definition for this function makes sure that it has the relevant iamRole and also that only authenticated users will be served. This is done through the specific function definition for serverless found in the index.ts for this function, as follows:
events: [
{
http: {
method: 'get',
path: 'todos',
cors: true,
authorizer: 'auth0Authorizer'
}
}
],
iamRoleStatements: [
{
Effect: 'Allow',
Action: [
'dynamodb:Query',
],
Resource: ["arn:aws:dynamodb:${self:provider.region}:*:table/${self:provider.environment.TODOS_TABLE}"]
}
]
The createTodo
lambda function is provided in handler.ts.
The steps this function does are as follows:
- using the uuid we create an RFC4122 version 4 Universally Unique Identifier for the todo item.
- We get the current date/time to create a
timestamp
for this item - We retrieve the
userId
using the JWT Authorization token - We retrieve the data from the body of the request (
name
anddueDate
) - We put this item in the database
Prior to executing this function, the defintion of our function for serverless in index.ts makes sure that APIGateway has first checked that:
- User is authorized
- Request body conforms to our schema.ts
- Function has appropriate iamRole to put items in the database
We check that a valid name has been provided for a todo item both at the client side and the backend.
We match the name to a regex that accepts everything except a space or blank name. This is done in Todos.tsx on the onTodoCreate
function as follows:
onTodoCreate = async (event: React.ChangeEvent<HTMLButtonElement>) => {
try {
if (/^(?!\s*$).+/.test(this.state.newTodoName) === false) {
alert('Please enter a name for this todo item')
return
}
const dueDate = this.calculateDueDate()
const newTodo = await createTodo(this.props.auth.getIdToken(), {
name: this.state.newTodoName,
dueDate
})
this.setState({
todos: [...this.state.todos, newTodo],
newTodoName: ''
})
} catch {
alert('Todo creation failed')
}
}
Our schema.ts checks that name
is non-empty by matching it to the pattern as follows:
export default {
type: "object",
properties: {
name: {
type: 'string',
pattern: '^(?!\s*$).+' },
dueDate: {type: 'string'}
},
required: [
'name',
'dueDate'
]
} as const;
The updateTodo
lambda function is provided in handler.ts.
The steps this function does are as follows:
- We retrieve the
userId
using the JWT Authorization token - We retrieve the
todoId
from the path parameters - We check if this todo item exists in the database, by querying over the secondary index
- If yes, we retrieve the new status from the request body
- We update the entry in the database
Prior to executing this function, the defintion of our function for serverless in index.ts makes sure that APIGateway has first checked that:
- User is authorized
- Request body conforms to our schema.ts
- Function has appropriate iamRole to query and update items in the database
The deleteTodo
lambda function is provided in handler.ts.
The steps this function does are as follows:
-
We retrieve the
userId
using the JWT Authorization token -
We retrieve the
todoId
from the path parameters -
We check if this todo item exists in the database, by querying over the secondary index
-
Then:
- we check if this todo item had an
attachmentUrl
by usinghasOwnProperty('attachmentUrl')
- We delete the relevant bucket, by first extracting the
bucketKey
from theattachmentUrl
using a regex as follows:
let re = /.*amazonaws\.com\/(.*)/i let match = re.exec(attachmentUrl)
- we check if this todo item had an
-
Finally, using the
timestamp
key we retrieved from the database query, we proceed to delete the item from the database
Prior to executing this function, the defintion of our function for serverless in index.ts makes sure that APIGateway has first checked that:
- User is authorized
- Function has appropriate iamRole to :
- query and delete items in the database
- delete items in the s3 store
First, we setup a S3 data store on the cloud that will store the todo items attachments.
The spec for this store (s3.ts) creates the store and the BucketPolicy
to allow for interactions with this store.
The interactions with this store is performed through our library storage.ts, so that our lambda functions are agnostic to the specific data store API used.
The generateUploadUrl
lambda function is provided in handler.ts.
The steps this function does are as follows:
- We retrieve the
userId
using the JWT Authorization token - We retrieve the
todoId
from the path parameters - We check if this todo item exists in the database, by querying over the secondary index
- If yes, we retrieve the filename from the request body and create a bucket key that will include the
todoId
to avoid filename clashes between different todo itemsconst bucketKey = `${todoId}/${fname}
- We update the todo entry in the database with the
attachmentUrl
that will be as follows:const url = `https://${bucketName}.s3.amazonaws.com/${bucketKey}`
- We retrieve a
SignedUrl
for aPUT
operation on our S3 data store using the AWS SDK getSignedUrl function as follows:AWS.s3.getSignedUrl("putObject", { Bucket: bucketName, Key: bucketKey, Expires: urlExpiration, });
Prior to executing this function, the defintion of our function for serverless in index.ts makes sure that APIGateway has first checked that:
- User is authorized
- Request body conforms to our schema.ts
- Function has appropriate iamRole to :
- query and update items in the database
- put objects on the s3 data store
We use AWS X-Ray to trace calls to the AWS services. Implementing this requires installation of the aws-xray-sdk library, and then to replace our calls to AWS services as follows:
Standard | With X-Ray tracing |
import * as AWS from 'aws-sdk'
const docClient = new AWS.DynamoDB.DocumentClient()
...
const s3 = new AWS.S3({
signatureVersion: "v4",
}); |
import * as AWS from 'aws-sdk'
import { captureAWS } from "aws-xray-sdk-core";
var XAWS = captureAWS(AWS);
const docClient = new XAWS.DynamoDB.DocumentClient()
...
const s3 = new XAWS.S3({
signatureVersion: "v4",
}); |
Once we deploy and use our service with X-Ray enabled, we can obtain a service map on the aws console as per below example: