Version Matters: Effective API Versioning In Django and FastAPI

The text discusses best practices for handling API versioning in Python, emphasizing the importance of maintaining consistent project structures and offers a specific approach for Django configuration using dynamic module imports.

a year ago   •   17 min read

By Christopher Miller
Table of contents

As we found out in my other article API versioning in Python:

No matter what type of API versioning we use in Python, we'll want to consider several different aspects of how we handle this in our codebases. We could be changing many different areas of code between versions:

Request Structure - What your user sends to you
Response Structure - What your user receives from you
Business Logic - What your API actually does
New Endpoints - Something new your user can call
Remove Endpoints - Taking Something away your user can no longer call
Deprecations / Sunsetting - Adding warnings to things

But how do we actually do that? Well, the first thing would be to set up our project structures:

Project Structure

The good news is - our project structure doesn't need to change from our original blog! We simply need to use the same one - here it is again to help you remember:

root/
│
├── app/
│   │
│   ├── v1/
│   │   │
│   │   ├── endpoints/
│   │   │
│   │   └── models/
│   │
│   ├── v2/
│   │   │
│   │   ├── endpoints/
│   │   │
│   │   └── models/
│   │
│   ├── endpoints/
│   │
│   └── models/
│
└── config/

Directory Tree For A Python API Project

Django Differences

Django requires a settings.py file to exist at the root of the app you're looking at (in our case, the app directory).

Here's a nifty little function to help you to have a separated config directory that you can globally use.

import os
import importlib

config_dir = os.path.dirname(__file__) + '/../config'

module_files = [file[:-3] for file in os.listdir(config_dir) if file.endswith('.py')]

for module_name in module_files:
    module = importlib.import_module(f'config.{module_name}')
    globals().update(vars(module))

settings.py

