DEV Community

Toul
Toul

Posted on • Edited on • Originally published at toul.io

How to use GoLang, Hugo, and Lambda for a Single Page Application

Intro

In this post, I'll show how I built the website Free Resume Scanner with Hugo and AWS Serverless. The architecture of the app is similar to most Single Page Web applications. But instead of using React.js, or whatever the trendy Javascript framework is at the moment. We'll use Hugo as our front end. I personally chose this setup with blogging in mind because blogging is one of the best ways to boost a website's search engine optimization (SEO) to get web traffic aka users.I hope that you use this as a starting point in your dev adventures and even more so consider building small open source tools as well.

Feel free to open pull requests on any of the repositories to add things you think are missing or are areas of improvement– it's an easy way to boost your resume with open source contributions

N.B. The Original Post may be found here with a few extra goodies in terms of formatting and ability to comment if stuck (goes right to my inbox).

TL;DR - Give me the code!

Part I. Setting up the Frontend

There's not really any explicit order to doing any of the steps. However, I prefer to start with the domain and basic site. Because at the very least you can start blogging for your website idea to build up your SEO ranking. Also, it's a great stopping point while still being a huge win, so that you can take a well earned break and rest before tackling the next two pieces.

I.a Create a Hugo Theme

Building a custom theme may take more time in the beginning but if you really want to be able to customize your idea in every possible then I firmly believe this is the way to go.
Install Hugo if you haven't already

I.a.1 Creating a theme

Open your terminal and create your awesome new website theme.

> hugo new site your-awesome-new-website

I.a.2 Customizing theme

Now, that you have the project laid out it's time to create a theme fitting for your website.

> cd your-awesome-new-website
> hugo new theme yourAwesomeTheme
Enter fullscreen mode Exit fullscreen mode

Which should output the following

.
├── archetypes
│   └── default.md
├── config.toml
├── content
├── data
├── layouts
├── resources
│   └── _gen
│       ├── assets
│       └── images
├── static
└── themes
    └── exampleTheme
        ├── LICENSE
        ├── archetypes
        │   └── default.md
        ├── layouts
        │   ├── 404.html
        │   ├── _default
        │   │   ├── baseof.html
        │   │   ├── list.html
        │   │   └── single.html
        │   ├── index.html
        │   └── partials
        │       ├── footer.html
        │       ├── head.html
        │       └── header.html
        ├── static
        │   ├── css
        │   └── js
        └── theme.toml
Enter fullscreen mode Exit fullscreen mode

Go ahead and check that your code works by typing:

> hugo server

which will generate your static website and a server to view it at localhost in your web browser.
It will auto-update itself as you are changing the files so that means no need to run hugo server every time you want to see changes.

Lastly, open the config.toml file and check that it looks like this

baseURL = "http://your-awesome-site.org/"
languageCode = "en-us"
title = "My New Hugo Site"
Enter fullscreen mode Exit fullscreen mode

Update it to read

baseURL = "https://yourAwesomeSite.com"
languageCode = "en-us"
title = "Your Awesome Site"
Enter fullscreen mode Exit fullscreen mode

Now, create a sample post so that you can get the hang of Hugo.

> hugo new posts/my-awesome-essay.md

Great, now that you have a general idea of Hugo let's make the site viewable on the internet.

I.b Get Amped by Publishing the First Draft

Amplify is the easiest way in my opinion as an AWS Stan to deploy and manage a static website with CI/CD. I've tried other static website hosts, but have learned that if you stick within the AWS ecosystem then life is much easier and oftentimes safer.

I.b.1 Purchase a domain name

If you do not have a list of unused domain names based on late-night whimsical ideas to pick from then go ahead and buy one now within Route53. Once, you have your domain name go to AWS Amplify.

I.b.2 Go to AWS Amplify

Go ahead and press create a new app and name your app appropriately. Once it's been created you'll see a screen that says something along the lines of getting source code. Select, your source code provider of choice. Then authorize the Amplify Github Application to be able to read from your repository.

