Part 2: Using a Router to Create Two Handlers
Matt Butcher
Posted on January 4, 2024
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"
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!
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)
We added two import
s. 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",
)
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)
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 thehandler.target()
to the handler function is optional. And inindex()
,request
is unused. However, in the next section we are going to use therequest
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
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
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']
}
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
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'
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"
}
]
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
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()
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})
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"})
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"]
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
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 |
+------------------------------------------------------+
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
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"]
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)
Note that you are now running two routes:
-
/
is running ourbookmarker
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:
Excellent! Now let’s turn back to our index()
function and write a nice frontend.
Posted on January 4, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.