'FastAPI swagger 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:
- https://github.com/encode/starlette/issues/919
- Blocked code while using middleware and dependency injections to log requests in FastAPI(Python)
- https://github.com/tiangolo/fastapi/issues/394
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 1:[1]
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.
Update 1
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("/")
async def foo(request: Request):
return {"hello": "world!"}
Update 2
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:
routes_with_middleware = ["/"]
@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:
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
Working Example
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("/")
async def root():
return {"hello": "world!"}
@app.get("/items/{id}")
async def get_item(id: int):
return {"Item": id}
@app.get("/courses/{code}")
async def get_course(code: my_constr):
return {"course_code": code, "course_title": "Deep Learning"}
Solution 2:[2]
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
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | |
| Solution 2 | lsabi |
