Part 2: Using a Router to Create Two Handlers
Tags: programming, tutorial, python, webassembly
Published: 2024-01-04 16:28:31

We’re on our way to building a bookmarking app in Python. We are using [Spin](https://developer.fermyon.com/spin/v2/index), 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](https://pypi.org/project/http-router/), 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`:

```toml
[[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:

```python
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:

```python
# 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:

```python
# 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 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
```

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`.

```python
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:

```python
{
    '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:

```json
[
	{
		"title": "SOME TITLE",
		"url": "http://example.com"
	},
	{
		"title": "SOME OTHER TITLE",
		"url": "http://another.example.com"
	}
]
```

So let’s code that up.

```python
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.

```python
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.

```python
# 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`:

```python
# 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:

```toml
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](https://github.com/fermyon/spin-kv-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:

```toml

[[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 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ctxqmf67o1xuiopfqh51.png)


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