1

So, I have created a Custom Middleware for my big FastAPI Application, which alters responses from all of my endpoints this way:

Response model is different for all APIs. However, my MDW adds meta data to all of these responses, in an uniform manner. This is what the final response object looks like:

{
    "data": <ANY RESPONSE MODEL THAT ALL THOSE ENDPOINTS ARE SENDING>,
    "meta_data": 
    {
        "meta_data_1": "meta_value_1",
        "meta_data_2": "meta_value_2",
        "meta_data_3": "meta_value_3",
    }
}

So essentially, all original responses, are wrapped inside a data field, a new field of meta_data is added with all meta_data. This meta_data model is uniform, it will always be of this type:

    "meta_data": 
    {
        "meta_data_1": "meta_value_1",
        "meta_data_2": "meta_value_2",
        "meta_data_3": "meta_value_3",
    }

Now the problem is, when the swagger loads up, it shows the original response model in schema and not the final response model which has been prepared. How to alter swagger to reflect this correctly? I have tried this:

# This model is common to all endpoints!
# Since we are going to add this for all responses 
class MetaDataModel(BaseModel):
    meta_data_1: str
    meta_data_2: str
    meta_data_3: str

class FinalResponseForEndPoint1(BaseModel):
    data: OriginalResponseForEndpoint1
    meta_data: MetaDataModel

class FinalResponseForEndPoint2(BaseModel):
    data: OriginalResponseForEndpoint2
    meta_data: MetaDataModel

and so on ...

This approach does render the Swagger perfectly, but there are 2 major problems associated with it:

  • All my FastAPI endpoints break and give me an error when they are returning response. For example: my endpoint1 is still returning the original response but the endpoint1 expects it to send response adhering to FinalResponseForEndPoint1 model
  • Doing this approach for all models for all my endpoints, does not seem like the right way
Here is a minimal reproducible example with my custom middleware:
from starlette.types import ASGIApp, Receive, Scope, Send, Message
from starlette.requests import Request
import json
from starlette.datastructures import MutableHeaders
from fastapi import FastAPI


class MetaDataAdderMiddleware:
    application_generic_urls = ['/openapi.json', '/docs', '/docs/oauth2-redirect', '/redoc']
    
    def __init__(
            self, 
            app: ASGIApp
    ) -> None:
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] == "http" and not any([scope["path"].startswith(endpoint) for endpoint in MetaDataAdderMiddleware.application_generic_urls]):
            responder = MetaDataAdderMiddlewareResponder(self.app, self.standard_meta_data, self.additional_custom_information)
            await responder(scope, receive, send)
            return
        await self.app(scope, receive, send)


class MetaDataAdderMiddlewareResponder:

    def __init__(
            self,
            app: ASGIApp,
    ) -> None:
        """
        """
        self.app = app
        self.initial_message: Message = {}

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        self.send = send
        await self.app(scope, receive, self.send_with_meta_response)

    async def send_with_meta_response(self, message: Message):

        message_type = message["type"]
        if message_type == "http.response.start":
            # Don't send the initial message until we've determined how to
            # modify the outgoing headers correctly.
            self.initial_message = message

        elif message_type == "http.response.body":
            response_body = json.loads(message["body"].decode())

            data = {}
            data["data"] = response_body
            data['metadata'] = {
                'field_1': 'value_1',
                'field_2': 'value_2'
            }

            data_to_be_sent_to_user = json.dumps(data, default=str).encode("utf-8")

            headers = MutableHeaders(raw=self.initial_message["headers"])
            headers["Content-Length"] = str(len(data_to_be_sent_to_user))
            message["body"] = data_to_be_sent_to_user

            await self.send(self.initial_message)
            await self.send(message)


app = FastAPI(
    title="MY DUMMY APP",
)

app.add_middleware(MetaDataAdderMiddleware)

@app.get("/")
async def root():
    return {"message": "Hello World"}
raghavsikaria
  • 867
  • 17
  • 30

1 Answers1

0

If you add default values to the additional fields you can have the middleware update those fields as opposed to creating them.

SO:

from ast import Str
from starlette.types import ASGIApp, Receive, Scope, Send, Message
from starlette.requests import Request
import json
from starlette.datastructures import MutableHeaders
from fastapi import FastAPI
from pydantic import BaseModel, Field

# This model is common to all endpoints!
# Since we are going to add this for all responses


class MetaDataModel(BaseModel):
    meta_data_1: str
    meta_data_2: str
    meta_data_3: str


class ResponseForEndPoint1(BaseModel):
    data: str
    meta_data: MetaDataModel | None = Field(None, nullable=True)


class MetaDataAdderMiddleware:
    application_generic_urls = ['/openapi.json',
                                '/docs', '/docs/oauth2-redirect', '/redoc']

    def __init__(
            self,
            app: ASGIApp
    ) -> None:
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] == "http" and not any([scope["path"].startswith(endpoint) for endpoint in MetaDataAdderMiddleware.application_generic_urls]):
            responder = MetaDataAdderMiddlewareResponder(
                self.app)
            await responder(scope, receive, send)
            return
        await self.app(scope, receive, send)


class MetaDataAdderMiddlewareResponder:

    def __init__(
            self,
            app: ASGIApp,
    ) -> None:
        """
        """
        self.app = app
        self.initial_message: Message = {}

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        self.send = send
        await self.app(scope, receive, self.send_with_meta_response)

    async def send_with_meta_response(self, message: Message):

        message_type = message["type"]
        if message_type == "http.response.start":
            # Don't send the initial message until we've determined how to
            # modify the outgoing headers correctly.
            self.initial_message = message

        elif message_type == "http.response.body":
            response_body = json.loads(message["body"].decode())

            response_body['meta_data'] = {
                'field_1': 'value_1',
                'field_2': 'value_2'
            }

            data_to_be_sent_to_user = json.dumps(
                response_body, default=str).encode("utf-8")

            headers = MutableHeaders(raw=self.initial_message["headers"])
            headers["Content-Length"] = str(len(data_to_be_sent_to_user))
            message["body"] = data_to_be_sent_to_user

            await self.send(self.initial_message)
            await self.send(message)


app = FastAPI(
    title="MY DUMMY APP",
)

app.add_middleware(MetaDataAdderMiddleware)


@app.get("/", response_model=ResponseForEndPoint1)
async def root():
    return ResponseForEndPoint1(data='hello world')

I don't think this is a good solution - but it doesn't throw errors and it does show the correct output in swagger.

In general I'm struggling to find a good way to document the changes/ additional responses that middleware can introduce in openAI/swagger. If you've found anything else I'd be keen to hear it!