Serverless apps have been around for a while, but they became mainstream around 2014 when AWS introduced Lambda. Despite the name, serverless apps do run on servers — they’re just managed by a cloud provider such as AWS. This frees you to focus on coding your app logic. Another benefit of serverless apps is their ability to run in response to events and bill you only when they run.
The Serverless framework is a CLI tool that allows you to build and deploy serverless apps in a structured way. The framework supports a variety of providers, including AWS Lambda, Google Cloud Functions, and Microsoft Azure Functions. In this tutorial, we’ll use the serverless framework to build an API powered by AWS Lambda.
What we’ll build
We’ll build a REST API for managing products stored in a warehouse. We’ll make these four operations possible:
- Add a product to the warehouse: a POST request to
/products
with the product information in the body. For each product, we’ll store the name, the quantity, a timestamp marking when it was added, and a unique ID. - List all products in the warehouse: a GET request to
/products
. - View a single product: a GET request to
/products/{id}
.{id}
here is a placeholder for the product ID. - Remove a product from the warehouse: a DELETE request to
/products/{id}
.
We’ll use AWS DynamoDB as our data store. Let’s go!
Prerequisites
- Node.js v6.5.0 or greater
- An AWS account. You can sign up for a free account here.
Setting up the project
First up, we’ll install the serverless CLI:
npm install -g serverless
Next, we’ll create a new service using the AWS Node.js template. Create a folder to hold your service (I’m calling mine stockup
) and run the following command in it:
serverless create --template aws-nodejs
This will populate the current directory with the starter files needed for the service. Your directory should have the following structure:
stockup
|- .gitignore
|- handler.js
|- serverless.yml
What goes in a serverless app?
A service provides functions. Functions are points of entry into your app for performing a specific functionality. Remember the operations we listed above for our API? Each of those is going to be a function in our service.
Each function has events that trigger it, and a handler that responds to the event. An event can be a web request (visiting a URL or making an API call), an action from another service, or a custom action that happens in your app. A handler is a code that responds to the event. Each function may also make use of one or more resources. Resources are external services your functions make use of, such as a database, a cache, or external file storage.
The serverless.yml
file serves as a manifest for our service. It contains information that the serverless CLI uses to configure and deploy our service. We’ll write ours, then examine the contents to get a deeper understanding. Replace the contents of your serverless.yml
file with the following:
service: stockup
provider:
name: aws
runtime: nodejs6.10
iamRoleStatements:
- Effect: Allow
Action:
- dynamodb:DescribeTable
- dynamodb:Query
- dynamodb:Scan
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
Resource:
Fn::Join:
- ""
- - "arn:aws:dynamodb:*:*:table/"
- Ref: ProductsDynamoDbTable
functions:
create:
handler: handler.create
events:
- http:
path: products
method: post
cors: true
list:
handler: handler.list
events:
- http:
path: products
method: get
cors: true
view:
handler: handler.view
events:
- http:
path: products/{id}
method: get
cors: true
remove:
handler: handler.remove
events:
- http:
path: products/{id}
method: delete
cors: true
resources:
Resources:
ProductsDynamoDbTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: products
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
We’ve described our service using four top-level keys:
service
: the name of our service (“stockup”)provider
: this is where we specify the name of the provider we’re using (AWS) and configurations specific to it. In our case, we’ve configured the runtime (Node.js) and the IAM (Identity Access Management) role that our functions will run under. Our functions need to read from and write to our DynamoDB permissions, so we added the necessary permissions to the IAM role.functions
: here we specify the functions provided by our service, the API calls that should trigger them, and their handlers (we’ll write the code for the handlers soon)resources
: Theresources
key contains all necessary configuration for our resources. In our case, we’ve configured the DynamoDB resource by specifying the name of the table we’ll be interacting with (products). DynamoDB is schemaless but requires you to declare the primary key for each table, so we’ve defined this in ourAttributeDefinitions
andKeySchema
. We’re using theid
, a string, as our primary key.
Now let’s install our app’s dependencies. Remember that this is a Node.js app, so we can use NPM to install dependencies as normal. Create a file called package.json
in your project root with the following content:
{
"dependencies": {
"aws-sdk": "^2.205.0",
"uuid": "^3.2.1"
}
}
We need the AWS SDK for interacting with DynamoDB and the uuid
module to generate product IDs.
Now run npm install
, and we’re ready to write our event handlers.
Writing the event handlers
Let’s write the code that responds to events. Remember that we have to export our handlers from the file handler.js
. There’s no rule, however, that says that we have to put all the code for them in that one file. To keep our code clean, we’ll write each of our handlers in its own file, then export them all from handler.js
. Let’s start off with adding a product.
Create a sub-directory called handlers
. All our handler files ill go in this directory.
Create a file called create.js
in the handlers
directory with the following code:
'use strict';
const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();
const uuid = require('uuid');
module.exports = (data) => {
const params = {
TableName: 'products',
Item: {
name: data.name,
quantity: data.quantity,
id: uuid.v1(),
addedAt: Date.now(),
}
};
return dynamoDb.put(params).promise()
.then(result => params.Item)
};
In this file, we’re exporting a function that takes in the product data (sent by the user in the body of the request). Our function then inserts the product into the database, returning the result via a Promise
.
Next, we’ll import this module and export the handler in our handler.js
:
'use strict';
const addProduct = require('./handlers/create');
const create = (event, context, callback) => {
const data = JSON.parse(event.body);
addProduct(data)
.then(result => {
const response = { body: JSON.stringify(result) };
callback(null, response);
})
.catch(callback);
};
module.exports = {
create,
};
How does this work? Let’s take a closer look.
The handler.js
file must export an object with properties matching those named as handlers in the serverless.yml
file. Each handler is a function that takes three parameters:
- The AWS event object provides us with useful information about what triggered the function, including the request body and path parameters (for instance, the product ID).
- The current execution context. It provides us with information about the environment and conditions under which the function is currently executing. We won’t be making use of it here.
- A callback function, which we can use to respond to the caller. It takes an error, if any, as its first parameter, and the response you wish to send as its second.
In the code above, we’re importing our create
module and passing the product data to it, then responding with an error or success to the user.
Now that we’re familiar with the design pattern, let’s write the rest of our handlers.
Our list function (handlers/list.js
) is quite simple. We don’t need any parameters. We call the DynamoDB scan
command to get all the products:
'use strict';
const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();
module.exports = () => dynamoDb.scan({ TableName: 'products' }).promise();
Our view function (handlers/view.js
) takes in the product ID and returns the corresponding product using dynamoDb.get
:
'use strict';
const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();
module.exports = (id) => {
const params = {
TableName: 'products',
Key: { id }
};
return dynamoDb.get(params).promise();
};
And our remove function (handlers/remove.js
) also takes a product ID, but uses the delete
command to remove the corresponding product.
'use strict';
const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();
module.exports = (id) => {
const params = {
TableName: 'products',
Key: { id }
};
return dynamoDb.delete(params).promise();
};
And, now, putting everything together, our handler.js
becomes:
'use strict';
const addProduct = require('./handlers/create');
const viewProduct = require('./handlers/view');
const listProducts = require('./handlers/list');
const removeProduct = require('./handlers/remove');
const create = (event, context, callback) => {
const data = JSON.parse(event.body);
addProduct(data)
.then(result => {
const response = { body: JSON.stringify(result) };
callback(null, response);
})
.catch(callback);
};
const list = (event, context, callback) => {
listProducts()
.then(result => {
const response = { body: JSON.stringify(result) };
callback(null, response);
})
.catch(callback);
};
const view = (event, context, callback) => {
viewProduct(event.pathParameters.id)
.then(result => {
const response = { body: JSON.stringify(result) };
callback(null, response);
})
.catch(callback);
};
const remove = (event, context, callback) => {
removeProduct(event.pathParameters.id)
.then(result => {
const response = { body: JSON.stringify({message: 'Product removed.'}) };
callback(null, response);
})
.catch(callback);
};
module.exports = {
create,
view,
remove,
list
};
Time to deploy
To deploy your service to AWS, you’ll need to first configure the serverless CLI with your AWS credentials. Serverless has published a guide on that (in video and text formats).
When you’ve done that, run this command:
serverless deploy
And that’s it! Let’s confirm that the deploy was successful.
You can see the function names are prefixed with stockup-dev
. “Stockup” here is the name of the service, while “dev” represents the stage. If you click on one of them, say the stockup-dev-create
function.
The pane on the right contains two lists of cards. The cards on the left are the events that trigger our app. HTTP requests show up in this pane via AWS API Gateway. The cards shown on the right are the resources our app uses. You can see our DynamoDB resource listed; the CloudWatch resource is added by default by AWS and used for logs and monitoring your app.
Now we need to find the URL for accessing this function. Clicking on the “API Gateway” trigger opens a pane below, and if you expand the “Details” box, you’ll see an “Invoke URL” property:
Now we can test the API. Open up Postman or whatever API testing tool you use, and try making a POST request to the /products
endpoint with some data to create a new product:
{
"name": "Vibranium shield",
"quantity": 1
}
Conclusion
That was fun, right? In a very short time, we were able to have a functioning API up and running without provisioning any servers. And we can go beyond that, by, for instance, choosing to run an entire web or mobile app serverless. If you’re interested, you can read more about the serverless framework at its official documentation, and check out the AWS Lambda Node.js docs too.