I.b.3 Free SSL (https)

You'll see this in the pop-up at the top of the screen as one of the five things that AWS recommends you do to get the most out of Amplify. It is as simple as clicking the text and pressing next to the questions. Amplify will automatically provision your Amazon Certificate for HTTPS behind the scenes, create the appropriate records in Route53, and set up a CloudFront distribution to reduce latency for users. All of which is why I choose to use AWS Amplify.

I.b.4 Select Production Branch

Click Domains in the AWS Amplify console and then subdomains for your app. Now you can set up your production branch, which in our case will be the main branch. It will also be the branch associated with the top-level domain https://freeresumescanner.com
However, you're welcome to create a branch named 'prod' and select that as it if you want. Notice, that you can have multiple branches to your site to set up subdomains– a very useful feature. For E.g. you could create dev a branch and have the subdomain https://dev.your-cool-app-idea.com.

I.b.5 CI/CD for FREE!

Here's another reason why I really enjoy Amplify and that's automatic Continous Integration and Continous Delivery for your website out of the box. That means every time that you have a commit that is merged to your branch associated with the top-level domain it'll be redeployed within minutes (static sites build fast). Meaning, that you can deploy to prod multiple times a day.
Now, verify that your front end is viewable by typing it in your URL bar.

Part II. Creating Backend

This next section requires GoLang so if you don't have it installed then here's the official installation source.

Now, the trick to coding a lambda is adhering to the standards that AWS outlines for naming the files and function names. I'm going to show you the correct way to structure your files, and as a consequence, I will not post the full code in the article so that it isn't confusing. Once, that makes sense then go ahead and view the full code listed above for a more complex example.

Let's begin, open your terminal and create your directory, and initiate your module.

> mkdir yourWebAppIdea
> cd yourWebAppIdea
> go mod init yourWebAppIdea
Enter fullscreen mode Exit fullscreen mode

Now, onto the files.

II.a main_test.go

Create the file main_test.go
and paste the following within it

package main

import "testing"

func TestHandleRequest(t *testing.T) {
    HandleRequest()
}
Enter fullscreen mode Exit fullscreen mode

II.b main.go

Now, create the application code file main.go and add the following.

package main

import (
    "encoding/json"
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "math/rand"
    "net/mail"
    "regexp"
    "sort"
    "strings"
)

func HandleRequest(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    var result *Result
    var event Event

    // IMPORTANT! Set Cors headers
    resp := events.APIGatewayProxyResponse{Headers: make(map[string]string)}
    resp.Headers["Access-Control-Allow-Origin"] = "*"

    err := json.Unmarshal([]byte(req.Body), &event)
    if err != nil {
        resp.StatusCode = 500
        resp.Body = err.Error()
        return resp, nil
    }
    // do stuff with response..
    resp.StatusCode = 200
    resp.Body = string(re)
    return resp, nil

func main() {
    lambda.Start(HandleRequest)
}
Enter fullscreen mode Exit fullscreen mode

This is the basic structure of a GoLang lambda where we have a function defined that is started by lambda.Start(function_name) and it must be this way so that AWS knows how to deploy the lambda.

II.c Build and push the package to S3 bucket

Time to build the binary, zip, and ship it to S3, if you don't have an S3 bucket for storing artifacts then create one named your-awesome-website-api
On your terminal paste the following one after each other.

Now, that the code for the lambda has been published it's time to provision the lambda and the API gateway necessary to letting web requests hit it.

Okay 2/4 of the way there.

Take a coffee break or try tweaking your static website in its current form. There's no rush. It's not costing you more the 1 USD at this point likely never will with this set up.

Part III. Provision Infrastructure

We'll be using Terraform to provision our infrastructure. Terraform has popularized the idea of infrastructure as code and is currently one of the top choices. In otherwords, it's great for your resume. Here's the official installation link if you have not installed it yet.

Now, create the repository that is going to store the Terraform code to provision your infrastructure.

> mkdir your-awesome-website-tf
> cd your-awesome-website-tf
Enter fullscreen mode Exit fullscreen mode

That was easy. Time to add the files.

III.a main.tf

provider "aws" {
  region = "${var.region}"
}

############################################
# IAM - Role & Permissions for our lambda
############################################

resource "aws_iam_role" "lambda_role" {
  name = "serverless_website_lambda"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
}

resource "aws_iam_role_policy" "lambda_policy" {
  name = "serverless_lambda_policy"
  role = "${aws_iam_role.lambda_role.id}"

  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
          "Effect": "Allow",
          "Action": "s3:*",
          "Resource": "*"
        }
    ]
}
EOF
}

