Creating a contact form with AWS Lambda and .Net Core

Alex Hyett - Nov 5 '20 - - Dev Community

I recently switched my website over from a standard Wordpress installation to a static site built with Gatsby.js and React. With Gatsby.js you get a blazingly fast website but you do miss out on some of the features that come as standard with Wordpress such as comments and forms.

I have been using Disqus for my commenting system and Formspree.io for a simple way to send an email when someone fills out my contact form.

In this post, I will show you how I replaced my Formspree.io contact form with an AWS Lambda function.

Getting Started

As the title of this post suggests I am going to be using C# .Net Core for my Lambda function. To get started with Amazon Lambda and .Net Core you will want to install the Amazon Lambda Tools and Templates. This will also give you access to the dotnet lambda command which we will be using later as well as a number of starter templates

dotnet tool install -g Amazon.Lambda.Tools
dotnet new -i Amazon.Lambda.Templates
Enter fullscreen mode Exit fullscreen mode

After installing this you can view the available templates by typing in dotnet new -all. You should get a list that looks something like this:

Usage: new [options]

Options:
  -h, --help Displays help for this command.
  -l, --list Lists templates containing the specified name. If no name is specified, lists all templates.
  -n, --name The name for the output being created. If no name is specified, the name of the current directory is used.
  -o, --output Location to place the generated output.
  -i, --install Installs a source or a template pack.
  -u, --uninstall Uninstalls a source or a template pack.
  --nuget-source Specifies a NuGet source to use during install.
  --type Filters templates based on available types. Predefined values are "project", "item" or "other".
  --dry-run Displays a summary of what would happen if the given command line were run if it would result in a template creation.
  --force Forces content to be generated even if it would change existing files.
  -lang, --language Filters templates based on language and specifies the language of the template to create.