It looks kind of complicated at first, right? Well, here's the line-by-line explanation:

  1. import os: This line imports the os module, which provides a way to interact with the operating system, including file and directory operations.
  2. import importlib: This line imports the importlib module, which allows dynamic loading of Python modules.
  3. config_dir = os.path.dirname(__file__) + '/../config': Here, we define a variable named config_dir. This line gets the directory name of the current Python script using __file__ (which is a special variable in Python that represents the current script's filename) and then appends '/../config' to it. This is likely used to construct the path to a directory named 'config' located one level above the directory containing the current script.
  4. module_files = [file[:-3] for file in os.listdir(config_dir) if file.endswith('.py')]: This line does a few things. It lists all the files in the directory specified by config_dir using os.listdir(). Then, it filters this list to include only files with names ending in '.py' by checking if file.endswith('.py'). For each file in this filtered list, it removes the last three characters (the '.py' extension) using slicing (file[:-3]). The resulting list will contain the names of Python modules without the '.py' extension.
  5. for module_name in module_files:: This line starts a loop that iterates through each module_name in the module_files list. Each module_name represents the name of a Python module found in the 'config' directory.
  6. module = importlib.import_module(f'config.{module_name}'): Inside the loop, this line dynamically imports each module. It uses the import_module() function from the importlib module to import the module with the specified name. The f'config.{module_name}' construct generates the full module name by combining 'config' (the name of the package) and the module_name from the list.
  7. globals().update(vars(module)): This line updates the global namespace with the variables and functions defined in the imported module. It uses vars(module) to get a dictionary of all attributes defined in the module, and then globals().update() is used to merge these attributes into the global namespace, making them accessible as global variables in the current script.

Fetching The Right Model for Request And Response

If we look back at our first point of change earlier, it was the Request Structure, and the response structure - so how do we handle that when we can have one of three options:

  • It exists in an API Version directory
  • It exists in the base App directory
  • It doesn't exist at all

Well, that's where we start with some helper functionality this one is for FastAPI:

from typing import Type, Union, TypeVar, Optional
from importlib import import_module


class ModelNotFound(Exception):
    pass


T = TypeVar('T')


def get_model_version(api_version: str, model_name: str) -> Union[Type[T], None]:
    try:
        model_module = import_module(f'app.{api_version}.models.{model_name}')
        model_class = getattr(model_module, model_name)
        return model_class
    except ImportError:
        try:
            model_module = import_module(f'app.models.{model_name}')
            model_class = getattr(model_module, model_name)
            return model_class
        except ImportError:
            raise ModelNotFound(f"Model '{model_name}' not found in API version '{api_version}' or base version.")

Python Helper Function - Model Version

Now that's quite a complex function - so let's explain it step by step!

from typing import Type, Union, TypeVar, Optional: This line imports various type hinting classes from the typing module, which are used to annotate the types of function arguments and return values.

from importlib import import_module: Here, the import_module function is imported from the importlib module. This function is used to dynamically import modules in Python.

class ModelNotFound(Exception):: This line defines a custom exception class named ModelNotFound. This exception will be raised when the specified model is not found.

T = TypeVar('T'): This line defines a type variable T using Python's TypeVar. It is used to indicate that the function can return an instance of any class.

def get_model_version(api_version: str, model_name: str) -> Union[Type[T], None]:: This is the function's signature. It defines that the function is named get_model_version, takes two arguments (api_version and model_name), and returns a value that can be either a type (class) or None. The function is expected to return the model class based on the provided API version and model name.

try:: This begins a try block, indicating that the following code inside this block will be executed, and any exceptions raised within this block will be caught.

model_module = import_module(f'app.{api_version}.models.{model_name}'): In this line, the import_module function is used to dynamically import a Python module based on the provided api_version and model_name. It constructs a module path using f-strings, assuming a certain project structure where models are organized by API version.

model_class = getattr(model_module, model_name): Here, the getattr function is used to retrieve an attribute (in this case, a class) named model_name from the model_module. This assumes that each module named model_name contains a class with the same name.

return model_class: If the import and attribute retrieval are successful, the function returns the retrieved model_class.

except ImportError:: This is an exception handler that catches ImportError exceptions. If the import of the specified module fails (indicating that the model for the specified API version doesn't exist), the code inside this block will be executed.

try:: This begins another try block inside the exception handler. It is an attempt to import the model from the base version of the API.

model_module = import_module(f'app.models.{model_name}'): Similar to line 17, this line attempts to import the model from the base version of the API, assuming it's organized under app.models.

model_class = getattr(model_module, model_name): This line attempts to retrieve the model_name class from the base version of the API.

return model_class: If successful, this line returns the model_class from the base version.

except ImportError:: This is another exception handler inside the previous one. If both attempts to import the model fail (for the specified version and the base version), this block will be executed.

raise ModelNotFound(f"Model '{model_name}' not found in API version '{api_version}' or base version."): In this line, the custom ModelNotFound exception is raised with a message indicating that the specified model was not found in either the specified API version or the base version.

Here's the same functionality, but for Django:

from typing import Type, Union, TypeVar, Optional
from importlib import import_module


class ModelNotFound(Exception):
    pass


T = TypeVar('T')


def get_model_version(api_version: str, model_name: str) -> Union[Type[T], None]:
    """
    Get the specified model version, or fallback to the base version if the specified API version doesn't exist.

    Args:
        api_version (str): The API version (e.g., 'v1', 'v2').
        model_name (str): The name of the model.

    Returns:
        Union[Type[T], None]: The model class, or None if the model is not found.

    Raises:
        ModelNotFound: If the specified model version or the base version doesn't exist.
    """
    try:
        model_module = import_module(f'app.{api_version}.models.{model_name}')
        model_class = getattr(model_module, model_name)
        return model_class
    except ImportError:
        try:
            model_module = import_module(f'app.models.{model_name}')
            model_class = getattr(model_module, model_name)
            return model_class
        except ImportError:
            raise ModelNotFound(f"Model '{model_name}' not found in API version '{api_version}' or base version.")

So let's get that in place - in a utils directory.

Our First Route: GET /hello

Now we have our helper function, we need to build up our first route. the aim of this - unsuprisingly - is to return hello to our users. The problem is - we actually changed our mind after doing version 1 of our API six months ago, so we need to do version 2 as well to change the output string. There's 3 steps to this:

Generate A Model

The model is actually kind of easy here, it's a super-simple class for our output. In app\v1\models\HelloInputModel.py we're going to create our first version of the model:

from pydantic import BaseModel


class HelloOutputModel(BaseModel):
    message: str

app\v1\models\HelloOutputModel.py

Awesome! So now we have version one of our model, let's also create version 2 in app\v2\models\HelloInputModel.py

from pydantic import BaseModel


class HelloOutputModel(BaseModel):
    greeting: str

app\v2\models\HelloOutputModel.py

Subtle difference, right? yup! It's so subtle it's easy to miss! We changed the name of our return field from message to greeting!

And the same functionality for Django:

from rest_framework import serializers


class HelloOutputModel(serializers.Serializer):
    message = serializers.CharField(max_length=300)

app\v1\models\HelloOutputModel.py

from rest_framework import serializers


class HelloOutputModel(serializers.Serializer):
    greeting = serializers.CharField(max_length=300)

app\v2\models\HelloOutputModel.py

Great - that's model versioning sorted on our first route - and this time we have two options: V1 and V2 - later on we'll deal with a model that doesn't have that - and instead has just a base model

Generate A Route

The routing is basically the same thing - so I'll provide the two files, then we'll examine the difference.

from fastapi import APIRouter
from app.utils.main import get_model_version

router = APIRouter()


@router.get("/hello", response_model=get_model_version('v1', 'HelloOutputModel'))
async def say_hello():
    return {"message": "Hello"}

app\v1\endpoints\hello.py

from fastapi import APIRouter
from app.utils.main import get_model_version

router = APIRouter()


@router.get("/hello", response_model=get_model_version('v2', 'HelloOutputModel'))
async def say_hello():
    return {"greeting": "Hello"}

app\v2\endpoints\hello.py

That change is so tiny right now - v1 and v2 is the only difference there in our get model version function. So that is really really cool! Now let's link up the route for us.

So what about Django?

from app.utils.get_model_version import get_model_version
from rest_framework.response import Response
from rest_framework import status


def hello_get(self, request):
    data = {
        "message": "hello"
    }

    serializer = get_model_version('v1', 'HelloOutputModel')(data=data)

    if serializer.is_valid():
        return Response(serializer.data)
    else:
        return Response(serializer.errors, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

app\v1\endpoints\HelloGet.py

from app.utils.get_model_version import get_model_version
from rest_framework.response import Response
from rest_framework import status


def hello_get(self, request):
    data = {
        "greeting": "hello"
    }

    serializer = get_model_version('v2', 'HelloOutputModel')(data=data)

    if serializer.is_valid():
        return Response(serializer.data)
    else:
        return Response(serializer.errors, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

app\v2\endpoints\HelloGet.py

from rest_framework import viewsets
from rest_framework.routers import DefaultRouter
from rest_framework.decorators import action
from app.endpoints.base import BaseSet
from .HelloGet import hello_get


class HelloSet(viewsets.ViewSet):
    @action(detail=False, methods=['get', 'post'], url_path='hello')
    def hello(self, request):
        if request.method == 'GET':
            return hello_get(self, request)


v1_router = DefaultRouter(trailing_slash=False)
v1_router.register(r'', BaseSet, basename='base')
v1_router.register(r'', HelloSet, basename='v1')

app\v1\endpoints\v1.py

from rest_framework import viewsets
from rest_framework.routers import DefaultRouter
from rest_framework.decorators import action
from app.endpoints.base import BaseSet
from .HelloGet import hello_get


class HelloSet(viewsets.ViewSet):
    @action(detail=False, methods=['get'], url_path='hello')
    def hello(self, request):
        if request.method == 'GET':
            return hello_get(self, request)


v2_router = DefaultRouter(trailing_slash=False)
v2_router.register(r'', BaseSet, basename='base')
v2_router.register(r'', HelloSet, basename='v2')

app\v2\endpoints\v2.py

Linking up the routes to manage our API Versioning is pretty easy: we already have our hello router (described above) - so we need to modify our v1 main file:

from fastapi import APIRouter
from app.v1.endpoints import hello

router = APIRouter()

router.include_router(hello.router, tags=["v1"])

app/v1/main.py

and our v2 main file:

from fastapi import APIRouter
from app.v2.endpoints import hello


router = APIRouter()

router.include_router(hello.router, tags=["v2"])

app/v2/main.py

here's the django version of that:

from django.urls import path, include
from app.endpoints.base import base_router
from app.v1.endpoints.v1 import v1_router
from app.v2.endpoints.v2 import v2_router

urlpatterns = [
    path('', include(base_router.urls)),
    path('v1/', include(v1_router.urls)),
    path('v2/', include(v2_router.urls)),
]

app\urls.py

Putting The Pieces Together

So far, we have a route for v1 and v2 for different responses from a get route, we've linked them up to our main file, but we've not got our app running yet. Let's change that:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.v1.main import router as v1_router
from app.v2.main import router as v2_router
from config.settings import API_VERSIONS


app = FastAPI(
    title="My FastAPI Project",
    description="A FastAPI project with versioned APIs.",
    version="1.0.0",
)


app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["*"],
)

app.include_router(v1_router, prefix=f"/{API_VERSIONS[0]}", tags=[API_VERSIONS[0]])
app.include_router(v2_router, prefix=f"/{API_VERSIONS[1]}", tags=[API_VERSIONS[1]])


if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="0.0.0.0", port=8000)

main.py

Our route directory has a main.py file - and that's the one above. So let's take a look at what's happening here:

from fastapi import FastAPI: This line imports the FastAPI class from the fastapi module. FastAPI is a modern web framework for building APIs with Python.

from fastapi.middleware.cors import CORSMiddleware: Here, the CORSMiddleware class is imported from the fastapi.middleware.cors module. This middleware is used to enable Cross-Origin Resource Sharing (CORS) for the API.

from app.v1.main import router as v1_router: This line imports a router named v1_router from the app.v1.main module. It assumes that your project is organized into versions, and this line imports the router for version 1 of your API.

from app.v2.main import router as v2_router: Similar to the previous line, this line imports a router named v2_router from the app.v2.main module, representing version 2 of your API.

from config.settings import API_VERSIONS: Here, the API_VERSIONS variable is imported from the config.settings module. This variable likely contains a list of available API versions.

app = FastAPI(...): This line creates an instance of the FastAPI class and configures it with various parameters like title, description, and version. This instance will be used to define and run your API.

app.add_middleware(...) (lines 15-21): This block of code adds middleware to your FastAPI application. In this case, it adds the CORSMiddleware, which enables CORS support. CORS allows your API to be accessed from different origins (e.g., web browsers) by specifying which origins are allowed (allow_origins), which HTTP methods are allowed (allow_methods), and which headers are allowed (allow_headers). In this example, it allows all origins, all methods, and all headers, which might need to be adjusted for production use.

app.include_router(v1_router, prefix=f"/{API_VERSIONS[0]}", tags=[API_VERSIONS[0]]): This line includes the router v1_router under a specific prefix based on the first version defined in API_VERSIONS. It also assigns tags to this router, which can be used for organizing and documenting your API routes.

app.include_router(v2_router, prefix=f"/{API_VERSIONS[1]}", tags=[API_VERSIONS[1]]): Similarly, this line includes the v2_router under a prefix based on the second version defined in API_VERSIONS, and it assigns appropriate tags.

if __name__ == "__main__":: This conditional block checks whether the script is being run directly (as opposed to being imported as a module).

import uvicorn: It imports the uvicorn library, which is used for running ASGI web applications like FastAPI.

uvicorn.run(app, host="0.0.0.0", port=8000): Finally, this line starts the FastAPI application using uvicorn. It specifies the host and port on which the application will listen for incoming requests. In this case, it listens on all available network interfaces (0.0.0.0) and port 8000.

It references settings - so here's mine:

from typing import List

API_VERSIONS: List[str] = ["v1", "v2"]

DATABASE_URL: str = "sqlite:///./test.db"

JWT_SECRET_KEY: str = "your-secret-key"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRATION_TIME_MINUTES: int = 30

DEBUG: bool = True

config/settings.py

The important bit right now is the API_VERSIONS variable - a list of strings describing our API versions

Let's try running our app: python main.py

We get the following output:

INFO:     Started server process [69784]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

output of python main.py

So it's working. Let's go take a look at the docs generated by FastAPI to see what we have - head over to http://127.0.0.1:8000/docs in your browser to see them!

API Docs Generated by FastAPI

That's perfect! Our API Versioning in Python has been picked up! we have two api routes, available on /v1 and /v2, with two separate versions of our responses.

And heres the really fun part - in Django - we just need to define our routers, it does the rest for us!

But wait - there's more!

Now that we know how to get routes, let's dive into post routes, and a default route that applies to both setups. The code is below:

from fastapi import APIRouter
from app.v1.endpoints import hello
from app.endpoints import base

router = APIRouter()

router.include_router(hello.router, tags=["v1"])
router.include_router(base.router, tags=["v1"])

app\v1\main.py

from pydantic import BaseModel


class HelloInputModel(BaseModel):
    name: str

app\v1\models\HelloInputModel.py

from fastapi import APIRouter
from app.utils.main import get_model_version

router = APIRouter()


@router.get("/hello", response_model=get_model_version('v1', 'HelloOutputModel'))
async def say_hello():
    return {"message": "Hello"}


@router.post("/hello", response_model=get_model_version('v1', 'HelloOutputModel'))
async def say_hello_to_name(input_data: get_model_version('v1', 'HelloInputModel')):
    return {"message": f"Hello, {input_data.name}!"}

app\v1\endpoints\hello.py

from fastapi import APIRouter
from app.v2.endpoints import hello
from app.endpoints import base


router = APIRouter()

router.include_router(hello.router, tags=["v2"])
router.include_router(base.router, tags=["v2"])

app\v2\main.py

from pydantic import BaseModel


class HelloInputModel(BaseModel):
    user: str

app\v2\models\HelloInputModel.py

from fastapi import APIRouter
from app.v2.endpoints import hello
from app.endpoints import base


router = APIRouter()

router.include_router(hello.router, tags=["v2"])
router.include_router(base.router, tags=["v2"])

app\v2\main.py

from fastapi import APIRouter
from datetime import datetime
from app.utils.main import get_model_version

router = APIRouter()


@router.get("/ping", response_model=get_model_version('v1', 'TimestampReturnModel'))
async def say_hello():
    return {"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}

app\endpoints\base.py

from pydantic import BaseModel


class TimestampReturnModel(BaseModel):
    timestamp: str

app\models\TimestampReturnModel.py

So that's a lot of code, right? But it's actually a good exercise to get your head around it yourself. It should be relatively easy, as we're following our common principles of routing, and model versioning. The only difference is it's using the fallback for the model. The genius bit? You can just add a model for a version and it'll pick it straight up.

Let's go take a look at our docs again:

FastAPI Docs after adding our other routes

Django is fairly similar - here's the extra files:

from app.utils.get_model_version import get_model_version
from rest_framework.response import Response
from rest_framework import status


def hello_post(self, request):
    serializer = get_model_version('v1', 'HelloInputModel')(data=request.data)

    if serializer.is_valid():
        name = serializer.validated_data.get("name", "")
        message = f"Hello, {name}!"

        response_data = {"message": message}
        response_serializer = get_model_version('v1', 'HelloOutputModel')(data=response_data)

        if response_serializer.is_valid():
            return Response(response_serializer.validated_data)

    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

app\v1\endpoints\HelloPost.py

from rest_framework import viewsets
from rest_framework.routers import DefaultRouter
from rest_framework.decorators import action
from app.endpoints.base import BaseSet
from .HelloGet import hello_get
from .HelloPost import hello_post


class HelloSet(viewsets.ViewSet):
    @action(detail=False, methods=['get', 'post'], url_path='hello')
    def hello(self, request):
        if request.method == 'GET':
            return hello_get(self, request)
        elif request.method == 'POST':
            return hello_post(self, request)


v1_router = DefaultRouter(trailing_slash=False)
v1_router.register(r'', BaseSet, basename='base')
v1_router.register(r'', HelloSet, basename='v1')

app\v1\endpoints\v1.py

from rest_framework import serializers


class HelloInputModel(serializers.Serializer):
    name = serializers.CharField(max_length=255)

app\v1\models\HelloInputModel.py

from app.utils.get_model_version import get_model_version
from rest_framework.response import Response
from rest_framework import status


def hello_post(self, request):
    serializer = get_model_version('v2', 'HelloInputModel')(data=request.data)

    if serializer.is_valid():
        name = serializer.validated_data.get("user", "")
        message = f"Hello, {name}!"

        response_data = {"greeting": message}
        response_serializer = get_model_version('v2', 'HelloOutputModel')(data=response_data)

        if response_serializer.is_valid():
            return Response(response_serializer.validated_data)

    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

app\v2\endpoints\HelloPost.py

from rest_framework import viewsets
from rest_framework.routers import DefaultRouter
from rest_framework.decorators import action
from app.endpoints.base import BaseSet
from .HelloGet import hello_get
from .HelloPost import hello_post


class HelloSet(viewsets.ViewSet):
    @action(detail=False, methods=['get', 'post'], url_path='hello')
    def hello(self, request):
        if request.method == 'GET':
            return hello_get(self, request)
        elif request.method == 'POST':
            return hello_post(self, request)


v2_router = DefaultRouter(trailing_slash=False)
v2_router.register(r'', BaseSet, basename='base')
v2_router.register(r'', HelloSet, basename='v2')

app\v1\endpoints\v2.py

from rest_framework import serializers


class HelloOutputModel(serializers.Serializer):
    greeting = serializers.CharField(max_length=300)

app\v1\models\HelloOutputModel.py

So that handles us for URL-based API Versioning very easily, now let's add in our alternative options. But what if we don't want to use URL-based Versioning?

Well, we still have options here:

Query Parameter Versioning

Managing query parameter routing builds on what we already have - we add the option to use query parameter instead of the URL to route - and the system does the rest for us:

class QueryParameterVersioning:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        async def asgi(receive, send):
            api_version = scope.get('query_string').decode('utf-8')
            api_version = api_version.split("=")[1] if "api_version=" in api_version else None

            original_path = scope["path"]
            new_path = scope["path"]

            if api_version == "1":
                new_path = f"/v1{original_path}"
            elif api_version == "2":
                new_path = f"/v2{original_path}"

            scope["path"] = new_path

            await self.app(scope, receive, send)

        return await asgi(receive, send)

app/middleware/QueryParameterVersioning.py

Let's dive into this a bit:

class QueryParameterVersioning:
    def __init__(self, app):
        self.app = app

Here, you define a class QueryParameterVersioning that takes app as an argument during initialization. app is the FastAPI application instance to which this middleware will be added.

async def __call__(self, scope, receive, send):

The __call__ method is called for each incoming request. It takes three arguments:

  • scope: This is a dictionary containing information about the request.
  • receive: A function to receive data from the client.
  • send: A function to send data to the client.
api_version = scope.get('query_string').decode('utf-8')
api_version = api_version.split("=")[1] if "api_version=" in api_version else None

In this part, you extract the query string from the request's scope using scope.get('query_string'). Then, you decode it from bytes to a UTF-8 string. You check if the query parameter "api_version" is present, and if so, you split the string to get its value. If it's not present, api_version is set to None.

original_path = scope["path"]
new_path = scope["path"]

Here, you store the original path from the request's scope in original_path, and you create a new variable new_path initially set to the same value.

if api_version == "1":
    new_path = f"/v1{original_path}"
elif api_version == "2":
    new_path = f"/v2{original_path}"

Depending on the value of api_version, you modify new_path by prepending it with "/v1" or "/v2" while keeping the rest of the path (from original_path) intact.

scope["path"] = new_path

Finally, you update the scope["path"] with the new_path value. This effectively changes the path that subsequent parts of the FastAPI application will use for routing the request.

await self.app(scope, receive, send)

After modifying the scope, you call the next middleware or endpoint in the ASGI application using await self.app(scope, receive, send).

return await asgi(receive, send)

This line returns an await statement that calls the asgi function, which is defined earlier. This is necessary for the ASGI protocol to continue processing the request.

So that, friends, adds in our second option - API versioning just by adding in app.add_middleware(QueryParameterVersioning) to our main.py file at the root level.

But, that still leaves us with one more option: content type versioning!

Content Type Versioning

In a very similar way to query parameter versioning, we can actually manage content type versioning with a middleware:

class MediaTypeVersioning:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        async def asgi(receive, send):
            accept_header = scope.get('headers', {}).get(b'accept', b'').decode('utf-8')
            version = None

            if 'application/vnd.api.' in accept_header:
                version = accept_header.split('application/vnd.api.')[1].split('+json')[0]

            original_path = scope["path"]
            new_path = scope["path"]

            if version == "1":
                new_path = f"/v1{original_path}"
            elif version == "2":
                new_path = f"/v2{original_path}"

            scope["path"] = new_path

            await self.app(scope, receive, send)

        return await asgi(receive, send)

app/middleware/MediaTypeVersioning.py

So, there we have it - we split the header and make a change that way instead. Don't forget to include the middleware in the setup though! app.add_middleware(MediaTypeVersioning)

The good news - these middleware should work as is in Django - just add them to the middleware configuration.

There we have it, friends: URL Versioning, Query Parameter Versioning and Media Type Versioning all for your API in Python using FastApi or Django. If you're interested in reading the code I've created, feel free to head over to The Repository I Created to download the repo and play with the FastAPI version, or This Repository for the Django version.

Spread the word

Keep reading