FastAPI: Swagger UI does not render because of custom Middleware

Question:

So I have a custom middleware like this:

Its objective is to add some meta_data fields to every response from all endpoints of my FastAPI app.


@app.middelware("http")
async def add_metadata_to_response_payload(request: Request, call_next):

    response = await call_next(request)

    body = b""
    async for chunk in response.body_iterator:
        body+=chunk


    data = {}
    data["data"] = json.loads(body.decode())
    data["metadata"] = {
        "some_data_key_1": "some_data_value_1",
        "some_data_key_2": "some_data_value_2",
        "some_data_key_3": "some_data_value_3"
    }

    body = json.dumps(data, indent=2, default=str).encode("utf-8")

    return Response(
        content=body,
        status_code=response.status_code,
        media_type=response.media_type
    )

However, when I served my app using uvicorn, and launched the swagger URL, here is what I see:


Unable to render this definition

The provided definition does not specify a valid version field.

Please indicate a valid Swagger or OpenAPI version field. Supported version fields are
Swagger: "2.0" and those that match openapi: 3.0.n (for example, openapi: 3.0.0)

With a lot of debugging, I found that this error was due to the custom middleware and specifically this line:

body = json.dumps(data, indent=2, default=str).encode("utf-8")

If I simply comment out this line, swagger renders just fine for me. However, I need this line for passing the content argument in Response from Middleware. How to sort this out?

UPDATE:

I tried the following:
body = json.dumps(data, indent=2).encode("utf-8")
by removing default arg, the swagger did successfully load. But now when I hit any of the APIs, here is what swagger tells me along with response payload on screen:
Unrecognised response type; displaying content as text

More Updates (6th April 2022):

Got a solution to fix 1 part of the problem by Chris, but the swagger wasn’t still loading. The code was hung up in the middleware level indefinitely and the page was not still loading.

So, I found in all these places:

that this way of adding custom middleware works by inheriting from BaseHTTPMiddleware in Starlette and has its own issues (something to do with awaiting inside middleware, streamingresponse and normal response, and the way it is called). I don’t understand it yet.

Asked By: raghavsikaria

||

Answers:

You are substituting the body of the swagger html with json data taken from both middleware and response (html response in this case).

You’ll end up with something like

{
    "data": "<html>....</html>",
    "metadata": {
        "some_data_key_1": "some_data_value_1",
        "some_data_key_2": "some_data_value_2",
        "some_data_key_3": "some_data_value_3"
    }
}

Of course this won’t work.

Possible Solution

Perform a check on the content type of the response in the middleware. Extend the response if it json, otherwise leave it as it is.

Note:
This can only be done if it can be safely assumed that every json response needs the metadata to be added, while html content type doesn’t. (you can change the check according to your needs)

Another possible solution

Wait for the following issue to be merged into the current starlettes implementation and fastapi to start using this version.

https://github.com/tiangolo/fastapi/issues/1174
https://github.com/encode/starlette/pull/1286

Answered By: lsabi

Here’s how you could do that (inspired by this). Make sure to check the Content-Type of the response (as shown below), so that you can modify it by adding the metadata, only if it is of application/json type.

For the OpenAPI (Swagger UI) to render (both /docs and /redoc), make sure to check whether openapi key is not present in the response, so that you can proceed modifying the response only in that case. If you happen to have a key with such a name in your response data, then you could have additional checks using further keys that are present in the response for the OpenAPI, e.g., info, version, paths, and, if needed, you can check against their values too.

from fastapi import FastAPI, Request, Response
import json

app = FastAPI()

@app.middleware("http")
async def add_metadata_to_response_payload(request: Request, call_next):
    response = await call_next(request)
    content_type = response.headers.get('Content-Type')
    if content_type == "application/json":
        response_body = [section async for section in response.body_iterator]
        resp_str = response_body[0].decode()  # converts "response_body" bytes into string
        resp_dict = json.loads(resp_str)  # converts resp_str into dict 
        #print(resp_dict)
        if "openapi" not in resp_dict:
            data = {}
            data["data"] = resp_dict  # adds the "resp_dict" to the "data" dictionary
            data["metadata"] = {
                "some_data_key_1": "some_data_value_1",
                "some_data_key_2": "some_data_value_2",
                "some_data_key_3": "some_data_value_3"}
            resp_str = json.dumps(data, indent=2)  # converts dict into JSON string
        
        return Response(content=resp_str, status_code=response.status_code, media_type=response.media_type)
        
    return response


@app.get("/")
def foo(request: Request):
    return {"hello": "world!"}

Update 1

Alternatively, a likely better approach would be to check for the request’s url path at the start of the middleware function (against a pre-defined list of paths/routes that you would like to add metadata to their responses), and proceed accordingly. Example is given below.

from fastapi import FastAPI, Request, Response, Query
from pydantic import constr
from fastapi.responses import JSONResponse
import re
import uvicorn
import json

app = FastAPI()
routes_with_middleware = ["/"]
rx = re.compile(r'^(/items/d+|/courses/[a-zA-Z0-9]+)$')  # support routes with path parameters
my_constr = constr(regex="^[a-zA-Z0-9]+$")

@app.middleware("http")
async def add_metadata_to_response_payload(request: Request, call_next):
    response = await call_next(request)
    if request.url.path not in routes_with_middleware and not rx.match(request.url.path):
        return response
    else:
        content_type = response.headers.get('Content-Type')
        if content_type == "application/json":
            response_body = [section async for section in response.body_iterator]
            resp_str = response_body[0].decode()  # converts "response_body" bytes into string
            resp_dict = json.loads(resp_str)  # converts resp_str into dict 
            data = {}
            data["data"] = resp_dict  # adds "resp_dict" to the "data" dictionary
            data["metadata"] = {
                "some_data_key_1": "some_data_value_1",
                "some_data_key_2": "some_data_value_2",
                "some_data_key_3": "some_data_value_3"}
            resp_str = json.dumps(data, indent=2)  # converts dict into JSON string
            return Response(content=resp_str, status_code=response.status_code, media_type="application/json")

    return response

@app.get("/")
def root():
    return {"hello": "world!"}

@app.get("/items/{id}")
def get_item(id: int):
    return {"Item": id}

@app.get("/courses/{code}")
def get_course(code: my_constr):
    return {"course_code": code, "course_title": "Deep Learning"}

Update 2

Another solution would be to use a custom APIRoute class, as demonstrated here and here, which would allow you to apply the changes on the response body only to routes that you specify—which would solve the issue with Swaager UI in a more easy way.

You could still use the middleware option if you wish, but instead of adding the middleware to the main app, you could add it to a sub application—as shown in this answer and this answer—that includes again only the routes for which you need to modify the response in order to add some additional data in the body.

Answered By: Chris
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.