Understanding FastAPI: How Starlette works

ceb10n

Rafael de Oliveira Marques

Posted on June 24, 2024

Understanding FastAPI: How Starlette works

This post lives in:

We've seen in the last post that FastAPI is built on top of Starllete. We also saw that Starlette is a lightweight ASGI framework/toolkit.

Now, to start undertanding how FastAPI works, let's see what Starlette has to offer, how he deals with our HTTP requests, etc.

⚠️ Note: To work with starlette, you'll need at least python 3.8, so check which version you have installed on your computer before continuing

Hello world Starlette

Let's recreate that simple hello world from the previous post using Starllete:

from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
from starlette.routing import Route


async def hello(request):
    return PlainTextResponse("Hello, World!")


app = Starlette(routes=[
    Route('/', hello),
])

Enter fullscreen mode Exit fullscreen mode

Can you see the similarities with the example in the previous post? Let's remember it:

class SimplestFrameworkEver:
    async def __call__(self, scope, receive, send):
        await send({
        "type": "http.response.start",
        "status": 200,
        "headers": [
            [b"content-type", b"text/plain"],
        ],

        })
        await send({
            "type": "http.response.body",
            "body": b"Hello, World!",
        })


app = SimplestFrameworkEver()
Enter fullscreen mode Exit fullscreen mode

In both cases we have a class (SimplestFrameworkEver or Starlette), we create an instance of this class and pass it to the ASGI server to deal with it.

And according with ASGI's specification, an ASGI must expose a a single, asynchronous callable who receives a dictionary named scope and two other async callables named receive and send as parameters.

So if we are passing a Starlette object to the ASGI server, it MUST be a class that has a __call__ method implemented. Let's open Starlette's source code to check if it's true.

If we go to its official repository, we can find Starllete class inside starlette/applications.py:

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
    scope["app"] = self
    if self.middleware_stack is None:
        self.middleware_stack = self.build_middleware_stack()
    await self.middleware_stack(scope, receive, send)
Enter fullscreen mode Exit fullscreen mode

So we can see here that Starlette is a "callable" class, and apparently has a list of middlewares that will be executed each request. Simple as that, right? Yes and no 🤣

Things are never as simple as they seem, and we'll need to see what is a middleware_stack

ASGIApp

What we've seen this far is: Starlette is a callable that receives a scope, a receive and a send parameters. Which is exactly what and ASGI application is supposed to be. And what is a middleware_stack?

self.middleware_stack: ASGIApp | None = None
Enter fullscreen mode Exit fullscreen mode

middleware_stack is an ASGIApp. But if you look in Starlette's types, ASGIApp is just an ASGI callable:

ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]]
Enter fullscreen mode Exit fullscreen mode

If we take a look at Starlette.build_middleware_stack, we'll se a strange piece of code:

middleware = (
    [Middleware(ServerErrorMiddleware, handler=error_handler, debug=debug)]
    + self.user_middleware
    + [
        Middleware(
            ExceptionMiddleware, handlers=exception_handlers, debug=debug
        )
    ]
)

app = self.router
for cls, args, kwargs in reversed(middleware):
    app = cls(app=app, *args, **kwargs)
return app
Enter fullscreen mode Exit fullscreen mode

What is happening here is that starlette is creating sort of a chain of responsability of middlewares, and lastly our router / endpoint. Things will work like:

-> ServerErrorMiddleware
    -> Other Middlewares
        -> ExceptionMiddleware
            -> Router
Enter fullscreen mode Exit fullscreen mode

Each ASGIApp will receive another ASGIApp as a dependency, and each one will call the next app when it gets called, till we reach ExceptionMiddleware, that will wrap our Router to deal with our exceptions.

The Router then will not receive any ASGIApp as a dependency. It will implement it's own app function (ASGI app) to match a route and execute our path operation function:

async def app(self, scope: Scope, receive: Receive, send: Send) -> None:
    # ... previous code

    for route in self.routes:
        # Determine if any route matches the incoming scope,
        # and hand over to the matching route if found.
        match, child_scope = route.matches(scope)
        if match == Match.FULL:
            scope.update(child_scope)
            await route.handle(scope, receive, send)
            return

    # ... code continues
Enter fullscreen mode Exit fullscreen mode

Now that we know the flow of a Starlette's request, we can create a simple middleware to log the resquest's path, and another one that logs that everything went ok after the response is sent:

class LogRequestMiddleware:
    def __init__(self, app: ASGIApp):
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send):
        logging.info(f"-> received a request @ {scope['path']}")
        await self.app(scope, receive, send)


class LogResponseMiddleware:
    def __init__(self, app: ASGIApp) -> None:
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send):
        await self.app(scope, receive, send)
        logging.info("-> wow, we did it")


async def hello(request):
    logging.info("Great news, we got a request!")
    return PlainTextResponse("Hello, World!")


app = Starlette(
    routes=[
        Route('/', hello),
    ],
    middleware=[
        Middleware(LogRequestMiddleware),
        Middleware(LogResponseMiddleware)
    ]
)
Enter fullscreen mode Exit fullscreen mode

And we'll get:

INFO:root:-> received a request @ /
INFO:root:Great news, we got a request!
INFO:     127.0.0.1:51770 - "GET / HTTP/1.1" 200 OK
INFO:root:-> wow, we did it
Enter fullscreen mode Exit fullscreen mode

To learn more about Starlette's middlewares, you can read it's own documentation: Middleware. The people from the project made a great work documenting it.

Routes and Router

And last but not least, after all the chain of middlewares, we'll get our Router beeing executed wrapped in an ExceptionMiddleware, so it can deal with our exceptions. Router will have a list of routes to deal with.

If the router finds a matching route, it will call the route's handle function. The handle function will call our endpoint, that is basically the function or class that you passed while creating the Starlette app:

app = Starlette(
    routes=[
        Route('/', hello), # -> hello is the function that will be handled by Router's handle
    ],
Enter fullscreen mode Exit fullscreen mode

Infact, a Router is an ASGIApp too, and you can dismiss all Starlette's middlewares by creating only a Router:

app = Router(routes=[
    Route('/', hello)
])
Enter fullscreen mode Exit fullscreen mode

And what about FastAPI?

But Rafael, the title of your posts are saying Understanding FastAPI, but it's the second post and you keep writing stuff about ASGI specs, Starlette, etc.

But remember what I said: FastAPI is Starlette. It's a framework built on top of Starllete, and if you go to FastAPI's source code, you'll find this:

class FastAPI(Starlette):

    def __init__(
    # ... code continues
Enter fullscreen mode Exit fullscreen mode

All this time you were reading about FastAPI, but I was writing about the internals of FastAPI, the foundations that support FastAPI.

In the next post we'll look how FastAPI extends Starlette and ASGI specs to offer us Pydantic models, OpenAPI specs out of the box, etc.

Stay tuned ;)

💖 💪 🙅 🚩
ceb10n
Rafael de Oliveira Marques

Posted on June 24, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related