############################################
# LAMBDA - Create the lambda function
############################################

resource "aws_lambda_function" "frs_api" {
  function_name = "frs-api"

  # fetch the artifact from bucket created earlier
  s3_bucket = "${var.artifact_bucket}"
  s3_key    = "${var.artifact_zip_name}"

  handler = "${var.faas_name}"
  runtime = "go1.x"

  role = "${aws_iam_role.lambda_role.arn}"
}

resource "aws_lambda_permission" "apigw" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = "${aws_lambda_function.frs_api.arn}"
  principal     = "apigateway.amazonaws.com"

  source_arn = "${aws_api_gateway_rest_api.frs_gw.execution_arn}/*/*"
}

############################################
# API GATEWAY - Sets up & configure api gw
############################################

resource "aws_api_gateway_rest_api" "frs_gw" {
  name        = "free-resume-scanner-api"
  description = "created by terraform"
}

resource "aws_api_gateway_resource" "proxy" {
  rest_api_id = "${aws_api_gateway_rest_api.frs_gw.id}"
  parent_id   = "${aws_api_gateway_rest_api.frs_gw.root_resource_id}"
  path_part   = "review"
}

resource "aws_api_gateway_method" "options_method" {
  rest_api_id   = "${aws_api_gateway_rest_api.frs_gw.id}"
  resource_id   = "${aws_api_gateway_resource.proxy.id}"
  http_method   = "OPTIONS"
  authorization = "NONE"
}

resource "aws_api_gateway_method_response" "options_200" {
  rest_api_id = "${aws_api_gateway_rest_api.frs_gw.id}"
  resource_id = "${aws_api_gateway_resource.proxy.id}"
  http_method = "${aws_api_gateway_method.options_method.http_method}"
  status_code = "200"

  response_models = {
    "application/json" = "Empty"
  }

  response_parameters = {
    "method.response.header.Access-Control-Allow-Headers" = true
    "method.response.header.Access-Control-Allow-Methods" = true
    "method.response.header.Access-Control-Allow-Origin"  = true
  }

  depends_on = [aws_api_gateway_method.options_method]
}

resource "aws_api_gateway_integration" "options_integration" {
  rest_api_id = "${aws_api_gateway_rest_api.frs_gw.id}"
  resource_id = "${aws_api_gateway_resource.proxy.id}"
  http_method = "${aws_api_gateway_method.options_method.http_method}"
  type        = "MOCK"

  request_templates = {
    "application/json" = "{ \"statusCode\": 200 }"
  }

  depends_on = [aws_api_gateway_method.options_method]
}

resource "aws_api_gateway_integration_response" "options_integration_response" {
  rest_api_id = "${aws_api_gateway_rest_api.frs_gw.id}"
  resource_id = "${aws_api_gateway_resource.proxy.id}"
  http_method = "${aws_api_gateway_method.options_method.http_method}"
  status_code = "${aws_api_gateway_method_response.options_200.status_code}"

  response_parameters = {
    "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" = "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'"
    "method.response.header.Access-Control-Allow-Origin"  = "'*'"
  }

  depends_on = [aws_api_gateway_method_response.options_200]
}

resource "aws_api_gateway_method" "proxy" {
  rest_api_id   = "${aws_api_gateway_rest_api.frs_gw.id}"
  resource_id   = "${aws_api_gateway_resource.proxy.id}"
  http_method   = "ANY"
  authorization = "NONE"
}