Templates Short Name Language Tags
---------------------------------------------------------------------------------------------------------------------------------------------------------
Order Flowers Chatbot Tutorial lambda.OrderFlowersChatbot [C#] AWS/Lambda/Function
Lambda Detect Image Labels lambda.DetectImageLabels [C#], F# AWS/Lambda/Function
Lambda Empty Function lambda.EmptyFunction [C#], F# AWS/Lambda/Function
Lex Book Trip Sample lambda.LexBookTripSample [C#] AWS/Lambda/Function
Lambda Simple DynamoDB Function lambda.DynamoDB [C#], F# AWS/Lambda/Function
Lambda Simple Kinesis Firehose Function lambda.KinesisFirehose [C#] AWS/Lambda/Function
Lambda Simple Kinesis Function lambda.Kinesis [C#], F# AWS/Lambda/Function
Lambda Simple S3 Function lambda.S3 [C#], F# AWS/Lambda/Function
Lambda Simple SQS Function lambda.SQS [C#] AWS/Lambda/Function
Lambda ASP.NET Core Web API serverless.AspNetCoreWebAPI [C#], F# AWS/Lambda/Serverless
Lambda ASP.NET Core Web Application with Razor Pages serverless.AspNetCoreWebApp [C#] AWS/Lambda/Serverless
Serverless Detect Image Labels serverless.DetectImageLabels [C#], F# AWS/Lambda/Serverless
Lambda DynamoDB Blog API serverless.DynamoDBBlogAPI [C#] AWS/Lambda/Serverless
Lambda Empty Serverless serverless.EmptyServerless [C#], F# AWS/Lambda/Serverless
Lambda Giraffe Web App serverless.Giraffe F# AWS/Lambda/Serverless
Serverless Simple S3 Function serverless.S3 [C#], F# AWS/Lambda/Serverless
Step Functions Hello World serverless.StepFunctionsHelloWorld [C#], F# AWS/Lambda/Serverless
Console Application console [C#], F#, VB Common/Console
Class library classlib [C#], F#, VB Common/Library
Unit Test Project mstest [C#], F#, VB Test/MSTest
NUnit 3 Test Project nunit [C#], F#, VB Test/NUnit
NUnit 3 Test Item nunit-test [C#], F#, VB Test/NUnit
xUnit Test Project xunit [C#], F#, VB Test/xUnit
Razor Page page [C#] Web/ASP.NET
MVC ViewImports viewimports [C#] Web/ASP.NET
MVC ViewStart viewstart [C#] Web/ASP.NET
ASP.NET Core Empty web [C#], F# Web/Empty
ASP.NET Core Web App (Model-View-Controller) mvc [C#], F# Web/MVC
ASP.NET Core Web App webapp [C#] Web/MVC/Razor Pages
ASP.NET Core with Angular angular [C#] Web/MVC/SPA
ASP.NET Core with React.js react [C#] Web/MVC/SPA
ASP.NET Core with React.js and Redux reactredux [C#] Web/MVC/SPA
Razor Class Library razorclasslib [C#] Web/Razor/Library/Razor Class Library
ASP.NET Core Web API webapi [C#], F# Web/WebAPI
global.json file globaljson Config
NuGet Config nugetconfig Config
Web Config webconfig Config
Solution File sln Solution

Examples:
    dotnet new mvc --auth Individual
    dotnet new lambda.SQS
    dotnet new --help
Enter fullscreen mode Exit fullscreen mode

When I first started I used the empty function template dotnet new lambda.EmptyFunction for creating new lambda functions.

If you want a boilerplate template already set up with dependency injection, Serilog and config file then you can check out my project on GitHub.

If you just want a working contact form then you can check out lambda-contact-form.

Setting up the Lambda function

To get started we are going to set up a simple Lambda function from my lamda-dotnet-console project.

This function takes a string and capitalises it. If you are using my lamda-dotnet-console project you will see that I have added a prefix, read from appsettings.json to make sure the config is being read properly.

Lambda functions are set up as library projects which means to test it you are going to have either create another console application that uses the library or use the unit tests that come with it.

To get started we are just going to deploy what we have.

Deploying to AWS

I am going to assume you have used AWS before and already have a credential profile set up on your computer.

Deployment is simple with the Lambda tools we installed.

dotnet lambda deploy-function lambda-dotnet-console
Enter fullscreen mode Exit fullscreen mode

The argument after deploy-function is the name of your function. You can call it whatever you want but you will need to update what you have written in the defaults file aws-lambda-tools-defaults.json.

During deployment, it will ask you to set up IAM Credentials or pick one you may have created before.

Once it is deployed you can test it using the following:

dotnet lambda invoke-function lambda-dotnet-console --payload "Hello World"
Enter fullscreen mode Exit fullscreen mode

If you are using my GitHub project you should see something like this:

Amazon Lambda Tools for .NET Core applications (3.1.1)
Project Home: https://github.com/aws/aws-extensions-for-dotnet-cli, https://github.com/aws/aws-lambda-dotnet

Payload:
"TestHELLO WORLD"

Log Tail:
START RequestId: b40718bb-fec0-11e8-b682-07112db8d970 Version: $LATEST
END RequestId: b40718bb-fec0-11e8-b682-07112db8d970
REPORT RequestId: b40718bb-fec0-11e8-b682-07112db8d970 Duration: 3523.77 ms Billed Duration: 3600 ms Memory Size: 256 MB Max Memory Used: 67 MB
Enter fullscreen mode Exit fullscreen mode

Don’t worry too much about the Billed Duration as you get 1 Million Requests and 400,000 GB-Seconds per month for free.

Setting environment variables

To configure our function we are going to use environment variables in the same way we do for Docker containers. Using environment variables we can override the settings in appsettings.json.

We can do this because we have the NuGet package Microsoft.Extensions.Configuration.EnvironmentVariables installed.

This is then set up in our function like this:

var configuration = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json", optional : false, reloadOnChange : true)
    .AddEnvironmentVariables(prefix: "LAMBDA_")
    .Build();

_appSettings = new AppSettings();
configuration.GetSection("App").Bind(_appSettings);
Enter fullscreen mode Exit fullscreen mode

You can override the prefix using the following environment variable: LAMBDA_App__Prefix(Note the double underscore). The project is set up to use Serilog, I recommend Seq if you want a nice way to view logs. You can follow my tutorial on how to set this up on AWS.

You will need to log on to the AWS Console if you want to change the environment variables. There probably is a way to do this on the command line but for the odd change, it is just easier to do it using the interface.

Once you have logged on to AWS, use the Services search to find Lambda. Then click on your Lambda function and scroll down to the environment variable section.

Lambda Environment Variables

We are going to update the prefix just to make sure it works. Remember to click save and then we can invoke our function again in the same way as before. Your output should look like this:

Amazon Lambda Tools for .NET Core applications (3.1.1)
Project Home: https://github.com/aws/aws-extensions-for-dotnet-cli, https://github.com/aws/aws-lambda-dotnet

Payload:
"LambdaHELLO WORLD"

Log Tail:
START RequestId: 9b30342f-fec5-11e8-b090-d5e24f128b73 Version: $LATEST
END RequestId: 9b30342f-fec5-11e8-b090-d5e24f128b73
REPORT RequestId: 9b30342f-fec5-11e8-b090-d5e24f128b73 Duration: 3520.72 ms Billed Duration: 3600 ms Memory Size: 256 MB Max Memory Used: 67 MB
Enter fullscreen mode Exit fullscreen mode

If you set it up correctly you should see LambdaHELLO WORLD instead of TestHELLO WORLD.

Creating a contact form endpoint

My goal for the contact form endpoint was to be able to replace the action URL on my contact form with my lambda function so I didn’t need to use FormSpree anymore.

Requirements

These are the requirements for my new endpoint.

  • Should accept the fields Name, Email, Phone, Website and Message.
  • Should get fields from a form submit e.g. a content type of application/x-www-form-urlencoded.
  • Should send an email to my chosen address.
  • Should redirect to a thank you page on submission.
  • Should log messages and errors to Seq.

Sending an Email in .Net Core

I am using MailKit and MimeKit NuGet packages for sending the email. This is the code I am using:

public async Task SendEmail(string body)
{
    var message = new MimeMessage();
    message.From.Add(new MailboxAddress("Contact Form", _settings.EmailFrom));
    message.To.Add(new MailboxAddress(_settings.EmailTo));
    message.Subject = "Contact Request";
    message.Body = new TextPart(TextFormat.Text)
    {
        Text = body
    };

    using(var client = new SmtpClient())
    {
        client.Connect(_settings.Host, _settings.Port, _settings.Port == 465);
        client.AuthenticationMechanisms.Remove("XOAUTH2");
        await client.AuthenticateAsync(_settings.Username, _settings.Password);
        await client.SendAsync(message);
        await client.DisconnectAsync(true);
    }
}
Enter fullscreen mode Exit fullscreen mode

I have put all the settings for the email in appsettings.json so they can be overridden using environment variables. Again you can see a working example on GitHub.

I also updated my FunctionHandler to take a ContactRequest object and return a JSON object with the location of the thank you page. By default, the function will just convert your object to a string so we need to add an additional JSON serializer to get the right output. Your function should look something like this:

[LambdaSerializer(typeof(Amazon.Lambda.Serialization.Json.JsonSerializer))]
public object FunctionHandler(ContactRequest input, ILambdaContext context)
{
    // Function code here.
    return new { location = _appSettings.ReturnUrl };
}
Enter fullscreen mode Exit fullscreen mode

Once deployed you will need to update the environment variables for your particular email addresses and SMTP server settings. I am using Amazon SES to send my emails but you can use any SMTP server.

Setting up Amazon API Gateway

So by now, you should have a lambda function that when invoked on the command line it will send you an email. You should be able to call your function with this and get an email:

dotnet lambda invoke-function lambda-contact-form -payload "{ 'name': 'Alex Hyett', 'email': 'hello@alexhyett.com', 'phone': '0123456789', 'website': 'https://www.alexhyett.com', 'body': 'This is a message'}"
Enter fullscreen mode Exit fullscreen mode

You should then get back a JSON payload with the URL you want to redirect to:

Payload:
{"location":"https://www.alexhyett.com/thank-you"}
Enter fullscreen mode Exit fullscreen mode

This all very good but isn’t an endpoint yet so we can’t use it for our contact form. We are going to use Amazon’s API Gateway to create the endpoint which will call our lambda function.

Our lambda function only accepts application/json as a content type. However, as we are calling this from a form submission we are going to need to convert application/x-www-form-urlencoded to application/json in API Gateway before passing to our Lambda function.

Finally, we are going to get API Gateway to return a 302 redirect to our chosen page so users see a nice page after clicking submit.

Create the API

Using the service’s search in the AWS Console find the API Gateway page.

API Gateway

After clicking Get Started you can then Create your API.

New API

Once created use the Actions menu to Create a Method and choose POST. Then search for your lambda function in the box provided.

Method Setup

Click OK on the popup to give permission for API Gateway to call your lambda function.

Converting application/x-www-form-urlencoded to application/json

On the next screen, you will see all the steps that go into calling your Lambda Function.

Method Execution

We are going to update the Integration Request so that it converts the application/x-www-form-urlencoded it receives into application/json before passing it on to our Lambda function.

Click on Integration Request and scroll down to Mapping Templates. Choose the recommended “When there are no templates defined” option and add application/x-www-form-urlencoded as the Content-Type.

For the template, we are going to use this fantastic gist by fellow developer Ryan Ray.

Just in case it goes offline you can find it here but please check the gist for an up to date version.

## convert HTML POST data or HTTP GET query string to JSON

## get the raw post data from the AWS built-in variable and give it a nicer name
#if ($context.httpMethod == "POST")
 #set($rawAPIData = $input.path('$'))
#elseif ($context.httpMethod == "GET")
 #set($rawAPIData = $input.params().querystring)
 #set($rawAPIData = $rawAPIData.toString())
 #set($rawAPIDataLength = $rawAPIData.length() - 1)
 #set($rawAPIData = $rawAPIData.substring(1, $rawAPIDataLength))
 #set($rawAPIData = $rawAPIData.replace(", ", "&"))
#else
 #set($rawAPIData = "")
#end

## first we get the number of "&" in the string, this tells us if there is more than one key value pair
#set($countAmpersands = $rawAPIData.length() - $rawAPIData.replace("&", "").length())

## if there are no "&" at all then we have only one key value pair.
## we append an ampersand to the string so that we can tokenise it the same way as multiple kv pairs.
## the "empty" kv pair to the right of the ampersand will be ignored anyway.
#if ($countAmpersands == 0)
 #set($rawPostData = $rawAPIData + "&")
#end

## now we tokenise using the ampersand(s)
#set($tokenisedAmpersand = $rawAPIData.split("&"))

## we set up a variable to hold the valid key value pairs
#set($tokenisedEquals = [])

## now we set up a loop to find the valid key value pairs, which must contain only one "="
#foreach( $kvPair in $tokenisedAmpersand )
 #set($countEquals = $kvPair.length() - $kvPair.replace("=", "").length())
 #if ($countEquals == 1)
  #set($kvTokenised = $kvPair.split("="))
  #if ($kvTokenised[0].length() > 0)
   ## we found a valid key value pair. add it to the list.
   #set($devNull = $tokenisedEquals.add($kvPair))
  #end
 #end
#end

## next we set up our loop inside the output structure "{" and "}"
{
#foreach( $kvPair in $tokenisedEquals )
  ## finally we output the JSON for this pair and append a comma if this isn't the last pair
  #set($kvTokenised = $kvPair.split("="))
 "$util.urlDecode($kvTokenised[0])" : #if($kvTokenised[1].length() > 0)"$util.urlDecode($kvTokenised[1])"#{else}""#end#if( $foreach.hasNext ),#end
#end
}
Enter fullscreen mode Exit fullscreen mode

Your page should now look like this, remember to click save.:

Mapping Template

Finally, we need to set up the response so that it returns a 302 with a Location header pointing to our return page.

Returning 302

Go back to the Method Execution page and click Method Response. We are going to delete the default 200 response and replace it with a 302 with a location header. Your method response should look like this:

Method Response

Go back to Method Execution and click on Integration Response. Again delete the existing 200 response and create a new one with a 302 response status.

You then need to add in a mapping value of integration.response.body.location. The location at the end should match the value returned in your JSON response from your lambda function.

Your response should look like this:

Integration Response

Finally after saving choose Deploy API from the Actions menu. It is worth noting if you update any settings in API Gateway in the future you need to click Deploy API before the changes will take effect.

You will be prompted to create a new deployment stage. Call it something like prod and click Deploy.

Deployment Stage

Once deployed you will get an invoke URL at the top of the page. You should be able to use this as the Action URL on your contact form.

Custom Domain Name

Finally, you might want to use your own domain name for your API. To do this go to Custom Domain Names on the side and enter in the domain you want to use. Such as api.yourdomain.com.

You will need to have a certificate set up for your domain using ACM for the region us-east-1 so that SSL will work.

Custom Domain Name

Once saved it will take about 40 mins to initialise and start working. You will, however, get a CloudFront Url which you will need to add to Route 53 as an A Record Alias.

Finally, you need to add a base path mapping to your endpoint. I have used /contact for mine.

Base Path Endpoint

Once initialised you should be able to replace your URL with https://api.yourdomain.com/contact.

Conclusion

Using AWS Lambda functions is a great way to create small bits of functionality that scales well. This can either be standalone like this method or part of a larger micro-services architecture. It is also possible to host a full .Net Core API with Lambda and API Gateway but I will save that for another post!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .