paint-brush
Introducing Starlite: A New Python Asynchronous API Frameworkby@naamanhirschfeld
4,993 reads
4,993 reads

Introducing Starlite: A New Python Asynchronous API Framework

by Na'aman HirschfeldJanuary 6th, 2022
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Starlite is built on top of the Starlette ASGI toolkit and pydantic. Pydantic offers fantastic data validation and parsing using type hints. The idea to use Starlette as bases is of course not new - it was first done by FastAPI. A core goal of Starlite as a project is to be a community / group project. A framework should not be the work of a solo maintainer, because this is a recipe for disaster  - Github is full of the derelict remains of once shiny projects that have been abandoned.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Introducing Starlite: A New Python Asynchronous API Framework
Na'aman Hirschfeld HackerNoon profile picture

I'm very excited to announce the release of Starlite  -  a new python asynchronous API framework (ASGI).


Starlite is built on top of the Starlette ASGI toolkit, which offers high-performance async message handling, and pydantic, which offers fantastic data validation and parsing using type hints.


Relation to FastAPI

The idea to use Starlette and pydantic as bases is of course not new - it was first done by FastAPI. In fact, the idea to create Starlite began after I was asked to migrate an existing project at my workplace to FastAPI as a way to evaluate it. At first, I was fascinated by it - it was simple, elegant and had several brilliant ideas I never encountered in the python ecosystem before. This was also my first real contact with pydantic.


I immediately fell in love with pydantic ,  and I dedicated several weeks of my free time to develop an open-source library for creating mock data using it called pydantic-factories. This gave me a strong understanding of how pydantic is built and operates, which compelled me to reconsider my original estimation of FastAPI :  it became clear that a large number of the functionalities I attributed to FastAPI were in fact pydantic (I delve into pydantic in some depth in the article - The Rise of the Pydantic Stack).


At the same time, and as I had to create increasingly complex code on top of FastAPI at my job, my original fascination quickly turned into irritation: I found many of the base patterns to be half-baked. Then, I couldn't find documentation for what I needed at all, which forced me to enter the source code. Doing so, it quickly became apparent that FastAPI was actually a thin wrapper on top of Starlette as far as the architecture of the app and routers were concerned. This further irritated me because FastAPI tries very strongly to "sell" itself as original, novel, and simple, which to me appeared disingenuous.


The Starlite Project

I picked the name Starlite in discussion with my collaborators Sondre Lillebø Gundersen and Jonas Krüger Svensson because it conveys the fact that Starlite is built on top of Starlette. It's important to me not to obfuscate the foundations on which the framework is built, and not to claim credit for work that is not mine or ours. In this regard, it's also important to note that I borrowed ideas from many other frameworks - and I make no claim to being "original". Rather, my only claim is to have created a solid and well-crafted synthesis that can grow and evolve.


A Collaborative Framework


A core goal of Starlite as a project is to be a community / group project: A framework should not be the work of a solo maintainer, because this is a recipe for disaster  - github is full of the derelict remains of once shiny projects that have been abandoned, now remaining as a stark warning not to go it on your own. Instead, the idea behind Starlite is to create a project that has a dynamic core team of maintainers and contributors. To this end, we have an open discord server, and we invite people to contribute, and - if they wish and show dedication, become maintainers. This is a good point to invite you - the reader, to participate in this effort as well!


An Opinionated Framework


The intention behind Starlite was to create a higher-level opinionated API framework. I placed opinionated in bold because in my view, being opinionated regarding how certain things should be done and shouldn't be done, and establishing best practices, is one of the most important things a framework can do. If we consider that a framework should literally be a "frame for work", this becomes obvious - by taking certain decisions for you and establishing certain patterns as desirable, a framework liberates developers to focus on developing software rather than trying to "architect" the codebase.


In this regard, Starlite has been influenced by the TypeScript framework NestJS. NestJS is built on top of two very famous JavaScript frameworks - ExpressJS and Fastify, both of which are relatively un-opinionated frameworks. Instead of creating the low-level http request/response layer, NestJS focuses on establishing a set of patterns and ways of doing things on top of it. For example, Starlette allows one to declare routes, event handlers (startup/shutdown) and middleware both by registering them directly on the app or router, and dynamically. This pattern was copied as-is into FastAPI :


