Understanding and Creating your Own ASGI Web Framework [Part-5]

Published on 18 September 2025
6 min read
ASGI
Web Framework
Python
WSGI
Uvicorn
Gunicorn
DIY
Understanding and Creating your Own ASGI Web Framework [Part-5]

Improving our ASGI framework

In the last part we have made a lot of progress implementing a background task system. And today we have a few more items to implement.

I have planned to implement a middleware system and also support for headers and query string, since it until now was just basically a placeholder.

Before we start, lets just make a few notes here for the middleware system and also the headers and query string support.

Middlewares:

  • Must be for global usage, to support middlewares like CSRF, Cookies etc.
  • Must have a simple signature based on class inheritance.

Headers:

  • Must handle the headers and provide a simple dict to access them.

Query string:

  • Must be straightforward to use and support custom extractors as we did with the body.
  • Must be able to parse the query string as a dict as default.

Headers and Query string

The changes for headers were pretty straightforward, I have added a method to get the headers as a dict parsing it from bytes to string.

On the other side for the query string, Even I have added a simple method that you would normally avoid to it yourself it shows the idea behind the query string parsing.

asgi/request_data.py
python
class RequestData(Generic[QUERY_STRING_TYPE, BODY_TYPE]):

    def __init__(
            self, asgi_receiver_method: Callable[[], Awaitable[Dict[str, Any]]],
            headers: List[Tuple[bytes, bytes]],
            query_string: bytes = b'',
            qs_extractor: QueryExtractor = None,
            body_extractor: BodyExtractor = None
    ):
        self._qs_extractor = qs_extractor
        self._body_extractor = body_extractor
        self._asgi_receiver_method = asgi_receiver_method

        self._body = b''
        self._headers = headers
        self._query_string = query_string
    
    async def get_headers(self) -> dict:
        return {key.decode("utf-8"): value.decode("utf-8") for key, value in self._headers}

    async def get_header_value(self, key: str) -> str:
        headers = await self.get_headers()
        return headers.get(key, "")

    async def get_query_string(self) -> Union[QUERY_STRING_TYPE, None]:
        """
        This method will trigger custom extractors registered withing the router
        The type of the returned value depends on the query_string_extractor used.

        If no extractor is registered, returns None.
        """
        if self._qs_extractor is None:
            return None
        return self._qs_extractor(self._query_string)

    async def get_query_string_dict(self) -> dict:
        """
        Parse the query string as Dict. Malformatted values will be ignored.

        !Important: it won't trigger custom extractors as query_string_extractor is not used

        !You would probably want to use either parse_qs or parse_qsl from the urllib.parse STD package,
        but I wanted to build it just to show the process of parsing it here.!
        """
        result = {}
        if not self._query_string:
            return result

        qs = self._query_string.replace(b'?', b'')
        for key_value_pair in qs.split(b'&'):
            infos = key_value_pair.split(b'=')
            if len(infos) != 2:
                # skip malformed values
                continue
            try:
                key = unquote_plus(infos[0].decode("utf-8"))
                value = unquote_plus(infos[1].decode("utf-8"))
                if key in result:
                    if not isinstance(result[key], list):
                        result[key] = [result[key]]
                    result[key].append(value)
                else:
                    result[key] = value
            except (UnicodeDecodeError, ValueError):
                continue  # Skip malformed values

        return result

    #ommited other methods

Adding a global middleware system

That’s one of the most simple things I’ve done so far but delivers a lot of value.

This simple middleware system has a manager that when triggered will chain all the middlewares and call the handler respecting the middlewares insertion order.

It will be injected into the App class so when the App decides to trigger the handler, instead, we call the middleware manager passing the target handler and then starting the chaining process.

With this foundation you can have any global middleware you want and have as much of them as you want, or none at all that’s up to you.

asgi/middleware.py
python
from abc import ABC
from typing import List, Union

from asgi.types import HandlerType


