FastAPI (with API versioning!) for data applications

Jordi Giner-Baldó
Geoblink Tech blog
Published in
10 min readFeb 25, 2021

At Geoblink we have a long history of deploying machine learning (ML) models behind REST APIs in order to make their predictions available to consumers. Lately we have been encountering other use cases where data teams find it useful to build their own APIs for a variety of tasks, so we decided to give a try to the new kid-in-the-block of the Python webdev framework ecosystem: FastAPI. In this blog post we’ll unveil our first impressions of FastAPI and discuss how we use it at Geoblink and, in particular, how this new framework has enabled us to version our APIs without too much hassle (but not without “some” hassle as you’ll see below), something we were not able to do before. This post is written from a data scientist perspective. The take-home message here is that FastAPI simplifies the API-building aspect so much that even someone that is not familiar with webdev can get a minimalistic, yet powerful API in no time. Let’s get started then :)

Motivation

Data Foundations is one of the three data teams we currently have at Geoblink. The team’s mission is to increase and maintain the data offering of our core product: evaluation of new geospatial data sources, developing POCs that combine those sources to provide innovative & valuable indicators to our clients, etc. A few months ago we were in need of building a REST API to communicate with our workflow orchestrator, Apache Airflow (the details of this will be the subject of another blog post ;) ). Given the simplicity of our requirements, we knew that Flask would be a good fit for the task, as we extensively use this framework to deploy ML models to production. However, after hearing about FastAPI we decided to give it a try, and this API seemed to be a good candidate as we had to develop it from scratch. The features of FastAPI that make it so attractive for building APIs have already been discussed in detail elsewhere (see e.g. this introduction article by tiangolo, FastAPI’s author; and the compilation of links in FastAPIs docs), so summing them up:

  • Simple, intuitive & easy to learn: the transition from Flask is really easy because both follow the “microframework” philosophy
  • Fast & async: FastAPI is one of the fastest Python web frameworks
  • “Free” data validation and serialization/deserialization just by using Python’s native type hinting
  • Automatic API documentation based on OpenAPI: can’t emphasize how useful this is!!
  • Exhaustive documentation with a lot of code samples

This is all achieved by standing on the shoulders of Starlette, Pydantic and Uvicorn: FastAPI provides the plumbing code to make them all work seamlessly.

We are not going to get into detail on the first steps to build a FastAPI app because there are already really good tutorials around (see the Resources section). The conclusion is that the framework totally held up to its development productivity promises: we quickly managed to spin up a production-ready API and we were impressed by all the things we got for “free”, in particular the beautiful API documentation & all the data validation & (de)serialization. All this was carried out by members of the team without any webdev experience and without resorting to any third-party plugin as it is common in the Flask ecosystem.

In our first FastAPI project we also put into practice a bunch of Python best practices that we already use in other projects at Geoblink, such as setting pre-commit hooks to lint code using Flake8, enforce consistent formatting with Black, automatically sort module imports with isort and type-checking the code through Mypy. This last step is particularly important since FastAPI takes advantage of type hints in order to validate and transform request and response data as well as automatically document the API. Moreover, we used Poetry to manage our Python dependencies and we built our CI/CD using an in-house version of Jenkins on k8s.

API versioning with FastAPI

While we were very happy with the result of our first FastAPI project, we were even happier when the API started being consumed by services owned by teams different from ours (e.g. the Data Engineering team maintaining our Airflow deployment). This however brought its own set of challenges, namely handling API-breaking changes in a nice way for the clients. And this is how we set on our journey to version our brand new FastAPI app!

API versioning is a very complex topic and while our needs as a small team are very different from those of tech giants like Stripe and Google, it was definitely helpful to understand their solutions and thoughts on versioning, see e.g. API design: which version of versioning is right for you and APIs as infrastructure.

However, what we were even more thrilled about is that there was already an initiative to implement API versioning using FastAPI: reading through a couple of issues (this and this) in the FastAPI repo led us to the package fastapi-versioning, which was developed as a proof-of-concept to show a simple implementation of API versioning on top of FastAPI [1].

The approach proposed by fastapi-versioning is quite straightforward, as it can be seen from the following example:

You basically:

  1. Create a standard FastAPI app instance
  2. Create the path operations and its associated functions, i.e. the endpoints. You associate them to the app instance using a decorator
  3. You can specify multiple versions of the same endpoint by using the @version decorator on the corresponding path function. This specifies at what API version that path function starts to be “active”, i.e. will handle calls to that endpoint.
  4. Create an instance of VersionedFastAPI, passing the app instance to it

If an endpoint is not decorated by the @version decorator, it will be available from the version specified in default_api_versionon.

Running the example above using uvicorn example.app:app will generate the following endpoints:

/v1_0/greet

/v1_1/greet

/v1_1/goodbye

/v1_2/greet

/v1_2/goodbye

/v1_2/foo

as well as:

/docs

/v1_0/docs

/v1_1/docs

/v1_2/docs

/v1_0/openapi.json

/v1_1/openapi.json

/v1_2/openapi.json

Probably the trickiest part to understand here is what the @version decorator exactly does: it basically represents what’s the initial API version at which the decorated path function will start taking the calls to the endpoint it’s associated to. What??? Yes, I can feel your pain after reading the tongue twister above, so let me try to explain it with an example: take the /greet endpoint in the sample code above. There are two different path functions associated with /greet: greet_with_hello() (decorated with @version(1,0)) and greet_with_hi() (decorated with @version(1,2)). So, for version 1.0, a call to /v1_0/greet will be dispatched by FastAPI to greet_with_hello(). So far so good. What happens for version 1.1? There’s no path function for /greet decorated with @version(1,1), but thanks to fastapi-versioning the /v1_1/greet endpoint will still be automatically generated and requests to it will be dispatched to the path function greet_with_hello(), as that’s the path function decorated with the latest version number that is at the same time older (inferior) than version 1.1. And what will happen for version 1.2? For that version, there’s already a path function that is decorated with @version(1,2), so FastAPI will route requests to /v1_2/greet to greet_with_hi(). From that version on, calls to /vX_X/greet will be handled by greet_with_hi(). This will happen until at a later version we decide to modify /greet and we create a new path function pointing to /greet that is annotated with a @version higher than 1.2.