from typing import Optional

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def read_root():
    return {"Hello": "World"}


@app.get("/items/{item_id}")
def read_item(item_id: int, q: Optional[str] = None):
    return {"item_id": item_id, "q": q}


While this looks very clean at first sight ,  reminding one of Celery, for example, it is an anti-pattern in almost all circumstances. Using a method on the app instance (or router) as a decorator inverts the relation between the application and route, which is very problematic when you want to split your code across multiple files: Simply put, it forces you to import the app or router instance into the files hosting the endpoints, to decorate them. But - whereas frameworks that use a similar pattern, like Celery, have code discovery baked in, FastAPI does not. This means that python will not discover your code, which will require additional workarounds just to make this work.


Starlite, in contrast, uses the Starlette ASGI toolkit internally, but it does not directly extend the Starlette application or the Starlette router. Instead, it has a much smaller API surface designed for encapsulation. You can initialise your application in exactly one way - by importing your route handlers, middleware and event handlers into the entry point:


from starlite import Starlite, LoggingConfig

from my_app.users import UserController


app = Starlite(
  route_handlers=[UserController], 
  on_startup=[
    LoggingConfig(loggers={
      "my_app": {
          "level": "INFO",
          "handlers": ["console"],
      },
    })
  ]
)


While this is more limiting, it also allows for much fewer issues and it keeps the relation between app and route clear.


Core Features

By using the Starlette ASGI toolkit, Starlite supports both async and sync endpoints called "route handlers" in Starlite.


Starlite also uses orjson, the fastest python JSON library, for serialisation/deserialisation, which gives it an extra boost as a REST framework.


In addition to the above, Starlite has first-class typing support - the entire codebase is typed and checked with the strictest settings of MyPy, and pydantic is utilised extensively internally.


Decorators and Route Handlers

The basic architectural unit in Starlite is called a "route handler". Route handlers are decorated functions or methods, which contain additional metadata:


from typing import List

from starlite import Partial, delete, get, patch, post, put

from my_app.models import Resource


@get(path="/resources")
def list_resources() -> List[Resource]:
    ...


@post(path="/resources")
def create_resource(data: Resource) -> Resource:
    ...


@get(path="/resources/{pk:int}")
def retrieve_resource(pk: int) -> Resource:
    ...


@put(path="/resources/{pk:int}")
def update_resource(data: Resource, pk: int) -> Resource:
    ...


@patch(path="/resources/{pk:int}")
def partially_update_resource(data: Partial[Resource], pk: int) -> Resource:
    ...


@delete(path="/resources/{pk:int}")
def delete_resource(pk: int) -> None:
    ...


The decorators are used to define the http method, the content-type headers (media type), the status code, the kind of response that is returned from the function, and to granularly configure the OpenAPI schema. You can read about them in depth  -  here.


Parameter and Request Body Validation, Parsing and Injection

The arguments and return value of the function or method that is being decorated are transformed into a pydantic model behind the scenes. This model is used to 1. validate and parse request data (parameters and request data), 2. inject the data into the function, and 3. generate the OpenAPI schema. For example, in the above code snippet the data kwarg to the create_resource function declares that this function expects a request-body that fits the Resource type (pydantic model instance). At the same time, the pk argument to most other functions, combined with the declaring of the path which includes "{pk:int}", declares that these functions expect a path parameter. You can read about this in the sections concerning parameters and request body in the docs.


Class-Based Controllers

Controllers are classes that include several different route handler methods:


from typing import List

from pydantic import UUID4
from starlite import Controller, Partial, get, post, put, patch, delete

from my_app.models import User


class UserController(Controller):
    path = "/users"

    @post()
    async def create_user(self, data: User) -> User:
        ...

    @get()
    async def list_users(self) -> List[User]:
        ...

    @patch(path="/{user_id:uuid}")
    async def partially_update_user(self, user_id: UUID4, data: Partial[User]) -> User:
        ...

    @put(path="/{user_id:uuid}")
    async def update_user(self, user_id: UUID4, data: User) -> User:
        ...

    @get(path="/{user_id:uuid}")
    async def get_user(self, user_id: UUID4) -> User:
        ...

    @delete(path="/{user_id:uuid}")
    async def delete_user(self, user_id: UUID4) -> User:
        ...