class BaseGlobalMiddleware(ABC):
    """
    Base class for global middleware.
    You can implement your own global middleware by inheriting this class.

    e.g: CSRF, Cookie, Session middlewares... anything you want to run in a global scope.
    """

    async def __call__(self, call_next: Union['BaseGlobalMiddleware', HandlerType]):
        """
        e.g:
        async def __call__(self, call_next):
            async def wrapper(request_data):
                # do something...
                return await handler(request_data)

        return wrapper
        """
        raise NotImplementedError("__call__ method is not implemented yet")


class _MiddlewareManager:

    def __init__(self, middlewares: List[BaseGlobalMiddleware]):
        self.stack = middlewares

    async def wrap(self, handler: HandlerType, request_data):
        current_handler = handler

        for middleware in reversed(self.stack):
            current_handler = await middleware(current_handler)

        return await current_handler(request_data)

    async def __call__(self, handler, request_data):
        return await self.wrap(handler, request_data)

Testing our framework

Let’s add a few middlewares to our sample and test it!

sample.py
python
from asgi.app import App
from asgi.api_router import ApiRouter
from asgi.background_tasks import get_background_tasks, create_task
from asgi.http_responses import OK_JSONResponse
from asgi.middleware import BaseGlobalMiddleware
from asgi.request_data import RequestData
from asgi.types import Methods

router1 = ApiRouter()

async def home_bg_task(_) -> None:
    print("home bg task triggered")

@router1.get("/home")
async def home(request_data):
    bg_task = get_background_tasks()
    await bg_task.add_tasks([create_task(home_bg_task)])
    print("home triggered")

    return OK_JSONResponse()

@router1.get("/")
async def root(request_data):
    print("root triggered")
    return OK_JSONResponse()

router2 = ApiRouter()
@router2.get("/about")
async def about(request_data):
    print("about triggered")
    # server should return 500 error because we are returning none as response


def qs_extractor(qs: dict) -> dict:
    return qs

def body_extractor(body: bytes) -> int:
    return 1

@router2.multi_methods(
    "/about/careers",
    [Methods.GET, Methods.POST],
    qs_extractor,
    body_extractor
)
async def about(request_data: RequestData[dict, int]):
    print("about careers triggered")
    qs = await request_data.get_query_string_dict()
    print(qs)
    body_stream = bytearray(*[ch async for ch in request_data.get_stream_body_bytes()])
    print(body_stream)
    body_custom = await request_data.get_body()
    print(body_custom)
    body_json = await request_data.get_json_body()
    print(body_json)

    return OK_JSONResponse()

class Mid1(BaseGlobalMiddleware):

    async def __call__(self, handler):
        async def wrapper(request_data):
            print("mid1")
            resp = await handler(request_data)
            print("mid1 end")
            return resp

        return wrapper

class Mid2(BaseGlobalMiddleware):

    async def __call__(self, handler):
        async def wrapper(request_data):
            print("mid2")
            resp = await handler(request_data)
            print("mid2 end")
            return resp

        return wrapper


app = App(middlewares=[Mid1(), Mid2()])

app.include_routes([router1, router2])

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

You can test it with python sample.py or uvicorn sample:app both should work just fine.

Conclusion

So far, we have seen a lot about ASGI frameworks are under the hood. I hope at this point you have a better vision of it.

I’m saying ASGI, but in fact it’s more like standard Web frameworks, you have learned the basis of them all.

You will see a lot of these inside known frameworks out there, of course, if they are in python will be closer to the examples, but the general idea you will see in any other language (Only the App/ASGI structure are Python specific).

Thanks!

This is the last part of this ASGI series, I’ll continue writing as frequently as possible, but it’s time to move to another series, languages, tools…

I’ll probably continue improving this framework, but I don’t know when. So, let’s see how it goes.

Thank you for reading! I truly hope you enjoyed it! If you have any feedback, let’s talk! If you loved it, please, share it with your friends, LinkedIn, and remember to give me a star!

!Source Code!

Link for Part-4!