How to build a full-stack serverless movie tracker with OpenJS Architect - Part 1

pchinjr

Paul Chin Jr.

Posted on May 6, 2021

How to build a full-stack serverless movie tracker with OpenJS Architect - Part 1

What we're building

In this post, I'll cover the beginnings of a full app that uses AWS serverless services that will have a full database, session support, GitHub OAuth login, and is developed with progressive enhancement in mind. The app will provide a logged-in user with a list of movies. Each movie can be checked to indicate if it has been watched or not. So you can keep track of all the Nic Cage movies that you've seen.

The tools we will need

This app will require you to have Node.js, OpenJS Architect, your favorite text editor, some basic JavaScript knowledge, and how to use Git. Oh, and a GitHub account.

Architect is an open-sourced deployment framework for serverless web apps on AWS. It will hide a lot of complexity in setting up Lambda functions, API Gateway, and DynamoDB.

Create a new Architect project and set up CI/CD on Begin

To get started click the button below to deploy it in 15 seconds:

Deploy to Begin

Once you login to Begin with your GitHub credentials it will create a new repo on your account. Your CI/CD is now done! Each commit to your default branch will cause a new build to a staging environment.

Clone your repo and run npm i to get started locally.

Infrastrucure as Code, app.arc file

Let's start by looking at our app.arc file and modify it to the following:

@app
example-movie-tracker

@static

@http
get /
get /login
post /logout
post /watched

@tables
data
  scopeID *String
  dataID **String
  ttl TTL
Enter fullscreen mode Exit fullscreen mode

After you've finished modifying the app.arc file, run arc init from the command line to scaffold out the functions.

GitHub OAuth and environment variables

Now let's implement GitHub OAuth. We're going to need to set up environment variables and modify a few files.

Environment variables allow us to parameterize sensitive state, like third-party API keys and URLs, without hard coding them into our application code.

First, follow these instructions and register a new OAuth app. You should see the following:

Alt Text

You will need to save GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_REDIRECT to a prefs.arc file in the root of your project. Architect reads this file and loads the environment variables into the Lambda at runtime. The file should look like this:

#prefs.arc

@env
testing
  GITHUB_CLIENT_ID <your-client-id>
  GITHUB_CLIENT_SECRET <your-client-secret>
  GITHUB_REDIRECT http://localhost:3333/login
Enter fullscreen mode Exit fullscreen mode

This allows us to work locally by running npm start. In order to get this working on our deployed app, you will have to create a new OAuth application for staging and production and updating a new set of environment variables in the Begin console.

Alt Text

Implementing OAuth login flow

Now we can write some code to handle the OAuth login flow from GitHub.

First, we'll write the get-login function.

// src/http/get-login/index.js

const arc = require('@architect/functions')
const github = require('./github')

exports.handler = arc.http.async(login)