The above example is a CRUD controller for a User model. Controllers offer a way to organise route handlers according to shared logical concerns - in this case grouping the logic concerning the User models, while leveraging Python OOP.


All the route handlers defined on the UserController will have the same base path (/users) appended to them. Additionally there are other attributes that can be defined on the Controller that affect all methods: shared dependencies that can be injected, the response class to use, the response headers to set etc.


You can read more about controllers and routers - here.


Dependency Injection Starlite has a built-in Dependency Injection mechanism that has been influenced by the wonderful test framework pytest, and specifically "pytest fixtures":


dependencies.py

from os import environ

from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

# we first define a dependency. The dependency is a function or method (async or sync) whose return value will be injected

def create_postgres_connection() -> AsyncEngine:
    postgres_connection_string = environ.get("POSTGRES_CONNECTION_STRING", "")
    if not postgres_connection_string:
        raise ValueError("Missing ENV Variable POSTGRES_CONNECTION_STRING")
    return create_async_engine(postgres_connection_string)


route_handler.py

from sqlalchemy.ext.asyncio import AsyncEngine
from starlite import get


# a dependency is a kwarg - in this case "connection". The dependency needs to be mapped to the kwarg on some 
# of the application. 
@get("/some-path")
async def my_route_handler(connection: AsyncEngine) -> None:
  ...


main.py

from starlite import Starlite, Provide

from dependencies import create_postgres_connection
from route_handler import my_route_handler


# when instantiating the app we declare the dependency, mapping it to the key "connection". We also cache it, so the
# engine will be created once and not on every call. 
app = Starlite(
  route_handlers=[my_route_handler], 
  dependencies={ 
    "connection": Provide(create_postgres_connection, use_cache=True) 
  },
)


Starlite allows you to define dependencies at all levels of your application: the application, routers, controllers and individual methods. It also allows overriding dependencies on each level - you can read more about this feature  -  here.


OpenAPI Schema Generation

Starlite supports automatic OpenAPI spec generation. It uses the excellent openapi-schema-pydantic library to support the latest version of OpenAPI - v3.1.0. While the spec generation is automatic and is based on inferring values from the route handler function signatures, you can configure and extend it in multiple ways.


Once you configure OpenAPI spec generation, you can download the spec as JSON (default) or YAML. Additionally, you can access a Redoc UI of the docs. You can read about this feature in more depth  -  here.


Middleware

Starlite supports the Starlette middleware architecture and is compatible with existing 3rd party middleware created for Starlette. Additionally, Starlite offers easy to use abstractions on top of Starlette - allowing for very simple configuration of CORS and allowed hosts, on which you can read  -  here.


Authentication

While Starlite does not force the user to use any specific authentication mechanism, the intended pattern for this is to use middleware. For this end, Starlite offers an easy-to-extend AbstractAuthenticationMiddleware and guidelines on how to use it. You can read more about authentication  -  here.


Route Guards

Guards are an authorization mechanism - directly borrowed from NestJS. The idea behind guards is simple - these are functions that receive both the request and the target route handler (which is a pydantic model with a lot of metadata), and they need to decide whether the request should proceed to the route handler or raise an exception. You can read more about guards - here.


Testing

Starlite regards testing as a first-class citizen. There is a built-in test client, which extends the Starlette test client. Additionally, there are helpers to create test requests and a test app - both of which allow for simpler unit testing. For example, lets say we have a very simple app:


from starlite import Starlite, MediaType, get


@get(path="/health-check", media_type=MediaType.TEXT)
def health_check() -> str:
    return "healthy"


app = Starlite(route_handlers=[health_check])


We could test it like so:


from starlette.status import HTTP_200_OK
from starlite import create_test_client

from my_app.main import health_check


def test_health_check():
    with create_test_client(route_handlers=[health_check]) as client:
        response = client.get("/health-check")
        assert response.status_code == HTTP_200_OK
        assert response.text == "healthy"


You can read more about testing  -  here.


Summary

In this article, I introduced Starlite and discussed some of its core features. You are invited to check out (and star!) the project on github, read the docs and join our discord server.


Also published on: https://itnext.io/introducing-starlite-3928adaa19ae