resource "aws_api_gateway_method_response" "response_200" {
  rest_api_id = "${aws_api_gateway_rest_api.frs_gw.id}"
  resource_id = "${aws_api_gateway_resource.proxy.id}"
  http_method = "${aws_api_gateway_method.proxy.http_method}"
  status_code = "200"

  response_models = {
    "application/json" = "Empty"
  }

  response_parameters = {
    "method.response.header.Access-Control-Allow-Origin" = true
  }

  depends_on = [aws_api_gateway_method.proxy]
}

resource "aws_api_gateway_integration" "lambda" {
  rest_api_id = "${aws_api_gateway_rest_api.frs_gw.id}"
  resource_id = "${aws_api_gateway_method.proxy.resource_id}"
  http_method = "${aws_api_gateway_method.proxy.http_method}"

  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = "${aws_lambda_function.frs_api.invoke_arn}"
  depends_on              = [aws_api_gateway_method.proxy, aws_lambda_function.frs_api]
}

resource "aws_api_gateway_deployment" "gw_deploy" {
  depends_on = [aws_api_gateway_integration.lambda]
  rest_api_id = "${aws_api_gateway_rest_api.frs_gw.id}"
  stage_name  = "prod"
}
Enter fullscreen mode Exit fullscreen mode

The main.tf is the file that pulls in the data from the other files that we are about to create. You'll notice that main is used a lot in programming things to signal that "hey this is where you should put the stuff that does the things".

III.b outputs.tf

output "base_url" {
  value = "${aws_api_gateway_deployment.gw_deploy.invoke_url}"
}
Enter fullscreen mode Exit fullscreen mode

An output is a piece of data that can be fed into other Terraform code. For our usecase it is going to be printed on the terminal so that we can have it for reference for the last stage.

III.c vars.tf

variable "region" {
  description = "specifies aws region"
  default     = "us-west-2"
}

variable "artifact_bucket" {
  description = "the bucket for fetching the artifact"
  default     = "your-awesome-website-bucket-api"
}

variable "artifact_zip_name" {
  description = "name of the zip file"
  default     = "your-awesome-website.zip"
}

variable "faas_name" {
  description = "name of the binary"
  default     = "your-awesome-website"
}
Enter fullscreen mode Exit fullscreen mode

The vars.tf is the variables pulled into the main.tf file so make sure the default values align with whatever you've chosen as names.

With those files created use terraform to hit the AWS API's and have our infrastructure up and running within hopefully two steps.
First we

> terraform plan

to verify everything is going to be provisioned without failure.

Then we run

> terraform apply

to provision the lambda resource.

Awesome, if you've made it this far, then take a much deserved break. Chances are you had to do a little bit of learning about Terraform, Lambda, Hugo, and Amplify to make it through this section. Have a coffee eat a sweet, and get charged up for the last part.

IV. It's ALIVE!

Now, that the lambda has been deployed with an API Gateway (outputted on your terminal) it is time to revisit the front end to update the index.html file to hit the endpoint and process the result. Go to your frontend repository and add the following to themes/your-awesome-website-theme/layouts/index.html

{{ define "main" }}
<html>
<head>
  <meta charset="UTF-8">
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
  <script type="text/javascript">
    function submitToAPI(e) {
      e.preventDefault();
      var name = $("#apa").val();
      var r = $("#free-resume-scanner-resume-text-area").val();
      var jd = $("#free-resume-scanner-job-description-text-area").val();
      var data = {
        resume: r,
        job_description: jd
      };

      console.log("datas", data.resume , data.job_description)
      $.ajax({
        type: "POST",
        url: "your-terraform-outputted-apigateway-url",
        crossDomain: "true",
        dataType: "json",
        contentType: "application/json",
        data: JSON.stringify(data),
        success: function(response) {
          $("#resume-has-linkedin-result").html(response.linked_in);
          $("#resume-length-result").html(response.resume_length);
          $("#resume-measurable").html(response.measurable);
          $("#job-description-skills").html("" + response.soft_skills + "," + response.hard_skills);
        }
      });
    }
  </script>
