Part 2: Using a Router to Create Two Handlers

technosophos

Matt Butcher

Posted on January 4, 2024

Part 2: Using a Router to Create Two Handlers

We’re on our way to building a bookmarking app in Python. We are using Spin, an open source tool for building serverless apps using WebAssembly. By the end of this series, we’ll be using an LLM to help us power things. But right now we’re in the early stages.

In the first part of this series we wrote a simple Python app that just returns your typical “hello world” text. We’re going to move along from there into creating an app that uses a router to delegate request handling.

There are two ways to route in a Spin app. You can use the spin.toml to route inbound requests to different apps, or you can let the app route on its own. The two can be easily combined. But for this example we are going to handle all the routing in Python.

Rather than write a custom router, we’re going to use the Python http-router project, which provides an elegant and powerful request router. To start with, we’re going to just declare one route, a handler for / and /index.html.

First up, let’s add the http-router to our project by editing Pipfile:

[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
# ADDED THIS: the http-router
http-router = "*"

[dev-packages]

[requires]
python_version = "3.10"
Enter fullscreen mode Exit fullscreen mode

We just added the http-router line. Afterward, we need to run pipenv update to fetch the latest dependencies.

$ pipenv update
Creating a virtualenv for this project...
Pipfile: /Users/technosophos/Code/Python/bookmarker/Pipfile
Using /opt/homebrew/bin/python3.10 (3.10.12) to create virtualenv...
⠹ Creating virtual environment...created virtual environment CPython3.10.12.final.0-64 in 210ms
  creator CPython3macOsBrew(dest=/Users/technosophos/.local/share/virtualenvs/bookmarker-oA563MLe, clear=False, no_vcs_ignore=False, global=False)
  seeder FromAppData(download=False, pip=bundle, setuptools=bundle, wheel=bundle, via=copy, app_data_dir=/Users/technosophos/Library/Application Support/virtualenv)
    added seed packages: pip==23.3.1, setuptools==68.2.2, wheel==0.41.3
  activators BashActivator,CShellActivator,FishActivator,NushellActivator,PowerShellActivator,PythonActivator

✔ Successfully created virtual environment!
Virtualenv location: /Users/technosophos/.local/share/virtualenvs/bookmarker-oA563MLe
Running $ pipenv lock then $ pipenv sync.
Locking [packages] dependencies...
Building requirements...
Resolving dependencies...
✔ Success!
Locking [dev-packages] dependencies...
Updated Pipfile.lock (41a73e4b5103ed04faa88dc0dec56d9326c3600776021154fed4cf155648244d)!
Installing dependencies from Pipfile.lock (48244d)...
To activate this project's virtualenv, run pipenv shell.
Alternatively, run a command inside the virtualenv with pipenv run.
All dependencies are now up-to-date!
Enter fullscreen mode Exit fullscreen mode

Now we’ve got our router. Back to app.py, where we’ll rewrite our code to do a little routing:

from http_router import Router
from spin_http import Response
from urllib.parse import urlparse

# Create the new router
router = Router(trim_last_slash=True)

# This is our index handler
@router.route("/", "/index.html")
def index(request):
    return Response(
        200,
        {"content-type": "text/plain"},
        b"Hello from Bookmarker",
    )

# Main Spin entrypoint
def handle_request(request):
    # Look up the route for the given URI and get a handler.
    uri = urlparse(request.uri)
    handler = router(uri.path, request.method)

    # Now that we have a handler, let's call the function.
    # For `/` and `/index.html`, this is calling index().
    return handler.target(request)
Enter fullscreen mode Exit fullscreen mode

We added two imports. One is the http_router and the other is Python’s built-in URL parser. We need that second one to take the raw data that Spin gives us and parse it out into a well-formed path.

Outside of the main function, we declared a new router. Doing so here allows us to add additional routing functions using the @router.route() decorator.

We added only one route in the example above. And that’s a route to map inbound requests to / and /index.html to the index() function:

# This is our index handler
@router.route("/", "/index.html")
def index(request):
    return Response(
        200,
        {"content-type": "text/plain"},
        b"Hello from Bookmarker",
    )
Enter fullscreen mode Exit fullscreen mode

Finally, inside of the main handle_request() function, we just set up the router and run the appropriate route handler:

# Main Spin entrypoint
def handle_request(request):
    # Look up the route for the given URI and get a handler.
    uri = urlparse(request.uri)
    handler = router(uri.path, request.method)

    # Now that we have a handler, let's call the function.
    # For `/` and `/index.html`, this is calling index().
    return handler.target(request)
Enter fullscreen mode Exit fullscreen mode

That’s the basics of handling routing. Next, let’s add a route where we can bookmark a URL that was submitted.

Passing the request through the handler.target() to the handler function is optional. And in index() , request is unused. However, in the next section we are going to use the request object to get posted form data. And later we’ll use it to access headers.

Adding a New Bookmark with an Upload Function

For our example, we’re going to do an old school form handler that takes a POST request and decodes it into a URL.

When we add a new bookmark, we want to add two things: a title and a URL. So we’ll send a POST that uses the normal encoding used by HTML forms. (We could get fancier and use JSON data, but we’ll stick with a simple one right now.) So we would expect the body of the POST request to look something like this:

title=SOME+TILE&url=SOME+URL
Enter fullscreen mode Exit fullscreen mode

That will need to get decoded into a dictionary with title and url keys. Let’s write up the function and then test it with curl.

For the first pass, let’s just get the form data, parse it, and print it to the console. In app.py, we’ll add a new function called add_url and map it to /add using an HTTP POST.

from http_router import Router
from spin_http import Response
from urllib.parse import urlparse, parse_qs # NEW IMPORT

# Create the new router
router = Router(trim_last_slash=True)

# Add a new bookmark
@router.route("/add", methods=["POST"])
def add_url(request):
    # This gets us the encoded form data
    params = parse_qs(request.body, keep_blank_values=True)
    print(params[b"title"][0])
    print(params[b"url"][0])

    # Direct the client to go back to the index.html
    return Response(303, {"location": "/index.html"})

# The rest of the code is all unchanged from before
Enter fullscreen mode Exit fullscreen mode

Our new /add handler, add_url(), uses parse_qs() to parse the request body.

The parse_qs() is part of the standard urllib.parse library, and it takes form data like title=Fermyon+blog&url=http://fermyon.com/blog/index and returns a dictionary whose key is the name (title, url) and whose value is an array of content:

{
    'title': ['Fermyon blog'],
    'url': ['http://fermyon.com/blog/index']
}
Enter fullscreen mode Exit fullscreen mode

In our basic function above, we print the values out to the console for debugging. Let’s test it out. Re-running spin build --up, we should have a server

Testing it with curl:

Now we can test it with curl:

$ curl -XPOST \
-H "content-type: application/x-www-form-urlencoded" \
-d "title=Fermyon+blog&url=http://fermyon.com/blog/index" \
-L http://localhost:3000/add
Hello from Bookmarker
Enter fullscreen mode Exit fullscreen mode

I broke that out onto multiple lines, but effectively we are: * Setting the method to POST with -XPOST * Setting the content type header with -H "content-type: application/x-www-form-urlencoded" * Setting the POST body with -d "title=Fermyon+blog&url=http://fermyon.com/blog/index" * Telling curl to follow redirects with -L * And then pointing it toward our new endpoint, http://localhost:3000/add

As we expect, we get redirected from /add back to /index.html, and that’s why we see the message Hello from Bookmarker.

And if we look at the output of spin build —-up, we’ll see this:

b'Fermyon blog'
b'http://fermyon.com/blog/index'
Enter fullscreen mode Exit fullscreen mode

Yay! That is all working. But what we really want to do is store the new bookmark. To do that, we’ll need to use the Key Value Store feature of Spin.

Storing a Bookmark

Let’s modify the add_url function to store our bookmarks in Spin’s Key Value Store. We could do a few things here. We could store each bookmark as a separate entry in Key Value Store, or we could just store all of the bookmarks as one JSON object. For simplicity, we’ll do the later and just store all bookmarks in one document.

A bookmark JSON record will look like this:

[
    {
        "title": "SOME TITLE",
        "url": "http://example.com"
    },
    {
        "title": "SOME OTHER TITLE",
        "url": "http://another.example.com"
    }
]
Enter fullscreen mode Exit fullscreen mode

So let’s code that up.

import json # NEW IMPORT
from http_router import Router
from spin_http import Response
from spin_key_value import kv_open_default # NEW IMPORT
from urllib.parse import urlparse, parse_qs

# Create the new router
router = Router(trim_last_slash=True)

# Add a new bookmark
@router.route("/add", methods=["POST"])
def add_url(request):
    # This gets us the encoded form data
    params = parse_qs(request.body, keep_blank_values=True)
    title = params[b"title"][0].decode()
    url = params[b"url"][0].decode()

    # Open key value storage
    store = kv_open_default()

    # Get the existing bookmarks or initialize an empty bookmark list
    bookmark_entry = store.get("bookmarks") or b"[]"
    bookmarks = json.loads(bookmark_entry)

    # Add our new entry.
    bookmarks.append({"title": title, "url": url})

    # Store the modified list in key value store
    new_bookmarks = json.dumps(bookmarks)
    store.set("bookmarks", bytes(new_bookmarks, "utf-8"))

    # Direct the client to go back to the index.html
    return Response(303, {"location": "/index.html"})

# The rest of the code is unchanged
Enter fullscreen mode Exit fullscreen mode

We did import a few new things here: the json library and the kv_open_default() function.

Then we rewrite the add_url() function.

First, for the sake of convenience, we grabbed title and url out of the params data that we received from the client. You’ll notice that throughout this function, we’re doing a lot of conversions between arrays of bytes and UTF-8 strings. For example, to get the title, we’re looking in params for the b”title”, then using decode() to get the title back as a UTF-8 string.

title = params[b"title"][0].decode()
Enter fullscreen mode Exit fullscreen mode

Then we opened a store connection to the default Key Value Store with kv_open_default(). You can create named Key Value Store backends, but unless that’s necessary, it’s best to use the default store. Note that we will make an adjustment to the spin.toml file in a moment to grant access to Key Value Store. Otherwise this line will result in an access control error (Error::AccessDenied).

Next, we’re going to either fetch the existing list of bookmarks or create a new list. Then we’re going to append our new bookmark to the list.

# Get the existing bookmarks or initialize an empty bookmark list
bookmark_entry = store.get("bookmarks") or b"[]"
bookmarks = json.loads(bookmark_entry)

# Add our new entry.
bookmarks.append({"title": title, "url": url})
Enter fullscreen mode Exit fullscreen mode

Then we store the modified list and return a redirect to the index.html:

# Store the modified list in key value store
new_bookmarks = json.dumps(bookmarks)
store.set("bookmarks", bytes(new_bookmarks, "utf-8"))

# Direct the client to go back to the index.html
return Response(303, {"location": "/index.html"})
Enter fullscreen mode Exit fullscreen mode

With that, we’ve got the code we need to upload some bookmarks. But first we need to make a small change to spin.toml to allow access to Key Value Store.

Allowing Access to Key Value Store

One of the virtues of WebAssembly (the core technology upon which Spin is built) is that it has a very strict security sandbox. By default, an application is given very few permissions to access things like the filesystem, the network, and so on.

In order to allow the application to use Key Value Store, we need to enable the feature in the spin.toml file. That’s as easy as adding a single line:

spin_manifest_version = 2

[application]
authors = ["Matt Butcher <matt.butcher@fermyon.com>"]
description = "A bookmark app"
name = "bookmarker"
version = "0.1.0"

[[trigger.http]]
route = "/..."
component = "bookmarker"

[component.bookmarker]
source = "app.wasm"
key_value_stores = ["default"]    # ADDED THIS LINE
[component.bookmarker.build]
command = "spin py2wasm app -o app.wasm"
watch = ["app.py", "Pipfile"]

Enter fullscreen mode Exit fullscreen mode

Inside of [component.bookmarker], we just need to tell Spin that it’s okay for our app to access the default Key Value Store: key_value_stores = ["default"].

At this point, we’re going to streamline our development a little. We could do another spin build --up to rebuild and start a local test. However, if we run spin watch, it will do this for us in the background, rebuilding and restarting the server every time app.py changes.

Now we can re-run our big curl command:

$ curl -XPOST -H "content-type: application/x-www-form-urlencoded" -d "title=Fermyon+blog&url=http://fermyon.com/blog/index" -L http://localhost:3000/add
Hello from Bookmarker
Enter fullscreen mode Exit fullscreen mode

We can see from the output that it ran our code without error, and then it redirected us back to /index.html. But how do we see whether the bookmark was stored? Let’s install a little helper.

Checking Our Work with spin-kv-explorer

At this point, we don’t have a way to see what’s in our bookmarks. The easiest way to check our work while we’re still building the app is to install the Spin Key Value Explorer (kv-explorer).

We can install the KV Explorer with a few Spin commands. First, let’s add the template repository to our existing list of templates.

$ spin templates install --git https://github.com/fermyon/spin-kv-explorer
Copying remote template source
Installing template kv-explorer...
Installed 1 template(s)

+------------------------------------------------------+
| Name          Description                            |
+======================================================+
| kv-explorer   Explore the contents of Spin KV stores |
+------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Now if we were to do a spin templates list, we would see kv-explorer in the list. Let’s install an instance of kv-explorer into our project:

$ spin add -t kv-explorer kv-explorer
Enter fullscreen mode Exit fullscreen mode

Now if we look at spin.toml, we will see the following added:


[[trigger.http]]
id = "trigger-kv-explorer"
component = "kv-explorer"
route = "/internal/kv-explorer/..."

[component.kv-explorer]
source = { url = "https://github.com/fermyon/spin-kv-explorer/releases/download/v0.9.0/spin-kv-explorer.wasm", digest = "sha256:07f5f0b8514c14ae5830af0f21674fd28befee33cd7ca58bc0a68103829f2f9c" }
allowed_outbound_hosts = ["redis://*:*", "mysql://*:*", "postgres://*:*"]
key_value_stores = ["default"]
Enter fullscreen mode Exit fullscreen mode

Then you can restart spin watch with:

$ spin watch --key-value kv-credentials="admin:SECRET"
Logging component stdio to "/Users/technosophos/Code/Python/bookmarker/.spin/logs/"
Storing default key-value data to "/Users/technosophos/Code/Python/bookmarker/.spin/sqlite_key_value.db"

Serving http://127.0.0.1:3000
Available Routes:
  bookmarker: http://127.0.0.1:3000 (wildcard)
  kv-explorer: http://127.0.0.1:3000/internal/kv-explorer (wildcard)
Enter fullscreen mode Exit fullscreen mode

Note that you are now running two routes:

  • / is running our bookmarker app that we are writing
  • /internal/kv-explorer is running the Key Value Explorer

And when we added --key-value kv-credentials="admin:SECRET" to the command line, we were adding a new entry to our Key Value Store that set kv-credentials to admin:SECRET. (You should change SECRET to something else.). If we access http://127.0.0.1:3000/internal/kv-explorer, we’ll be prompted for a username and password, which is (you guessed it!) admin for the user, and SECRET (or whatever you set it to) as the password.

Once we’ve successfully authenticated, we can use the explorer to see our new bookmarks:

Screenshot of KV Explorer UI

Excellent! Now let’s turn back to our index() function and write a nice frontend.

💖 💪 🙅 🚩
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