While this very simple approach works surprisingly well in general, we ran into some problems when we tried to use custom error handling together with fastapi-versioning. Custom exception handling in FastAPI is defined by passing a dictionary to the FastAPI app object as:

The exception_handlers dictionary maps HTTP error codes or exceptions to exception handling functions that transform those exceptions to custom responses, e.g.

Unfortunately, this does not work well when you use FastAPI together with fastapi-versioning, as the “sub-apps” that are internally used by fastapi-versioning to represent each API version don’t receive the exception handlers dictionary, thus it’s impossible to actually modify the default exception handling behaviour. As having custom error responses was a requirement for us (we use some standard error responses across many different microservices), we decided to patch the main VersionedFastAPI object in the following way:

  1. We first defined an APIVersion class containing major-minor information and that can be converted into a string or a tuple.

2. We defined a function version_app() to encapsulate all the actions needed to get the versioned endpoints working with custom error handling (which is enabled by the for loop at the end of the function).

3. The previous function is used as:

where internal_error_exception_handler and request_validation_exception_handler are callables that modify FastAPI’s default error handling behaviour. For example, request data validation errors raised by FastAPI return by default a 422 status code and a JSON response body with a detail key that contains Pydantic’s validation errors. We have used the following error handling function:

to get a different format for the JSON response where the error_name property contains the name of the raised exception and message contains the details provided by Pydantic. This allows us to have consistent response formats across different types of errors raised by our application.

Some snapshots of how the API docs look like:

  • main /docs endpoint:
The main /docs endpoint lists the available API versions, so that you can now access e.g. version 1.1 docs as /v1_1/docs. Note than in our set-up, the v1_1 next to the app title indicates the latest version available.

Note that we have also forced that the v1_1 superscript that appears next to the app title at the top coincides with the default_api_version passed to the VersionedFastAPI object.

  • /v1_0/docs endpoint:
/v1_0/docs lists all the endpoints available at API version 1.0

Lessons learnt / gotchas

While we are very happy so far with our FastAPI journey, we have obviously also encountered a few (non-critical) gotchas one needs to be aware of and handle appropriately, as with any other open-source project. Let’s sum up here some of the lessons learnt in our FastAPI / fastapi-versioning journey:

FastAPI

  • Logging in production: the recommended production setup for a FastAPI app consists of Nginx + Gunicorn + Uvicorn. Each of these components emits their own set of logs (sometimes from unexpected places) and unifying the format and level of all of them might be tricky.
  • Pay attention to the organization of the main.py file used as entrypoint to the app as it can get really cluttered, with a lot of framework “magic” happening at module-level.
  • While FastAPI docs have tons of examples showing the possibilities of the framework, we haven’t been able to find framework’s API docs (as in function and class-level docs). That means that sometimes you have to delve into FastAPI source code to understand better what a function does.

FastApi versioning

  • This plugin was started as a proof-of-concept and there aren’t so many contributors, which means that it might take a little time for it to adapt to improvements in the main FastAPI project, so be patient :)
  • All endpoints are versioned since the moment you introduce it, so be careful if you start using it when the API is already live because it will introduce a /vX.X at the beginning of all endpoints and you could break existing clients: apply redirections and use default_api_version parameter wisely.
  • Versioning only applies to path functions (endpoints), but not to models or exception handlers: you need to deal with breaking changes in those yourself.
  • A bit of a technical point: each group of path functions associated to a single API version is internally represented by a mounted subapp. fastapi-versioning doesn’t allow the user to configure such mounted sub-applications from its main VersionedFastAPI constructor, so one has to patch it after it has been constructed to manage things like exception handling (see section above).
  • Define a testing strategy upfront: at Geoblink we test all available endpoints for each API version using e2e tests. In this way we can’t unexpectedly break prior versions when modifying or deleting an existing endpoint. These tests also serve as documentation for each version without having to spin up the API docs.
  • The API version in the decorated endpoint means: “from which API version this function will handle calls to this endpoint”. Note that deleting an endpoint is then not straightforward: the best approach we have found so far is to overwrite the endpoint with a path function that returns a deprecated warning and that is decorated with the version where the endpoint should disappear from our API. When at a later time we decide to clean up old versions we can also get rid of these deprecated endpoints.

Future

Our journey with FastAPI has just started: we plan to grow the internal adoption of the framework by using it in all new data APIs and we also have a couple of next steps in our backlog:

  • Create an internal cookiecutter of a FastAPI project to spin up data applications in no time, as we already did for Flask.
  • Contribute to fastapi-versioning a better exception handling mechanism so that the above hacks are no longer needed.

Final note

We’d like to thank the whole FastAPI community and, of course, its author tiangolo, for this amazing project! And also to all the contributors to the fastapi-versioning package :)

Resources

FastAPI

ML use-case

API versioning

Versioning on FastAPI:

Footnotes

[1] We’re aware that API versioning is a topic that always produces heated debates (e.g. https://stackoverflow.com/questions/389169/best-practices-for-api-versioning). We have adopted a simple version-in-URL approach based on the tools available for FastAPI, taking into account that 1) the APIs we talk about here are for internal use, i.e. we’re not constrained to maintaining old versions for a very long time as it’s Stripe’s case. 2) we want to maintain a single active codebase for the service.

--

--