I work in the AWS ecosystem on a daily basis as a software developer. I get a lot of exposure to developing solutions using AWS technologies. I don’t have a huge amount to do with the dev ops side of things. There are specific teams developing tooling for that. When I decided to build a personal website, I wanted to use it as a learning experience for the things I don’t usually have to touch. It’s more effort than running a traditional CMS/blogging platform, but very rewarding to build something yourself.
This is how I end up building a static website using Gridsome. It’s running on Amplify as opposed to being served from an S3 bucket. I like that there’s an automatic process that builds and deploys the site on a push to the master branch. Building a blog this way means providing a lot of functions yourself. For example, building a contact form so that people can get it touch. You might ask why I don’t just put my socials out there. I don’t actually have any to share.
It’s not particularly hard to setup a contact form in AWS and there are several guides out there on how to do it. I even followed one myself. You created a Lambda function that sends an email through SES and expose it through API Gateway. I did want to go a step beyond and transform the steps into a CloudFormation template.
AWS Lambda is Amazon’s serverless computing platform. It allows you to run code without having to set up a server to run it on. The code for sending the email will be written for Lambda in JavaScript for the NodeJS runtime. API Gateway exposes the Lambda function to the internet. Simple Email Service (SES) is a simple email service that sends email, clearly.
CloudFormation is the AWS infrastructure management service. It provides a language you can use to model your infrastructure. From this you can perform repeatable, safe deployments without having to write custom code or manually create resources. And because a template is just a text file, you can subject it to the same source control practices you would any other source code you write. So the benefits to me of taking the time to transform the steps for building a CloudFormation template for a contact form are:
I can’t really claim authorship of this, so I will credit the authors of the original guide I found on creating a Lambda-powered contact form. So, thanks to KyleG on Linux Academy for writing a really good article that gave me the kickstart to automate the process with CloudFormation. You can read his post here.
"use strict";
const AWS = require("aws-sdk");
const sesClient = new AWS.SES();
exports.handler = (event, context, callback) => {
var emailObj = JSON.parse(event.body);
var params = getEmailMessage(emailObj);
var sendEmailPromise = sesClient.sendEmail(params).promise();
var response = {
statusCode: 200,
headers: {
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Origin": "https://" + process.env.WEBSITE,
"Access-Control-Allow-Methods": "OPTIONS,POST",
},
};
sendEmailPromise
.then(function (result) {
console.log(result);
callback(null, response);
})
.catch(function (err) {
console.log(err);
response.statusCode = 500;
callback(null, response);
});
};
function getEmailMessage(emailObj) {
var emailRequestParams = {
Destination: {
ToAddresses: [process.env.RECIPIENT],
},
Message: {
Body: {
Text: {
Data: emailObj.message,
},
},
Subject: {
Data:
"Message from " +
emailObj.name +
" - Sent via " +
process.env.WEBSITE,
},
},
Source: process.env.RECIPIENT,
ReplyToAddresses: [emailObj.email],
};
return emailRequestParams;
}
There are only really a few differences between my script and Kyle’s. I have externalised the recipient email address to an environmental variable named RECIPIENT
. I have also made reference to an environmental variable named WEBSITE
which tells me the origin of the message. This would be useful should I reuse the script in a different context.
I’ve also used the WEBSITE
environmental variable in populating the CORS headers added to the response. This is required because the API Gateway method cannot do this to proxied Lambda function calls.
Finally, the payload is different. I’ve decided to build the subject line in the script and ask for a name instead. My payload looks like this:
{
"name": <contact-name>,
"email": <contact-email>,
"message": <contact-message>
}
Let’s have a closer look into what is in the template.
There are a number of parameters that the CloudFormation template makes use of in order to setup the stack. Some have sensible defaults that can be overridden. The rest are provided at creation time, unless you add them to the template.
Name | Description |
---|---|
StageName | Defaults to contact . While this should refer to the lifecycle state of an API, here I am using it as the contact form endpoint. |
Bucket | This is the name of the S3 bucket into which you have uploaded the zip file containing the Lambda function. |
LambdaRuntime | The Lambda function runtime. I’ve defaulted this to nodejs12.x because the Lambda function is written for this runtime. |
SendEmailSource | The path in the bucket to the zip file containing contact-form.js , the Lambda function source. Default is artefacts/contact-form/contact-form-1.0.0.zip . I will be uploading the zip file to my bucket in the directory path artefacts/contact-form . The zip file is named contact-form-1.0.0.zip . I’m thinking ahead for any changes I make to the script. I could develop a build process that deploys a zip file with an incremented build number to this directory. Deployment using the CloudFormation template would be simple. |
SendEmailHandler | The handler method that is run when the Lambda function is executed. Default is contact-form.handler . |
Recipient | The email address to which to send contact form requests. |
Website | Your website URL. This is used to enable CORS so that your website can call the endpoint. |
The parameter AWS::StackName
is the name of the stack being created. It’s used to give resources relevant names that associate them with the stack.
Your recipient email address needs to be verified with AWS. You can do this in the Simple Email Service section of the AWS Console. Apparently you can automate this in CloudFormation, but that’s beyond the scope of this article.
The Lambda function will be making use of SES to send email, so we will need a policy for that. I’ve also included an X-Ray policy for tracing. Finally, the security role includes a policy for recording log events.
LambdaTracingPolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
Description: Allows lambda functions to use xray tracing
PolicyDocument:
Version: 2012-10-17
Statement:
- Action:
- xray:PutTraceSegments
- xray:PutTelemetryRecords
Effect: Allow
Resource: "*"
SendEmailPolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
Description: Allows policy holder to send email
PolicyDocument:
Version: 2012-10-17
Statement:
- Action:
- ses:SendEmail
- ses:SendRawEmail
Effect: Allow
Resource: "*"
SendEmailLambdaRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action:
- sts:AssumeRole
ManagedPolicyArns:
- !Ref LambdaTracingPolicy
- !Ref SendEmailPolicy
Policies:
- PolicyDocument:
Version: 2012-10-17
Statement:
- Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Effect: Allow
Resource:
- !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${AWS::StackName}-send-email-function:*"
PolicyName: !Sub "${AWS::StackName}-send-email-function-log-group-policy"
The Lambda function definition makes use of many of the template parameters.
FunctionName
incorporates the stack name into the value. I do this a lot throughout the template as a visible cue that the resources are grouped.Runtime
, Handler
, S3Bucket
and S3Key
are populated from parameters.RECIPIENT
and WEBSITE
are environmental variables used by the code.TracingConfig.Mode
is set to Active
. This is where the X-Ray policy is required.CostCode
. This will help me see where my costs are going each month.SendEmailFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub "${AWS::StackName}-send-email-function"
Runtime: !Ref LambdaRuntime
Handler: !Ref SendEmailHandler
Role: !GetAtt SendEmailLambdaRole.Arn
Code:
S3Bucket: !Ref Bucket
S3Key: !Ref SendEmailSource
Timeout: 25
TracingConfig:
Mode: Active
Environment:
Variables:
RECIPIENT: !Ref Recipient
WEBSITE: !Ref Website
Tags:
- Key: CostCode
Value: !Ref AWS::StackName
Next, we define the API Gateway. Again, the stack name is incorporated into the resource name. I have also added a tag for tracking costs.
ApiGateway:
Type: AWS::ApiGateway::RestApi
Properties:
Name: !Sub "${AWS::StackName}-api"
Description: "Contact form API"
Tags:
- Key: CostCode
Value: !Ref AWS::StackName
There are several resources that need to be created to allow the Lambda function to be called externally through API Gateway. Firstly, let’s start with a permission.
SendEmailLambdaApiGatewayInvoke:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !GetAtt SendEmailFunction.Arn
Principal: apigateway.amazonaws.com
SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGateway}/*/POST/
This permission allows SendEmailFunction
defined above to be invoked from the ApiGateway
we just defined.
Next we define the contact form endpoint as a method in the API Gateway.
ApiGatewayModel:
Type: AWS::ApiGateway::Model
Properties:
ContentType: "application/json"
RestApiId: !Ref ApiGateway
Schema: {}
SendEmailEndpoint:
Type: AWS::ApiGateway::Method
Properties:
AuthorizationType: NONE
HttpMethod: POST
Integration:
IntegrationHttpMethod: POST
Type: AWS_PROXY
Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SendEmailFunction.Arn}/invocations"
MethodResponses:
- ResponseModels:
application/json: !Ref ApiGatewayModel
StatusCode: 200
- ResponseModels:
application/json: !Ref ApiGatewayModel
StatusCode: 500
ResourceId: !GetAtt ApiGateway.RootResourceId
RestApiId: !Ref ApiGateway
There’s a little to go through here. The ApiGatewayModel
defines the response the Lambda function returns. For simplicity’s sake it’s an empty body. It’s associated with ApiGateway
.
The SendEmailEndpoint
method defines a POST
method that requires no authentication. It acts as a proxy to the SendEmailFunction
Lambda function. It’s response and error models are both the ApiGatewayModel
we just defined. It’s also associated with ApiGateway
.
For the moment I don’t have a subdomain for any APIs I create for the website. I will be just invoking the API Gateway URL. In order for this to work I will need to CORS-enable the endpoint. I noted above that the Lambda function contains code to add the appropriate CORS headers. This is because API Gateway does not transform the response coming from proxied invocations. You need to manually add them in your code. This is not necessarily enough. It’s common practice to make a call to the endpoint using the OPTIONS
HTTP method to ensure that CORS is supported. We need to setup an endpoint to enable this.
SendEmailOptionsMethod:
Type: AWS::ApiGateway::Method
Properties:
AuthorizationType: NONE
RestApiId: !Ref ApiGateway
ResourceId: !GetAtt ApiGateway.RootResourceId
HttpMethod: OPTIONS
Integration:
IntegrationResponses:
- StatusCode: 200
ResponseParameters:
method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
method.response.header.Access-Control-Allow-Origin: !Sub "'https://${Website}'"
ResponseTemplates:
application/json: ""
PassthroughBehavior: WHEN_NO_MATCH
RequestTemplates:
application/json: '{"statusCode": 200}'
Type: MOCK
MethodResponses:
- StatusCode: 200
ResponseModels:
application/json: "Empty"
ResponseParameters:
method.response.header.Access-Control-Allow-Headers: false
method.response.header.Access-Control-Allow-Methods: false
method.response.header.Access-Control-Allow-Origin: false
You’ll see that this is a MOCK
endpoint. This means it is an endpoint that will return a response without needing to link it to a real function. Useful for development, but also for this use case.
The final resources we need to define concern deploying ApiGateway
with the endpoints we’ve defined. This is done with a deployment stage and a AWS::ApiGateway::Deployment
resource. This deploys the endpoints to the stage so they may be called over the internet.
ApiGatewayStage:
Type: AWS::ApiGateway::Stage
Properties:
DeploymentId: !Ref SendEmailEndpointDeployment
RestApiId: !Ref ApiGateway
StageName: !Ref StageName
MethodSettings:
- HttpMethod: "*"
ResourcePath: "/*"
ThrottlingBurstLimit: 10
ThrottlingRateLimit: 100
SendEmailEndpointDeployment:
Type: AWS::ApiGateway::Deployment
DependsOn:
- SendEmailEndpoint
- SendEmailOptionsMethod
Properties:
RestApiId: !Ref ApiGateway
This can be simplified in CloudFormation where you don’t need to define a deployment stage. I decided to add it so that I may define some throttling on the endpoint so I don’t get spammed by bots or angry people. From an architecture standpoint it’s essential to consider such things up front rather than further down the line.
So, this is how I set up my contact form. There are a few improvements I could make and may do down the line:
contact
. Should I make use of a test environment the stage name would be used differently. This is something I nay revisit in time.Hope you found this helpful. I certainly enjoyed the process of creating something for me outside of my day job.
The code for this post can be found here. I found the following pages useful in creating my CloudFormation template: