Adding a REST API to a static site

Adding a REST API to a static site

I’ve been wanting to add a ‘contact me’ page to my blog for a while.

The HTML & CSS side of things is already done, i just need to handle the POST request.

There are quite a few static site form services out there, but i want to use API Gateway. That enables me to expand the API in the future for all sorts of things, rather than being limited by some service that handles the form for me instead.

Let’s see how i get on.

Today i’ll be using AWS CDK in python to deploy and manage all my infrastructure.

Table of contents

Architecture

Goal Architecture

Goal Architecture

Goal Architecture

HTML + JS

HTML

First things first, let’s check out the contact form HTML.

<form method="POST" class="row" action="{{ .Site.Params.contact_api | safeURL}}" id="contact-post">
  <div class="col-md-6">
    <input type="text" class="form-control mb-4" placeholder="Name" name="name" id="name">
  </div>
  <div class="col-md-6">
    <input type="email" class="form-control mb-4" placeholder="Email" name="email" id="email">
  </div>
  <div class="col-12">
    <input type="text" class="form-control mb-4" placeholder="Subject" name="subject" id="subject">
  </div>
  <div class="col-12">
    <textarea name="message" id="message" class="form-control mb-4" placeholder="Type You Message Here"
      rows="5"></textarea>
  </div>
  <div class="col-12">
    <button class="btn btn-primary btn-block" type="submit">Send</button>
  </div>
</form>

HTML forms are used to collect user input data. That’s perfect for my use case.

What gives this static page some brains is the client-side javascript script that will trigger the API call.

Javascript

window.addEventListener("load", () => {
  function sendData(event) {
    const formData = new FormData(event.currentTarget);
    const plainFormData = Object.fromEntries(formData.entries());
    const formDataJsonString = JSON.stringify(plainFormData);

    fetch(event.currentTarget.action, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: formDataJsonString,
    })
      .then((response) => response.json())
      .then((data) => {
        console.log('Server:', data);
        alert(data.body);
      })
      .catch((error) => {
        console.error('Error:', error);
        alert('Oops! Something went wrong.');
      });
  }

  // Get the form element
  const form = document.getElementById("contact-post");

  // Add 'submit' event handler
  form.addEventListener("submit", (event) => {
    event.preventDefault();
    sendData(event);
  });
});

Let’s start small and built up to it.

const form = document.getElementById("contact-post");

// Add 'submit' event handler
form.addEventListener("submit", (event) => {
  event.preventDefault();
  sendData(event);
});

First we define the form in js by the element ID ‘contact-post’. Find this in the HTML for the form element.

Then we add an event handler for the element (button in this case) of type ‘submit’.

Within the event handler we must disable the Default action of the submit button and replace it with our custom API call.

function sendData(event) {
    const formData = new FormData(event.currentTarget);
    const plainFormData = Object.fromEntries(formData.entries());
    const formDataJsonString = JSON.stringify(plainFormData);

    fetch(event.currentTarget.action, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: formDataJsonString,
    })
      .then((response) => response.json())
      .then((data) => {
        console.log('Server:', data);
        alert(data.body);
      })
      .catch((error) => {
        console.error('Error:', error);
        alert('Oops! Something went wrong.');
      });
  }

First we take the form data and convert it into JSON format. This is important for me as my API Gateway backed by Lambda will receive this data. JSON makes this easy.

Then using the Fetch API, i submit the POST request, telling the server that the content type is ‘application/json’.

Finally, i handle the response by both logging to the console, and creating a pop up alert with important information that the Lambda embedded in the body of the response.

Because this is a static site, i wanted a clear and simple notification if something went wrong with submitting the contact form.

Pop up information

Pop up information

Pop up information

API Gateway

You may have noticed in the HTML form element, action was set to {{ .Site.Params.contact_api | safeURL}}.

This value is a parameter i have within the site configuration file - set to the api resource i want to call - in my case that’s https://api.jeremyritchie.com/contact.

We’ll get to the custom domain in a moment, but first, let’s create that API.

self.cert = certificatemanager.DnsValidatedCertificate(
    self,
    "Certificate",
    region="us-east-1",
    hosted_zone=self.data_stack.hosted_zone,
    domain_name=f"api.{website_name}",
    validation=certificatemanager.CertificateValidation.from_dns(self.data_stack.hosted_zone)
)

self.api = apigateway.RestApi(
    self,
    f"API",
    rest_api_name=website_name,
    domain_name=apigateway.DomainNameOptions(
        certificate=self.cert,
        domain_name=f"api.{website_name}",
        endpoint_type=apigateway.EndpointType.EDGE,
    ),
    default_cors_preflight_options=apigateway.CorsOptions(
        allow_origins=[f"https://{website_name}"],
        allow_methods=apigateway.Cors.ALL_METHODS
    )
)

contact = self.api.root.add_resource("contact")
contact.add_method("POST", integration=apigateway.LambdaIntegration(self.contact_lambda))

Here i’m creating a REST API and assigning it a new custom domain name.

I chose to use a REST API over HTTP because it has more features and allows me to deploy the API to the edge. That means it will be more responsive globally. Right now that won’t matter much, but once i add more methods that can be cached to the API, it can make a difference.

Enabling CORS is important because browsers restrict cross-origin HTTP requests initiated from scripts. The Fetch API follow the same-origin policy and this means application using the Fetch API can only request resources from the same origin the application was loaded from unless the response from other origins includes the right CORS headers.

My website is hosted on a separate origin to the API, so we will need to enable CORS.

Custom Domain Name

When you define a domain_name for an API, in the background it is creating a cloudfront distribution that points to your API. API Gateway URL’s are long and generally look something like this: https://sdfdsf94j.execute-api.ap-southeast-2.amazonaws.com/prod

Once you define the domain name on the API, it exposes a cloudfront domain name that will redirect to your api. You can then go ahead and add the DNS record to point your custom domain, to the cloudfront.

route53.ARecord(
    self,
    "APIRecord",
    zone=self.data_stack.hosted_zone,
    record_name="api."+website_name,
    target=route53.RecordTarget.from_alias(targets.ApiGatewayDomain(self.api.domain_name))
)

Lambda

import json
import boto3
import os

def lambda_handler(event, context):
    print(event)
    if len(event) == 0:
        return {
                "isBase64Encoded": False,
                "statusCode": 400,
                "body": "Failure",
                "headers": {
                    "content-type": "application/json"
                }
            }
    client = boto3.client('sns')

    try:
        if event['name'] == '':
            return {
                "isBase64Encoded": False,
                "statusCode": 400,
                "body": "Name is missing",
                "headers": {
                    "content-type": "application/json"
                }
            }
        elif event['email'] == '':
            return {
                "isBase64Encoded": False,
                "statusCode": 400,
                "body": "Email is missing",
                "headers": {
                    "content-type": "application/json"
                }
            }
        elif event['subject'] == '':
            return {
                "isBase64Encoded": False,
                "statusCode": 400,
                "body": "Subject is missing",
                "headers": {
                    "content-type": "application/json"
                }
            }
        elif event['message'] == '':
            return {
                "isBase64Encoded": False,
                "statusCode": 400,
                "body": "Message is missing",
                "headers": {
                    "content-type": "application/json"
                }
            }
    except Exception as e:
        return {
                "isBase64Encoded": False,
                "statusCode": 500,
                "body": str(e),
                "headers": {
                    "content-type": "application/json"
                }
            }
    try:
        client.publish(
            TopicArn=os.environ['topic_arn'],
            Message=f"""
            Contact from JeremyRitchie.com.\n\n
            Name: {event['name']}\n
            Email: {event['email']}\n
            Subject: {event['subject']}\n
            Message: {event['message']}\n
            """
        )
        return {
          "isBase64Encoded": False,
          "statusCode": 200,
          "body": "Message successfully submitted",
          "headers": {
            "content-type": "application/json"
          }
        }
    except Exception as e:
        return {
                "isBase64Encoded": False,
                "statusCode": 500,
                "body": str(e),
                "headers": {
                    "content-type": "application/json"
                }
            }

This is a very simple function.

First i check the input data for missing information/errors. This is important because if there is something missing or there is a problem, that is sent as the response back to the Website, where that is displayed to the user.

If everything looks good, i go ahead and publish the information to an SNS topic to which my email address is subscribed.

Testing

Submitting API Call

Submitting API Call

Submitting API Call

Email

Email

Email

Conclusion

WooHoo!

Today we added the HTML & JS required to send the request on the client side, then added the API Gateway and Lambda to handle the request. All while wrapping it in a custom domain on a API Gateway CLoudfront Distribution serving everything at the edge.

That’s one highly available website if i can say so myself!

comments powered by Disqus