HTML forms
Before implementing complete REST and GraphQL backend APIs, we can prove things out using HTML forms. Often they suffice, and in many cases, a basic HTML form is the best option for writing data in a web application. Our example follows a similar Create, Read, Update, and Delete (CRUD) pattern that you may see in Rails, Sinatra, Django, or Flask, except now the operations will be backed by AWS Lambda functions.
For more on HTML Forms, check out this article.
This guide will walk through a prototype CRUD app that combines concepts from the previous lessons. We will use Eleventy, with an authenticated /admin
route using GitHub OAuth. Users will be able to login with their GitHub account to create and save drafts of markdown files. When the user is ready to publish the file, the application will directly publish the markdown file to GitHub.
Set up
- Start with deploying the example app on Begin and set up local development.
# Clone your app repo locally
git clone https://github.com/username/begin-app-project-name.git
# cd into your Begin project dir
cd begin-app-project-name
# Install NPM packages
npm install
# Build the code
npm run build
# Start Sandbox
npm start
- Review the app structure
Let's take a look at how we can implement a common design pattern called Model View Controller (MVC) with Architect and AWS Lambda functions.
-
Models or the data access layer go into
src/shared
and can be accessed by all runtime Lambda functions. -
View logic is added to
src/views
and will be copied into runtime Lambdas that handle HTTPGET
requests. -
Lambda functions are Controller logic which will route user actions between models and views.
Writing your applications this way will give you and your collaborators a consistent process for maintaining the code base.
Learn more about project structures in the Begin Docs
Data access layer
The data access layer is a fancy way to describe logic to interact with your backend database.
The CRUD logic is contained in src/shared/drafts.js
and we utilize the @begin/data
client library for DynamoDB.
Everything in src/shared
gets copied into all Lambdas node_modules/@architect/shared
at runtime so any Lambda function can use require('@architect/shared/drafts')
to access these methods.
// src/shared/drafts.js
let data = require('@begin/data')
let xss = require('xss')
module.exports = {
save,
read,
destroy
}
/**
* save draft
*
* @param {object} draft
* @param {string} draft.key
* @param {string} draft.title
* @param {string} draft.body
* @param {string} draft.author
* @param {string} draft.avatar
*/
async function save(draft) {
let required = ['title', 'body', 'author', 'avatar']
for (let param of required) {
if (!draft[param])
throw ReferenceError(`missing param ${param}`)
if (draft[param] && draft[param].length < 4)
throw RangeError(`${param} must be four or more characters`)
}
draft.title = xss(draft.title)
draft.body = xss(draft.body)
return data.set({
table: 'drafts',
...draft
})
}
/**
* read draft(s)
*
* @param {object} params
* @param {string} params.cursor
* @param {string} params.key
*/
async function read(params={}) {
return data.get({
table: 'drafts',
...params
})
}
/**
* destroy draft
*
* @param {object} params
* @param {string} params.key
*/
async function destroy(draft) {
return data.destroy({
table: 'drafts',
...draft
})
}
Notice that
src/shared
can also have its ownpackage.json
for dependencies. When@architect/sandbox
is started, it will rehydrate these dependencies if necessary
View layer
View layers are for templating logic. Everything in src/views
gets copied into the node_modules
folder of /http/get-*
Lambda functions.
Learn more about how Architect uses shared view login in Lambdas
// src/views/admin.js
let layout = require('./layout')
let form = require('./form')
module.exports = function admin(drafts) {
let html = form()
for (let i of drafts) {
html += `
<li>
<a href=/drafts/${i.key}>${i.title}</a>
<form method=post action=/drafts/${i.key}/destroy>
<button>X</button>
</form>
</li>
`
}
return layout(`<ul>${html}</ul>`)
}
It is perfectly ok to render HTML strings from a template function. It is the fastest SSR technique.
// src/views/layout.js
module.exports = function layout(body) {
return `
<!doctype html>
<html>
<body>
<form method=post action=/logout>
<button>Logout</button>
</form>
${body}
</body>
</html>`
}
Plain HTML forms create easy to follow logic when you know every route is a Lambda function.
// src/views/form.js
module.exports = function form(draft) {
if (!draft) {
return `
<form method=post action=/drafts>
<input type=text name=title>
<textarea name=body></textarea>
<button>Save new draft</button>
</form>
`
}
return `
<form method=post action=/drafts/${draft.key}>
<input type=hidden name=key value=${draft.key}>
<input type=hidden name=author value=${draft.author}>
<input type=hidden name=avatar value=${draft.avatar}>
<input type=text name=title value="${draft.title}">
<textarea name=body>${draft.body}</textarea>
<button>Save draft</button>
</form>
<form method=post action=/drafts/${draft.key}/publish>
<button>Publish draft</button>
</form>
`
}
// src/views/signin.js
module.exports = function signin() {
let client_id = process.env.GITHUB_CLIENT_ID
let redirect_uri = process.env.GITHUB_REDIRECT
let scope = 'user,repo'
let base = 'https://github.com/login/oauth/authorize'
let href = `${base}?client_id=${client_id}&redirect_uri=${redirect_uri}&scope=${scope}`
return `
<!doctype html>
<html>
<body>
<a href=${href}>Sign in with GitHub</a>
</body>
</html>`
}
You can add full GitHub OAuth functionality by following the method in the previous sections on Environment Variables and Authentication. You will need to create environment variables for
GITHUB_CLIENT_ID
,GITHUB_CLIENT_SECRET
,GITHUB_REDIRECT
, andGITHUB_REPO
# .arc-env
@testing
GITHUB_CLIENT_ID xxx
GITHUB_CLIENT_SECRET xxx
GITHUB_REDIRECT http://localhost:3333/login
GITHUB_REPO github-user/project-repo
Controller layer
You can consider Lambda functions as controllers. HTTP functions marshal user input, talk to the database and either render a web page or redirect the user elsewhere. Most of this apps frontend is static except the admin page. This Lambda function will check if there is an active session, read drafts from DynamoDB or instruct the user to sign in.
// src/http/get-admin/index.js
let arc = require('@architect/functions')
let drafts = require('@architect/shared/drafts')
let signin = require('@architect/views/signin')
let admin = require('@architect/views/admin')
async function http(req) {
if (req.session.account) {
let results = await drafts.read()
return {
html: admin(results)
}
}
return {
html: signin()
}
}
exports.handler = arc.http.async(http)
When the /admin
route receives a GET
request, it invokes the Lambda code at src/http/get-admin/index.js
. This function checks for an account session and renders the admin.js
view (or prompts sign in if the user has not authenticated). All other controller logic in this app are HTML form POST
operations. This is great because HTML form POST
s always redirect back to a view. (So they have no view logic.) It is possible to render HTML from a form post, but you never want that behavior because it often results in duplicate form submissions.
To 'create' drafts, take a look at the Lambda function in src/http/post-drafts/index.js
. This function checks for an authenticated account and redirects if it is not available.
// src/http/post-drafts/index.js
let arc = require('@architect/functions')
let drafts = require('@architect/shared/drafts')
async function http(req) {
if (!req.session.account) {
return {
location: '/?authorized=false'
}
}
try {
let draft = req.body
draft.author = req.session.account.name
draft.avatar = req.session.account.avatar
await drafts.save(draft)
return {
location: '/admin'
}
}
catch(e) {
return {
html: `${e.message} <pre>${e.stack}`
}
}
}
exports.handler = arc.http.async(http)
This method checks for an authenticated account and redirects if it is not available. If everything checks out we use @begin/data
to save a draft to DynamoDB.
To destroy a record we have post /drafts/:key/destroy
code:
// src/http/post-drafts-000key-destroy/index.js
let arc = require('@architect/functions')
let drafts = require('@architect/shared/drafts')
async function destroy(req) {
if (!req.session.account) {
return {
location: '/admin'
}
}
await drafts.destroy(req.params)
return {
location: '/admin'
}
}
exports.handler = arc.http.async(destroy)
Again we check for a legit session and if it exists we destroy the record and redirect back to /admin
.
The final method worth noticing is post /drafts/:key/publish
:
// src/http/post-drafts-000key-publish/index.js
let arc = require('@architect/functions')
let drafts = require('@architect/shared/drafts')
let github = require('./github')
async function publish(req) {
if (!req.session.account || !req.params.key) {
return {
location: '/'
}
}
try {
let token = req.session.account.token
let draft = await drafts.read(req.params)
// publish to github
await github({token, draft})
// delete the draft
await drafts.destroy(draft)
// go back home
return {
location: '/admin'
}
}
catch(e) {
return {
html: `
<h3>${e.message}</h3>
<pre>${e.stack}</pre>
`
}
}
}
exports.handler = arc.http.async(publish)
Similar to previous controller logic we check the session. If legit, we grab a copy of the draft and attempt to write it to GitHub. If that all works out we'll destroy the record and redirect back to the admin view. Let's quickly check out the GitHub publish portion of this exercise:
// src/http/post-drafts-000key-publish/github.js
let { put } = require('tiny-json-http')
module.exports = async function publish({token, draft}) {
let path = `${draft.title.toLowerCase().replace(/ /g, '-')}.md`
let message = `feat: adds ${path}`
let content = Buffer.from(draft.body).toString('base64')
// https://developer.github.com/v3/repos/contents/#create-or-update-a-file
await put({
url: `https://api.github.com/repos/${process.env.GITHUB_REPO}/contents/src/md/${path}`,
headers: {
Accept: 'application/json',
Authorization: `token ${token}`
},
data: {
message,
content,
}
})
}
GitHub has an amazing API. This method will write a new markdown document into our src/md
folder on GitHub.
REST is often likened to CRUD for HTTP. The verbs map well and it provides a framework for thinking about how to structure our apps.
CRUD | HTML forms | REST |
---|---|---|
Create | POST /drafts | POST /drafts |
Read | GET /drafts/:key | GET /drafts/:key |
Update | POST /drafts/:key | PATCH /drafts/:key |
Delete | POST /drafts/:key/destroy | DELETE /drafts/:key |
The problem is HTTP verbs
PATCH
,DELETE
andPUT
are not supported by HTML forms in web browsers.XMLHTTPRequest
andfetch
support all verbs but this makes JavaScript a hard requirement for your application to function in addition to less favorable accessibility behavior. The workaround is to add some extra state denoting deletion (which we do in the highlighted cell above by appendingdestroy
to the resource URL.
So go ahead and give it a try, see if you can complete it working locally and ship it to production on Begin.