Search code examples
pythonswaggerfastapiswagger-uiopenapi

FastAPI: Swagger UI does not render because of custom Middleware


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.


Solution

  • 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")
    
    
    @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 to the response body only for routes that you have specified—which would solve the issue with Swaager UI in a more easy way.

    Alternatively, 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.