How to build a full-stack serverless movie tracker with OpenJS Architect - Part 1
Published: 2021-05-06 16:24:44

# 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](https://arc.codes), 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](https://static.begin.com/deploy-to-begin.svg)](https://begin.com/apps/create?template=https://github.com/pchinjr/example-movie-tracker)

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
```
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](https://docs.github.com/en/developers/apps/creating-an-oauth-app) and register a new OAuth app. You should see the following: 

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/504xoh3wmcmhtokfyzt9.png)

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
``` 

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g91tgivmhy8x3rsk39m6.jpeg)

# 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. 

```javascript
// 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: '/'
    }
  }
}
```

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.

```javascript
// 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
    }
  }
}
```
Next, we'll create an auth component in our `get-index` function

```javascript
// 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>
`
  }
}
```

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

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

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

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

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

# 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jrsjjih3rx6dwxjvlhkj.png)

# 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. 

```javascript
// 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>
`
  }
}
```
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.

```javascript
// 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: '/'
  }
}
```

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.