Getting Started with bareASGI

Rob Blackbourn
5 min readOct 30, 2019

The next generation of Python web servers will be powered by ASGI, which is a low level standard for asynchronous web servers. A number of frameworks have been written to support this standard. I will talk about how to get started with bareASGI, a framework I have written and have been using for about 6 months.

ASGI Servers

To use ASGI you need a server. There are several to choose from. Here we will use hypercorn.

$ pip install hypercorn

Hello, World!

Let’s get started!

import asyncio
from hypercorn.asyncio import serve
from hypercorn.config import Config
from bareasgi import Application, bytes_writer
app = Application()@app.on_http_request({'GET'}, '/hello-world')
async def http_request_handler(scope, info, matches, content):
headers = [
(b'content-type', b'text/plain')
]
return 200, headers, bytes_writer(b'Hello, World!'), None
config = Config()
config.bind = ["0.0.0.0:9009"]
asyncio.run(serve(app, config))

Run the program and browse to http://localhost:9009/hello-world to check it works.

Now let’s take this apart.

The HTTP Request Handler

As it’s name suggests the http_request_handler function handles an HTTP request. It’s arguments form the request, and it returns a response. Unlike other frameworks I chose not to wrap these in an object. There aren’t many, and I took the view that the less work done that isn’t strictly necessary the faster the framework would be.

This example doesn’t use any of the input parameters, so I’ll introduce them later, but it does return a whole lot of stuff for the response.

The HTTP Response

The first element of the response tuple is the 200 HTTP response code.

The second element are the headers. These are optional (we could have passed in None), but we’re returning plain text so I set the content-type accordingly. Note that the headers are a list of tuples of bytes. Also the header name is in lower case. This is all part of the ASGI standard, and it means this list can be passed directly through to the server without the framework doing any extra work.

The third element is the body of the response. This looks a bit tricksy, but it’s one of the key concepts in the framework, so we’ll take a little time to understand it. Here’s the actual code for bytes_writer.

async def bytes_writer(buf, chunk_size = -1):
if chunk_size == -1:
yield buf
else:
start, end = 0, chunk_size
while start < len(buf):
yield buf[start:end]
start, end = end, end + chunk_size

This is an asynchronous generator. It (optionally) splits the response into chunks which are sent to the client in sequence. We do this for a number of reasons.

First it makes the generation of the response asynchronous. Lets say you’re sending an image file. If the file is read asynchronously in chunks the asyncio coroutines will be able to yield during the file chunk reading and when sending the chunk over the network to the client. This means your web server will be able to respond to other requests while the IO is waiting.

Second it means the response can be cancelled. If the client browses away from the page before the image has been uploaded, the image data will stop being read and the server will stop sending it.

Third we can support streaming content: for example a ticking price or a twitter feed.

The last element of the response is a list of push request supported by the HTTP 2 protocol which I won’t discuss that here.

Routing

In the above example routing was implemented with a decorator:

@app.on_http_request({'GET'}, '/hello-world')

The first argument is a set of HTTP methods that are supported by the handler, and the second is the path to which the handler will respond.

I used the decorator routing style for simplicity, however it could have been done in the following manner:

import asyncio
from hypercorn.asyncio import serve
from hypercorn.config import Config
from bareasgi import Application, bytes_writer
async def http_request_callback(scope, info, matches, content):
headers = [
(b'content-type', b'text/plain')
]
return 200, headers, bytes_writer(b'Hello, World!')
app = Application()
app.http_router.add({'GET'}, '/hello-world')
config = Config()
config.bind = ["0.0.0.0:9009"]
asyncio.run(serve(app, config))

This is my preferred method, as it allows more control over the creation of the Application object.

In the examples above we used a literal path. We could also have used a parameterised path:

'/foo/{name}/{id:int}/{created:datetime:%Y-%m-%d}'

Note how the parameters can optionally have types, and some types can have parse patterns. There is also the special parameter {path} which captures all remaining path elements.

The captured parameters are passed into the HTTP request handler as the matches argument which is a dict of the parameter names as keys for the matched values.

REST

The next example sends and receives data using REST. You will need something like postman to try it out.

import asyncio
import json
from hypercorn.asyncio import serve
from hypercorn.config import Config
from bareasgi import Application, text_reader, text_writer
import bareutils.header as header
async def get_info(scope, info, matches, content):
accept = header.find(b'accept', scope['headers'])
if accept != b'application/json':
return 500
text = json.dumps(info)
headers = [
(b'content-type', b'application/json')
]
return 200, headers, text_writer(text)
async def set_info(scope, info, matches, content):
content_type = header.find(b'content-type', scope['headers'])
if content_type != b'application/json':
return 500
text = await text_reader(content)
data = json.loads(text)
info.update(data)
return 204
app = Application(info={'name': 'Michael Caine'})
app.http_router.add({'GET'}, '/info', get_info)
app.http_router.add({'POST'}, '/info', set_info)
config = Config()
config.bind = ["0.0.0.0:9009"]
asyncio.run(serve(app, config))

To try this out make a GET request to http://localhost:9009/info with the accept header set to application/json. It should response with a content-type of application/json and body of {“name": “Michael Caine"}. Sending a POST to the same endpoint with the body {“name": “Peter Sellers"} and a content-type of application/json should respond with a 204 status code. A subsequent GET should return {“name": “Peter Sellers"}.

In this example we start to use some of the request parameters. The scope is passed directly from the ASGI server, and is used to fetch the headers. The content is the complement of the body in the response. It is an asynchronous generator to read the body of the request. The text_reader helper function is used to retrieve the body (note this is awaited). The info is a user supplied argument to the application, and is used to share information between the handlers.

The handlers respond with 500 if the request was incorrect. We can see that it is not necessary to provide all the elements of the response, where all elements to the right would be None.

Wrapping Up

If you’ve got here you have my sympathy. That was a whole lot of explanation for two example programs! I hope I’ve given you some ideas about how I’ve designed the framework.

There’s a small, but growing eco-system of code around the framework including CORS, static file serving, GraphQL, Jjinja2 templating, prometheus instrumentation middleware, and a complimentary client. If you’d like to find out more check out the documentation.

--

--