How to build a full-stack serverless movie tracker with OpenJS Architect - Part 1
Paul Chin Jr.
Posted on May 6, 2021
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:
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 and register a new OAuth app. You should see the following:
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.
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: '/'
}
}
}
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
}
}
}
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>
`
}
}
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)
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.
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>
`
}
}
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: '/'
}
}
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.
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
November 30, 2024