Part 3: Templating HTML with Python, Jinja2 and serverless WebAssembly
Matt Butcher
Posted on January 4, 2024
We’re up to part three of a series on building a bookmarker app with serverless Python. In the first part we covered the basics of using Spin to create a WebAssembly app out of Python code. Part 2 was where we scaffolded out a lot of the logic for saving and retrieving bookmarks.
In Part 3, we’ll turn our attention to the front end. We’ll start with some plain old HTML. Then we’ll mix in the famous Jinja2 template library (used in Django and other projects). Jinja2 is very easy to use on its own. By the end of this part we will have a working bookmarker app. Then we’ll kick it to the next level in Part 4 and Part 5, where we sprinkle some AI on it to automatically generate summaries of the pages we bookmark.
It’s time to turn our attention back to the index()
function. This time, we’re going to refactor it so we can serve out some actual HTML. And before we even get going on our Python code, let’s create an actual index.html
file with a form for submitting new URLs.
Next to app.py
, we’ll create a file named index.html
. Here’s some basic HTML content that creates two text fields and a submit button.
<!DOCTYPE html>
<html>
<head>
<title>Bookmarker: A Spin-powered bookmarking tool</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
</head>
<body>
<div class="container">
<h1 class="title is-1">Bookmarker</h1>
<h2 class="subtitle">Enter a new bookmark</h2>
<form action="/add" method="post">
<div class="field">
<label class="label">Title</label>
<div class="control">
<input name="title" class="input" type="text" placeholder="Page title">
</div>
</div>
<div class="field">
<label class="label">URL</label>
<div class="control">
<input name="url" class="input" type="text" placeholder="http://">
</div>
</div>
<div class="field is-grouped">
<div class="control">
<input type="submit" class="button is-link">
</div>
</div>
</form>
</div>
</body>
</html>
This is a pretty basic HTML that contains a form with a couple of form fields and a submit button. To make styling a little easier, I opted to use Bulma. But that’s entirely optional, and does nothing to impact our app.
There is one thing to pay close attention to, though. The most important line in this file is this one, which tells the form where to submit form data:
<form action="/add" method="post">
When the Submit
button is pressed, this form control will send a POST
request to /add
with all of the form data. And this is exactly what we expected when we wrote the add_url()
function above.
Now we have a static HTML file. But we have no way of delivering it to the client. One way would be to add the Spin static fileserver (spin add static-fileserver
). Another way would be load and serve the file from within our Python app, and that’s what we’re going to do.
💡 We are opting for this route because in a bit we are going to wire up Jinja templates, and that will require having access to the file in code.
We’ll make some small modifications to our index()
function, having it read the static index.html
file from disk and then serve it out to the browser.
# This is our index handler
@router.route("/", "/index.html")
def index(request):
# Buffer the file into one string.
buf = ""
with open("index.html", "r") as f:
buf += f.read()
return Response(
200,
{"content-type": "text/html"},
bytes(buf, "utf-8"),
)
All we’re doing above is reading the contents of index.html
into a buffer (buf
) and then sending that back. Note also that we changed the content-type
from text/plain
to text/html
.
By default, Spin does not allow an app to access the filesystem. This is part of Spin’s security profile. We need to make one quick change in our spin.toml
to allow our running app to load the index.html
file. Here’s just the relevant section from spin.toml
:
[component.bookmarker]
source = "app.wasm"
key_value_stores = ["default"]
files = ["index.html"] # <--- This is what we added
[component.bookmarker.build]
command = "spin py2wasm app -o app.wasm"
watch = ["app.py", "Pipfile"]
Details on the files
directive can be found in Spin’s configuration documentation. It’s best to rerun spin build --up
or spin watch
any time you make a change to spin.toml
Now if we point our browser to http://localhost:3000, we’ll see this:
At this point, you should be able to enter a title and URL into the form and add a new bookmark to your app. Next, let’s modify the HTML to display our bookmarks.
Adding Templating to Display Links
So far, we’ve worked with a single static HTML page. But we want to dynamically list all of the bookmarks we’ve collected. Python has an excellent template library called Jinja2 that makes it really easy to dynamically render HTML. So we’ll use that.
To install Jinja2, we need to add a new line to our Pipfile
:
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
http-router = "*"
Jinja2 = "*" # ADDED THIS
[dev-packages]
[requires]
python_version = "3.10"
And once we’ve added the Jinja2
package here, we need to re-run pipenv update
to install the package. With that, we can start coding.
A minor change to the HTML to make it a template
The first thing we’ll do is make a tiny change to our index.html
file to make it a template. We’ll substitute the hard-coded page heading with a template variable:
<!DOCTYPE html>
<html>
<head>
<title>Bookmarker: A Spin-powered bookmarking tool</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
</head>
<body>
<div class="container">
<h1 class="title is-1">{{title}}</h1>
<h2 class="subtitle">Enter a new bookmark</h2>
<form action="/add" method="post">
<!-- the rest is unchanged -->
All we did above is change our old h1
content to {{title}}
, which is a template substitution. Now, if we were to run our app as it is right now, we’d see the title set to {{title}}
because all we are currently doing is sending index.html
straight to the client. So we need to modify our index()
function to use Jinja2.
Setting up the template engine
To use Jinja2, we need to import a few libraries, set up a template rendering environment, and then send index.html
through the template renderer. Here’s what that looks like:
import json
from http_router import Router
from jinja2 import Environment, FileSystemLoader, select_autoescape # ADDED THIS
from spin_http import Response
from spin_key_value import kv_open_default
from urllib.parse import urlparse, parse_qs
# Create the new router
router = Router(trim_last_slash=True)
# Omitting a bunch of unchanged code here
# This is our index handler
@router.route("/", "/index.html")
def index(request):
# Set up Jinja2 to load anything we put in "/".
# This only has access to the files mapped in spin.toml.
env = Environment(loader=FileSystemLoader("/"), autoescape=select_autoescape())
# Render the template
template = env.get_template("index.html")
buf = template.render(title="Bookmarker")
return Response(
200,
{"content-type": "text/html"},
bytes(buf, "utf-8"),
)
I trimmed down the code above to just show the changes. We import several tools from jinja2
at the top. Then inside of our index()
function, we replaced the file reading code we had before with the new template rendering code.
First, we set up an Environment
, which is Jinja2’s way of describing a collection of templates and the associated configuration. The FileSystemLoader
tells Jinja2 to load templates straight off of the filesystem, looking inside of the directory /
. Understanding this requires understanding a little more about Spin and its security model.
A Spin app doesn’t actually get access to your filesystem. Instead, it gets access to a pseudo-filesystem that Spin sets up for it. And the only files that will show up there are the ones that spin.toml
grants access to.
Previously, we added this line to spin.toml
:
files = ["index.html"]
The syntax of this line is described in the Spin docs. Our specific line above is the most succinct way of telling Spin that the file index.html
should be copied into the root of the pseudo-filesystem. So that means if our app looks in the /
directory, it will see one, and only one, file: index.html
. And this makes Spin apps very secure because they cannot get from the pseudo-filesystem to any of the real files on your real filesystem unless you explicitly grant it permission.
Back to our code, now that we have an Environment
built in env
, we can load and render index.html
as a Jinja2 template:
# Render the template
template = env.get_template("index.html")
buf = template.render(title="Bookmarker")
The template.render()
function takes any number of named variables, which are passed into the template. So title=“Bookmarker”
means that in the template, the statement {{title}}
will get evaluated to Bookmarker
before being sent to the client.
Once the app is rebuilt, we can test this out:
With that done, we can take the next step and print out a list of links. This will require a little more template work. But first, we need to edit index()
to fetch the bookmarks from Key Value Store and pass them into the template engine.
Fetching and displaying bookmarks
Earlier we saw how to save bookmarks. As you may recall, we stored our bookmarks as a single JSON file in Key Value Store. So now all we need to do is fetch those, parse the JSON into a Python list, and then send that list into the template engine.
To do this, we need to do one more pass at our index()
function.
# This is our index handler
@router.route("/", "/index.html")
def index(request):
# Set up Jinja2 to load anything we put in "/".
# This only has access to the files mapped in spin.toml.
env = Environment(loader=FileSystemLoader("/"), autoescape=select_autoescape())
# Get our bookmarks out of KV store and parse the JSON
store = kv_open_default()
bookmarks = json.loads(store.get("bookmarks") or b"[]")
# Render the template
template = env.get_template("index.html")
buf = template.render(title="Bookmarker", bookmarks=bookmarks)
return Response(
200,
{"content-type": "text/html"},
bytes(buf, "utf-8"),
)
We’ve added a section to open the default Key Value Store (just like we did before) and then fetch the record named ”bookmarks”
. We parse the JSON into a Python object with json.loads()
. And with that, we’ve got a record of all the bookmarks we’ve saved so far.
Next, we just need to pass those bookmarks into the template renderer:
buf = template.render(title="Bookmarker", bookmarks=bookmarks)
When the template is rendered, now two different variables will be present: title
and bookmarks
. But while title
is just a string, bookmarks
will be a list of bookmarks that looks something like this:
[
{
"title": "SOME TITLE",
"url": "SOME URL"
},
{
"title": "SOME OTHER TITLE",
"url": "SOME OTHER URL"
}
]
Next up, we can go back to our HTML file and add some template directives to loop through the bookmarks
list and print our bookmarks.
Here’s what that looks like:
<div class="content">
<h2 class="title is-2">Your Bookmarks</h2>
<ul>
{% for bookmark in bookmarks %}
<li><a href="{{bookmark.url}}">{{bookmark.title}}</a></li>
{% endfor %}
</ul>
</div>
In Jinja2, it’s easy to loop over a list using the for… in
syntax. Essentially, that means that it will go through every entry in bookmarks
, assigning that entry to the bookmark
variable each time through the loop. And that means we can access each item’s title
and url
property using {{bookmark.title}}
and {{bookmark.url}}
.
Adding the above HTML at the bottom of the index.html
file (just before the </body>
) we can see the result in a web browser.
And if we add another bookmark, we’ll see our new entry.
And there we have it! We’ve built a new bookmarking tool with Python, Spin, the http_router
, Jinja2, and Key Value Store. Form here, we could deploy it to Fermyon Cloud with `spin deploy` or keep adding new features.
Posted on January 4, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.