We've all been new to serverless before. In this post, I'll walk you through how to get up and running on your first application. Let's cut through the docs, shall we?
This application will be a backend email service that can be called over HTTP from a simple frontend like curl
. You will learn how to:
- Setup the development environment
- Create an application project
- Create a serverless service using a boilerplate template
- Run and test the service locally
- Deploy the service
- Run the service via the public HTTP endpoint
- Perform basic validation and error handling
Getting Started
I had been following serverless technologies for a while, and skimmed over the provider documentation and examples. It was really helpful to know the lay of the land and what was available out there. AWS Lambda's getting started documentation was helpful but overwhelming, and it was tedious to use the AWS Console. I wanted to use my own development workflow - code using my favorite editor, build using an easy to use toolchain, do a test/debug cycle, and finally deploy.
I had to make some choices before I started development:
- Programming language: NodeJS (my familiarity, all serverless platforms support it)
- Platform: AWS Lambda (most popular and mature, lots of supporting services)
- Toolset: Serverless Framework (opensource, repo with 17K+ stars, actively maintained, 2 week release cadence)
Setup
The initial setup was straightforward:
- Install NodeJS: download or using package manager
- Install the Serverless Framework:
npm install -g serverless
- Setup an AWS account
The AWS setup is necessary so that we can deploy our serverless application on AWS Lambda. After creating the AWS IAM user, we'll have to configure the credentials to give access to the Serverless Framework, for creating and managing resources on our behalf.
You can use either of the options to configure the credentials:
aws configure
or serverless config credentials
Setting up an account with AWS and configuring was the most time-consuming and painful process, but as you will see, it gets better after that. So keep chugging along.
Creating the Project
A little bit of planning on the project structure makes a lot of difference in visualizing the different parts of the system. We will build an application named postman
, with a simple frontend using curl
and a backend serverless email service, to send out emails to users over HTTP.
Here is an example that works for me:
|-- postman
|-- README.md
|-- frontend
`-- services
where,
frontend
folder holds the frontend applicationservices
folder holds the serverless service(s)
A structure like this provides clear separation for the non-serverless and serverless code for the overall application. The way the non-serverless portion of the application is written is totally your choice. In this post, I will primarily focus on the serverless portion of the application inside the services
folder. We will not create a frontend application, so we don't need the frontend
folder.
Creating the Email Service
Let's create an email service that will send out emails to users with some text. We will use Mailgun as our email service provider, and shoot for making the email service generic enough to be reused across other applications.
Starting With a Boilerplate Template
The Serverless Framework comes with boilerplate templates that make it really quick to get started. In our case, since we are using AWS as our provider and NodeJS as our language of choice, we will start with:
$ cd services
$ serverless create --template aws-nodejs --path email-service
which creates the service for us:
Serverless: Generating boilerplate...
Serverless: Generating boilerplate in "/home/svrless/apps/postman/services/email-service"
_______ __
| _ .-----.----.--.--.-----.----| .-----.-----.-----.
| |___| -__| _| | | -__| _| | -__|__ --|__ --|
|____ |_____|__| \___/|_____|__| |__|_____|_____|_____|
| | | The Serverless Application Framework
| | serverless.com, v1.16.0
-------'
Serverless: Successfully generated boilerplate for template: "aws-nodejs"
Read the docs for more info. on creating a service.
We will briefly look at the generated files and then modify them to suit our requirements.
|-- services
`-- email-service
|-- handler.js
`-- serverless.yml
Let's look at the serverless.yml
and the handler.js
files. I have removed the commented lines for brevity and clarity. Note: I have updated the function and handler names, to suit the example.
# serverless.yml
service: email-service
provider:
name: aws
runtime: nodejs6.10
functions:
send:
handler: handler.sendEmail
The serverless.yml file describes the service and it's where you define your provider
settings, functions
with their corresponding handlers, events
that trigger them, and provider resources
needed by the service.
# handler.js
'use strict';
module.exports.sendEmail = (event, context, callback) => {
callback(null, { message: 'Go Serverless! Simulating sending emails successful.', event });
};
The handler.js file contains your function code. The function definition in serverless.yml
will point to this handler.js
file and the function defined here. Note: I have updated the handler name and the code within to suit our example.
# serverless.yml
...
functions:
send:
handler: handler.sendEmail
In our example, handler: handler.sendEmail
in the serverless.yml
file points to module.exports.sendEmail
in the handler.js
file.
Also, note that while the handler method defined in handler.js
file is sendEmail
, the function name defined in serverless.yml
file is send
. The serverless.yml
file provides the glue for that mapping.
Invoke Locally
Let's run the send
function locally:
$ cd services/email-service
$ serverless invoke local --function send
{
"message": "Go Serverless! Simulating sending emails successful.",
"event": ""
}
We have successfully run a serverless function locally. It is very important to be able to run a function locally while you are developing, so you can get quick feedback.
Mapping an HTTP endpoint
To be able to share this service across other applications, it is important that our service can be accessed publicly. Let's map an HTTP endpoint to our function so that we can call our function publicly over the web. Again, the serverless.yml
file provides the glue to that mapping.
# serverless.yml
service: email-service
provider:
name: aws
runtime: nodejs6.10
functions:
send:
handler: handler.sendEmail
events:
- http:
path: email
method: post
The events
sub-section defines the mapping of the send
function to the http
endpoint of email
, and defines it to be of type post
.
Read the docs for detailed info. on the services, functions, events, and resources.
The current code as shown below, has a response which is not good for HTTP results:
callback(null, { message: 'Go Serverless! Simulating sending emails successful.', event });
The HTTP response expects a shape that has a status code and a body attribute. So we will change the response shape to follow the HTTP convention.
Testing it locally shows the newly-updated response.
$ serverless invoke local --function send
{
"statusCode": 200,
"body": "{\"message\":\"Go Serverless! Simulating sending emails successful.\",\"input\":\"\"}"
}
Deploy to AWS
Now, let's deploy it to AWS Lambda, so we can invoke the function over the HTTP endpoint we created.
$ sls deploy
Serverless: Packaging service...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
.....
Serverless: Stack create finished...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 (336 B)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..............................
Serverless: Stack update finished...
Service Information
service: email-service
stage: dev
region: us-east-1
api keys:
None
endpoints:
POST - https://xdsf3ghy2s.execute-api.us-east-1.amazonaws.com/dev/email
functions:
send: email-service-dev-send
There is a lot going on behind the scenes, which the Serverless Framework does for you. sls deploy
will deploy all functions in your service. If you are just iterating on a single function, you can use sls deploy function --function send
to just deploy the named function.
Read the docs for detailed info. on deployment of services.
Without going into the details of the deployment itself, let's first focus on the usability aspect of the service. The output above gives us the HTTP endpoint for our service.
endpoints:
POST - https://smif3ghy1c.execute-api.us-east-1.amazonaws.com/dev/email
Note, A stage identifier dev
has been added automatically to the endpoint and the function name. You can specify a stage
section in serverless.yml
but it uses dev
by default in our case.
Let's test the deployed function by running it via curl
:
curl -X POST https://smif3ghy1c.execute-api.us-east-1.amazonaws.com/dev/email -d '{}'
and we get the following response:
{
"message":"Go Serverless! Simulating sending emails successful.",
"input":{
"resource":"/email",
"path":"/email",
"httpMethod":"POST",
"headers":{
...
"Content-Type":"application/x-www-form-urlencoded",
"Host":"smif3ghy1c.execute-api.us-east-1.amazonaws.com",
...
},
"queryStringParameters":null,
"pathParameters":null,
"stageVariables":null,
"requestContext":{
"path":"/dev/email",
"stage":"dev",
"requestId":"1234acaa-5dcd-11e7-afcd-43efe3598b81",
"identity":{
...
"cognitoIdentityPoolId":null,
...
},
"resourcePath":"/email",
"httpMethod":"POST",
"apiId":"smif3ghy1c"
},
"body":"{}",
...
}
}
Note: For brevity, I left some important items in the output JSON and deleted the rest.
Another important aspect to note is the event
structure coming back from AWS API Gateway in the input
attribute of the output JSON, as shown above. We will use this event
structure to simulate local testing in the upcoming sections.
So, we have come full circle - starting with some boilerplate code, customizing a function, adding an HTTP endpoint, testing it locally, deploying it to AWS and finally running it live using the HTTP endpoint.
To remove the service, you can simply do sls remove
.
Takeaway: We did all this without thinking about servers or infrastructure, or how we will deploy the service after we are done with development. It just happened as part of our development workflow. The developer just focused on coding their business requirements, experimenting with features, and getting a quick feedback cycle - without worrying about deployment or provisioning infrastructure. That is the power and essence of serverless development.
Implementing the Email Service
Let's implement the email functionality and finish up our email service. At the end of it, we will have a fully functional service that can send emails.
Setting up Mailgun
We will use Mailgun as our backend email service provider. To use the service, sign up for a free account, retrieve the API keys from the dashboard, and configure them as part of the service.
Read the Mailgun docs, to get started on sending emails.
Configuring the Service
Let's go back to our handler method sendEmails
in handler.js
file and update it. We will use the Node.js module mailgun_js for sending emails using the Mailgun API.
Let's add the dependency. Note: You can npm init
and follow the prompts to create your initial package.json
file.
npm install mailgun-js --save
We should have:
{
...
"dependencies": {
"mailgun-js": "^0.11.2"
}
}
We need a place to store our configuration settings that the service can use. We will use a JSON formatted config file to stash our settings. The idea is to have one config file per stage of deployment i.e. dev, test, prod etc. Here is the config.prod.example.json
file with some settings. Rename the file to config.prod.json
and supply the values.
# config.prod.example.json
{
"region": "us-east-1",
"MAILGUN_APIKEY": "your-mailgun-key-here",
"MAILGUN_DOMAIN": "your-mailgun-domain-here"
}
Note: Make sure that you DON'T check-in the config.prod.json
config file with your secrets into source control.
Let's look at some updates to the configuration settings in the serverless.yml
file.
- add
custom
section for custom settings - add
environment
section to add the Mailgun settings - update the
provider
section to add a stage and a region
...
custom:
defaultStage: prod
currentStage: ${opt:stage, self:custom.defaultStage}
currentRegion: ${file(./config.${self:custom.currentStage}.json):region}
provider:
...
stage: ${self:custom.currentStage}
region: ${self:custom.currentRegion}
environment:
MAILGUN_APIKEY: ${file(./config.${self:custom.currentStage}.json):MAILGUN_APIKEY}
MAILGUN_DOMAIN: ${file(./config.${self:custom.currentStage}.json):MAILGUN_DOMAIN}
...
One thing to note here, is the use of ${file(./config.${self:custom.currentStage}
to read the correct config file based on the value of currentStage
which in turn is defined in the custom
section. So just by changing the value of the currentStage
and providing a config file named config.<stage>.json
, you can have multiple configurations per stage of deployment.
Sending Emails
With the configuration out of the way, we can now focus on the actual code to send emails via Mailgun.
'use strict';
const MAILGUN_APIKEY = process.env.MAILGUN_APIKEY
const MAILGUN_DOMAIN = process.env.MAILGUN_DOMAIN
const mailgun = require('mailgun-js')({
apiKey: MAILGUN_APIKEY,
domain: MAILGUN_DOMAIN
});
First, we initialize the mailgun-js
module with the API key and domain, retrieving the appropriate values from the process' env
space.
const fromAddress = `<demo@MAILGUN_DOMAIN>`;
const subjectText = "Serverless Email Demo";
const messageText = 'Sample email sent from Serverless Email Demo.';
const messageHtml = `
<html>
<title>Serverless Email Demo</title>
<body>
<div>
<h1>Serverless Email Demo</h1>
<span>Sample email sent from Serverless Email Demo.</span>
</div>
</body>
</html>
`
Then, we define some constants, text and HTML content that will form the body of the email.
module.exports.sendEmail = (event, context, callback) => {
var toAddress = "";
if (event.body) {
try {
toAddress = JSON.parse(event.body).to_address || "";
}
catch (e){}
}
if (toAddress !== "") {
const emailData = {
from: fromAddress,
to: toAddress,
subject: subjectText,
text: messageText,
html: messageHtml
};
// send email
mailgun.messages().send(emailData, (error, body) => {
if (error) {
// log error response
console.log(error);
callback(error);
} else {
const response = {
statusCode: 202,
body: JSON.stringify({
message: "Request to send email is successful.",
input: body,
}),
};
console.log(response);
callback(null, response);
}
});
} else {
const err = {
statusCode: 422,
body: JSON.stringify({
message: "Bad input data or missing email address.",
input: event.body,
}),
};
// log error response
console.log(err);
callback(null, err);
}
};
First, we define the handler method sendEmail
that is mapped to the function send
in the serverless.yml
file. This method does some basic validation and then calls the mailgun.messages().send()
API method passing in the required data via the emailData
structure.
We define some basic error handling code block, and if there is no error, it returns an appropriate response back. In case of an error, an appropriate error response is returned.
Note that the event.body
holds the input data that is passed in by the caller.
Testing Locally
Before we deploy our function, let's test it locally first.
To make it easier to test, let's create a data file send-email-data.json
that mocks the event
data structure passed in by the API Gateway:
{
"resource":"/email",
"path":"/email",
"httpMethod":"POST",
"headers":{},
"queryStringParameters":null,
"pathParameters":null,
"stageVariables":null,
"requestContext":{
"path":"/prod/email",
"stage":"prod",
"requestId":"123",
"resourcePath":"/email",
"httpMethod":"POST"
},
"body":"{\"to_address\":\"your@email.com\"}",
"isBase64Encoded":false
}
Although we have a lot of attributes in the mocked data structure, the bare minimum attribute we need to make it work is the body
attribute with its value.
$ sls invoke local --function send -p send-email-data.json
{
"statusCode": 202,
"body": "{\"message\":\"Request to send email is successful.\",\"input\":{\"id\":\"<20170721005146.92353.69A8F3012CBF231A@yourdomain.mailgun.org>\",\"message\":\"Queued. Thank you.\"}}"
}
Note, that we pass the mock event
structure in the send-email-data.json
file via the -p
flag. The event
structure contains the email address required by our function in the body
attribute.
And, now you should have an email in your inbox:
From: demo@mailgun_domain
Date: July 3, 2017 at 7:02:07 PM EDT
To: your@email.com
Subject: Serverless Email Demo
Serverless Email Demo
Sample email sent from Serverless Email Demo.
Alternatively, if we test for the not-so-happy path, i.e. calling without passing in an email address, we get our desired error message:
$ sls invoke local --function send
{
"statusCode": 422,
"body": "{\"message\":\"Bad input data or missing email address.\"}"
}
Deploying the Email Service
Now that we feel we have the functionality working well, let's deploy the service.
$ sls deploy
Serverless: Packaging service...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 (2.03 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
...............................
Serverless: Stack update finished...
Service Information
service: email-service
stage: prod
region: us-east-1
api keys:
None
endpoints:
POST - https://yt1i5ydiu4.execute-api.us-east-1.amazonaws.com/prod/email
functions:
send: email-service-prod-send
Note, that the stage used is prod
and it is reflected in the function name email-service-prod-send
, and the resulting endpoint.
Calling the Service
Let's call the live function that has been deployed to AWS. We will explore the --log
flag that will output logging information about our invocation.
$ sls invoke --function send -p send-email-data.json --log
{
"statusCode": 202,
"body": "{\"message\":\"Request to send email is successful.\",\"input\":{\"id\":\"<20170721005341.92497.BF1D6AF2BA38787B@yourdomain.mailgun.org>\",\"message\":\"Queued. Thank you.\"}}"
}
--------------------------------------------------------------------
START RequestId: 09bd13cd-6daf-11e7-87e0-e9f5c3dd5058 Version: $LATEST
2017-07-21 00:53:41.960 (+00:00) 09bd13cd-6daf-11e7-87e0-e9f5c3dd5058 { statusCode: 202,
body: '{"message":"Request to send email is successful.","input":{"id":"<20170721005341.92497.BF1D6AF2BA38787B@yourdomain.mailgun.org>","message":"Queued. Thank you."}}' }
END RequestId: 09bd13cd-6daf-11e7-87e0-e9f5c3dd5058
REPORT RequestId: 09bd13cd-6daf-11e7-87e0-e9f5c3dd5058 Duration: 439.95 ms Billed Duration: 500 ms Memory Size: 1024 MB Max Memory Used: 38 MB
The email is successfully sent.
Since the deployment output also shows the HTTP endpoint for our email service, we can use curl
to call that endpoint, passing it an email address.
$ curl -X POST https://yt1i5ydiu4.execute-api.us-east-1.amazonaws.com/prod/email -d '{"to_address":"your@email.com"}'
HTTP/1.1 202 Accepted
...
...
{"message":"Request to send email is successful.","input":{"id":"<20170721005711.6305.4865866169A6F957@sandbox77a6b258412d4bb9a2d12abeb33ac01e.mailgun.org>","message":"Queued. Thank you."}}
Again, another email is successfully sent.
Error Handling
To have our code gracefully handle errors and bad input, I have added some validation checks. The code makes sure that if bad input data is passed in, the function returns an error even before calling the mailgun
API method. That means quicker response times with potential cost gains at the Mailgun API side. Let's try out some error edge cases and see the results:
No Data
$ curl -i -X POST https://yt1i5ydiu4.execute-api.us-east-1.amazonaws.com/prod/email
{"message":"Bad input data or missing email address.","input":null}
Bad Data
$ curl -i -X POST -d '{"to_address":""}' https://yt1i5ydiu4.execute-api.us-east-1.amazonaws.com/prod/email
{"message":"Bad input data or missing email address.","input":"{\"to_address\":\"\"}"}
Note: Testing with bad input data -d '{}'
or -d '{"junk"}'
, results in the same output.
Malformed Email Address
$ curl -i -X POST -d '{"to_address":"junk"}' https://yt1i5ydiu4.execute-api.us-east-1.amazonaws.com/prod/email
{"message": "Internal server error"}
curl
kind of barfs, as the function causes an exception in this case. We can see the exception if we call the function with the framework:
$ sls invoke --function send --data '{"body":"{\"to_address\":\"junk\"}"}' --log
{
"errorMessage": "'to' parameter is not a valid address. please check documentation",
"errorType": "Error",
"stackTrace": [
...
]
}
--------------------------------------------------------------------
START RequestId: 0eb38aeb-6d9d-11e7-9787-2996c7e3fdb0 Version: $LATEST
2017-07-20 22:44:58.544 (+00:00) 0eb38aeb-6d9d-11e7-9787-2996c7e3fdb0 { Error: 'to' parameter is not a valid address. please check documentation
...
...
2017-07-20 22:44:58.545 (+00:00) 0eb38aeb-6d9d-11e7-9787-2996c7e3fdb0 {"errorMessage":"'to' parameter is not a valid address. please check documentation","errorType":"Error","stackTrace":
...
...
END RequestId: 0eb38aeb-6d9d-11e7-9787-2996c7e3fdb0
REPORT RequestId: 0eb38aeb-6d9d-11e7-9787-2996c7e3fdb0 Duration: 339.84 ms Billed Duration: 400 ms Memory Size: 1024 MB Max Memory Used: 41 MB
Note: We have been passing in data via the -p
flag in our previous examples but you can also pass in data using the --data
flag.
Leaving an exception unhandled is not acceptable, so let's refactor the code to return a proper HTTP response.
...
mailgun.messages().send(emailData, (error, body) => {
if (error) {
// log error response
// console.log(error);
// callback(error);
const response = {
statusCode: 400,
body: JSON.stringify({
message: error,
input: body,
}),
};
callback(null, response);
} else {
...
Now, when we deploy the function and call it, we get a better response.
curl -i -X POST -d '{"to_address":"junk"}' https://yt9i5yniu3.execute-api.us-east-1.amazonaws.com/prod/email
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 118
...
...
{"message":{"statusCode":400},"input":{"message":"'to' parameter is not a valid address. please check documentation"}}
This concludes the development of the application.
Code: Get the full source code to the application project on Github.
Summary
We explored the path to creating a serverless application from scratch, starting with a boiler plate template. We then customized the code, tested the code locally, and deployed it to AWS Lambda. Finally, we accessed the public function via the HTTP endpoint, and also looked at some error conditions and use cases. At the end of it all, we created a fully functional serverless backed email service that sent out emails via the Mailgun email service provider.