async function login(req) {
  let account
  if (req.query.code) {
    try {
      account = await github(req)
    } catch (err) {
      return {
        statusCode: err.code,
        body: err.message
      }
    }
    return {
      session: { account },
      location: '/'
    }
  } else {
    return {
      location: '/'
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This get-login function is where our GitHub app redirects to after successfully authenticating.

If we have successfully authenticated we can then use the returned code to retrieve the account data from GitHub's API.

  • We check for req.query.code

  • Then use the code to retrieve the user account from the GitHub API

  • Finally, we return the account if present.

We also will create a github.js module to include the GitHub specific logic. This github.js is used to retrieve the account data from GitHub.

  • First, we POST to the GitHub OAuth service with the authentication code to retrieve an access token

  • Then we retrieve the account data with the access token set as an Authorization Header.

  • Finally, we return the account data or any error we receive.

// src/http/get-login/github.js

const tiny = require('tiny-json-http')

module.exports = async function github(req) {
  try {
    let result = await tiny.post({
      url: 'https://github.com/login/oauth/access_token',
      headers: { Accept: 'application/json' },
      data: {
        code: req.query.code,
        client_id: process.env.GITHUB_CLIENT_ID,
        client_secret: process.env.GITHUB_CLIENT_SECRET,
        redirect_url: process.env.GITHUB_REDIRECT
      }
    })
    let token = result.body.access_token
    let user = await tiny.get({
      url: `https://api.github.com/user`,
      headers: {
        Authorization: `token ${token}`,
        Accept: 'application/json'
      }
    })
    return {
      token,
      name: user.body.name,
      login: user.body.login,
      id: user.body.id,
      url: user.body.url,
      avatar: user.body.avatar_url
    }
  } catch (err) {
    return {
      error: err.message
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll create an auth component in our get-index function

// src/http/get-index/index.js

const arc = require('@architect/functions')

exports.handler = arc.http.async(http)

function authControl(account) {
  if (account && account.name) {
    return `
    Welcome back ${account.name}
    <form action=/logout method="post">
    <button>Logout</button>
    </form>`
  } else {
    let clientID = process.env.GITHUB_CLIENT_ID
    let redirectURL = process.env.GITHUB_REDIRECT
    let href = `https://github.com/login/oauth/authorize?client_id=${clientID}&redirect_url=${redirectURL}`
    return `
    <a href='${href}'>Login with GitHub</a>
    `
  }
}

async function http (req) {

  return {
    html: `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Praise Cage</title>
</head>
<body>
<h1>Praise Cage</h1>

${ authControl(req.session.account) }

</body>
</html>
`
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we can create the post-logout handler to clear the session object and log the user out.

// src/http/post-logout/index.js

let arc = require('@architect/functions')

async function logout(req) {
  return {
    session: {},
    location: '/'
  }
}

exports.handler = arc.http.async(logout)
Enter fullscreen mode Exit fullscreen mode

Testing Auth

Now we can test our app locally by running npm start. You should see a link to log in with your GitHub credentials.

Alt Text

Building our get-index route

Now it's time to continue to build out our UI. We're using a dynamic endpoint for our get-index route which will fetch any data from the database on page load.

// src/http/get-index/index.js

let arc = require('@architect/functions')
let data = require('@begin/data')

exports.handler = arc.http.async(http)

function authControl(account) {
  if (account && account.name) {
    return `
    Welcome back ${account.name}
    <form action=/logout method="post">
    <button>Logout</button>
    </form>`
  } else {
    let clientID = process.env.GITHUB_CLIENT_ID
    let redirectURL = process.env.GITHUB_REDIRECT
    let href = `https://github.com/login/oauth/authorize?client_id=${clientID}&redirect_url=${redirectURL}`
    return `
    <a href='${href}'>Login with GitHub to see a list of movies</a>
    `
  }
}

function movie({ key, watched, title }) {
  return `<form action="/watched" method="post">
    <input type="hidden" name="movieId" value="${key}">
    <input type="checkbox" name=watched ${ watched? 'checked' : ''}>
    ${title}
    <button>Save</button>
  </form>`
}

async function getMovies(account) {
  let movies = [
    {key: '001', title: 'Raising Arizona'},
    {key: '002', title: 'Con Air'},
    {key: '003', title: 'National Treasure'},
  ]
  if (account) {
    let accountMovies = await data.get({
      table: `${account.id}-movies`
    })
    console.log('found account movies', accountMovies)
    let result = ''
    for (let mov of movies) {
      let found = !!(accountMovies.find(m=> m.key === mov.key))
      result += movie({key: mov.key, title: mov.title, watched: found })
    }
    return result
  }
  return ''
}

async function http (req) {

  return {
    html: `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Praise Cage</title>
</head>
<body>
<h1>Praise Cage</h1>

${ authControl(req.session.account) }
${ await getMovies(req.session.account) }

<script src=/_static/index.js type=module></script>
</body>
</html>
`
  }
}
Enter fullscreen mode Exit fullscreen mode

There are two functions we're adding, getMovies and movies. These functions accept some parameters and return an HTML string. We're able to build out our UI using these functional components without the need for a larger framework.

Saving the 'watched' movies to DynamoDB with @begin/data

We're using @begin/data as a DynamoDB client to save watched movies to each user's account.

// src/http/post-watched/index.js

const arc = require('@architect/functions')
const data = require('@begin/data')

exports.handler = arc.http.async(route)

async function route(req) {
  console.log('post-watched:', req.body)
  let account = req.session.account.id

  if (req.body.watched) {
    await data.set({
      table: `${account}-movies`,
      key: req.body.movieId
    })
  } else {
    await data.destroy({
      table: `${account}-movies`,
      key: req.body.movieId
    })
  }

  return {
    location: '/'
  }
}
Enter fullscreen mode Exit fullscreen mode

Stay tuned for the next part where we will go over how to make this site work with client-side JavaScript. We will progressively enhance this site so that it will work even with JavaScript disabled.

💖 💪 🙅 🚩
pchinjr
Paul Chin Jr.

Posted on May 6, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related