Part 3: Templating HTML with Python, Jinja2 and serverless WebAssembly

technosophos

Matt Butcher

Posted on January 4, 2024

Part 3: Templating HTML with Python, Jinja2 and serverless WebAssembly

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>
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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"),
    )
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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:

Initial view of bookmarker app

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"
Enter fullscreen mode Exit fullscreen mode

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 -->
Enter fullscreen mode Exit fullscreen mode

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"),
    )
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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:

Bookmarker screenshot with rendered templates

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"),
    )
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"
    }
]
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Screenshot with one bookmark

And if we add another bookmark, we’ll see our new entry.

Screenshot with two bookmarks

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.

💖 💪 🙅 🚩
technosophos
Matt Butcher

Posted on January 4, 2024

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

Sign up to receive the latest update from our blog.

Related