</head>
<body>

<form id="free-resume-scanner-form" method="post">
  <div class="form-group">
    <label for="free-resume-scanner-resume-text-area"><h2>Resume</h2></label>
    <textarea id="free-resume-scanner-resume-text-area" name="resume" cols="40" rows="15" aria-describedby="textareaHelpBlock" class="form-control" required="required"></textarea>
    <span id="textareaHelpBlock" class="form-text text-muted">Open your resume document, select all, copy, and then paste it here</span>
  </div>
  <div class="form-group">
    <label for="free-resume-scanner-job-description-text-area"><h2>Job Description</h2></label>
    <textarea id="free-resume-scanner-job-description-text-area" name="textarea1" cols="40" rows="15" class="form-control" aria-describedby="textarea1HelpBlock" required="required"></textarea>
    <span id="textarea1HelpBlock" class="form-text text-muted">Go to the Job Description, select all of it, copy, and paste it here</span>
  </div>
  <div class="form-group">
    <button type="button" class="btn btn-warning btn-lg col" onClick="submitToAPI(event)" style="margin-top:20px;">Submit</button>
  </div>
</form>

<h2>Results</h2>
<div class="table-responsive">
<table class="table">
  <thead class="table-dark">
  <tr>
    <th scope="col">Metric</th>
    <th scope="col">Value</th>
    <th scope="col">Suggestion</th>
  </tr>
  </thead>
  <tbody>
  <tr>
    <td>Measurable</td>
    <td id="resume-measurable"></td>
    <td><a href="https://freeresumescanner.com/blog/2022/make-your-resume-pop-with-measurable-bullet-points">Make your Resume Pop with Measurable Bullet Points </a></td>
  </tr>
  <tr>
    <td>Missing Keywords</td>
    <td id="job-description-skills"></td>
    <td><a href="https://freeresumescanner.com/blog/2022/get-into-a-recruiters-inbox-with-keywords">Get into a Recruiters Inbox with Keywords</a></td>
  </tr>
  <tr>
    <td>Resume Length</td>
    <td id="resume-length-result"></td>
    <td><a href="https://freeresumescanner.com/blog/2022/dont-get-auto-rejected-keep-your-resume-between-500-1000-words">Don't get auto-rejected keep your Resume between 500-1000 words </a></td>
  </tr>
  <tr>
    <td>Has LinkedIn</td>
    <td id="resume-has-linkedin-result"></td>
    <td><a href="https://freeresumescanner.com/blog/2022/use-linkedin-so-that-recruiters-can-dm-you">Use LinkedIn so that Recruiters can DM you</a></td>
  </tr>
  </tbody>
</table>
</div>
<h2>Comments</h2>
<p>Thank you for using Free Resume Scanner. Let us know if there's anything else we can do to help with a comment below.</p>
<script defer src="https://cdn.commento.io/js/commento.js"></script>
<div id="commento"></div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Now, push the file to your production branch and visit your domain. Once, you provide input to the text areas and press submit AJAX will make an HTTP POST Request to your API gateway, and if all goes well receive a Response which will then be populated in the results section.

Yes, this is using JQUERY and yes it is an older technology. However, it didn't make sense to me to include a cognitively taxing framework like React.js, Next.js, Vue.js, etc. when the objective could be accomplished with a simple import. And to me a little import is much less to cognitively deal with than learning the quirks of the hottest new Javascript framework(s) of the day.

Conclusion

I hope you've enjoyed this article and have some ideas of your own to implement from making it this far. Feel free to make Pull Requests and to reach out to me if you get stuck.

Top comments (2)

Collapse
 
naucode profile image
Al - Naucode

That was a nice read! Liked, bookmarked and followed, keep the good work!

Collapse
 
terraier profile image
Toul

Thank you! Thinking of turning it into a small e book to go have more details for beginners