litestar icon indicating copy to clipboard operation
litestar copied to clipboard

Feature: Support for middleware parameter specification

Open peterschutt opened this issue 2 years ago • 12 comments

Thinking about ways to make parameter specification and parsing / validation available to the middleware stack.

Perhaps something like:

@runtime_checkable
class MiddlewareProtocol(Protocol):
    parameters: dict[str, tuple] = {"middleware_required_header": (int, Parameter(header="required-header"))}

    def __init__(self, app: ASGIApp):  # pragma: no cover
        ...

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:  # pragma: no cover
        # this obv. not actually defined in the protocol, but just for demo
        if self.validated_paraeters.get("middleware_required_header") != "13":
            ...
        else:
            ...
    
    @property
    def validated_parameters(self) -> dict[str, Any]:
        # this mapping actually constructed dynamically
        return {"middleware_required_header": 13}

Lets discuss:)

peterschutt avatar Jun 28 '22 11:06 peterschutt

Any thoughts on how you'd like this implemented @Goldziher

peterschutt avatar Jun 29 '22 20:06 peterschutt

So, there is a problem with defining concrete attributes on protocols - pydantic doesn't play nice with this.

How about adding a container type for Middleware that allows defining stuff like parameters, descriptions etc?

Otherwise we might need to use an abstract class instead of protocol.

Goldziher avatar Jun 29 '22 20:06 Goldziher

So, there is a problem with defining concrete attributes on protocols - pydantic doesn't play nice with this.

OK cool, Also there is the nature of a protocol being that it's just an interface, I.e, shouldn't need to extend from the base to fulfil the protocol.

How about adding a container type for Middleware that allows defining stuff like parameters, descriptions etc?

Can you give example of how this looks?

Otherwise we might need to use an abstract class instead of protocol.

This is intuitive to me, but we shouldn't need to do away with the protocol altogether, we could just have the base class that implements the protocol that users could extend for their custom middleware that allows us to handle parameter validation/parsing/docs for the parameters required by the middleware?

peterschutt avatar Jun 29 '22 21:06 peterschutt

So, as a container I'd try to do something like this probably:

from pydantic import validate_arguments
from starlite.params import Parameter
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware import Middleware as StarletteMiddleware

class DefinedMiddleware:
    @validate_arguments(config={"arbitrary_types_allowed": True})
    def __init__(
            self,
            middleware: Union[StarletteMiddleware, Type[BaseHTTPMiddleware], Type[MiddlewareProtocol]],
            parameters: Optional[Dict[str, Tuple[Any, Parameter]]] = None,
            description: Optional[str] = None,
            tags: Optional[List[str]] = None,
            response_headers: Optional[Dict[str, ResponseHeader]] = None,
    ):
        self.middleware = middleware
        self.parameters = parameters
        self.description = description
        self.tags = tags
        self.response_headers = response_headers

It will wrap the middleware, which is required, and then receive ooptional values for parameters, description, tags and response_headers. Im not 100% sure about description here, but the rest of the values do make sense to me: basically we will use the parameters to do validation and perhaps also calculate the required kwargs including the middleware component. We wil also use them to generate the OpenAPI schema, and this is where the other components (tags, response_headers etc.) come in.

Also, we can create a function that creates the DefinedMiddleware, or just give it a different name, to make more appealing to users.

Goldziher avatar Jun 30 '22 06:06 Goldziher

So, as a container I'd try to do something like this probably:

from pydantic import validate_arguments
from starlite.params import Parameter
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware import Middleware as StarletteMiddleware

class DefinedMiddleware:
    @validate_arguments(config={"arbitrary_types_allowed": True})
    def __init__(
            self,
            middleware: Union[StarletteMiddleware, Type[BaseHTTPMiddleware], Type[MiddlewareProtocol]],
            parameters: Optional[Dict[str, Tuple[Any, Parameter]]] = None,
            description: Optional[str] = None,
            tags: Optional[List[str]] = None,
            response_headers: Optional[Dict[str, ResponseHeader]] = None,
    ):
        self.middleware = middleware
        self.parameters = parameters
        self.description = description
        self.tags = tags
        self.response_headers = response_headers

It will wrap the middleware, which is required, and then receive ooptional values for parameters, description, tags and response_headers. Im not 100% sure about description here, but the rest of the values do make sense to me: basically we will use the parameters to do validation and perhaps also calculate the required kwargs including the middleware component. We wil also use them to generate the OpenAPI schema, and this is where the other components (tags, response_headers etc.) come in.

Also, we can create a function that creates the DefinedMiddleware, or just give it a different name, to make more appealing to users.

I see how this will give us info about the parameters for documentation, and an avenue to set other docs properties, but what about having access to the parsed/validated values inside the middleware?

This is where I think having a concrete base class in addition to the protocol might be a good idea. For example,


class StarliteBaseMiddleware(MiddlewareProtocol, Generic[T_model]):
    parameter_model: type[T_model]

    @property
    def parameters(self) -> T_model:
        ...
    ...

... so the parameters could be accessed in __call__() and we can handle the client error for validation.

peterschutt avatar Jul 05 '22 05:07 peterschutt

So, the question is why would we need to access this?

Goldziher avatar Jul 05 '22 06:07 Goldziher

Why would we want validated access to parameters inside a middleware? It just seems natural if we are bookkeeping all the types anyway that we could offer an interface to the validated values.

The wrapper approach would be better if using existing/3rd party middlewares as it would provide a way to tell us what the supplied middleware requires for generating the docs.

The base-class approach would be better for custom built middleware as validated and parsed parameter values would be available to the logic inside the middleware, info for docs would be available, and also the boilerplate required to check that a header or query param or whatever exists and is valid would be abstracted away.

peterschutt avatar Jul 06 '22 07:07 peterschutt

But why would we need a base class for this? We can simply use a wrapper as I suggested and use the existing stuff. The base class is not required for this. I dont understand what using a base class gives us here.

Goldziher avatar Jul 06 '22 08:07 Goldziher

OK say that I want build middleware that logs some info drawn from a header and it should be an error if a client is missing that header value (I haven't run this code at all btw so might not actually run):

class MyRequestLoggingMiddleware(MiddlewareProtocol):
    def __init__(self, app: ASGIApp):
        super().__init__(app)
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] == "http":
            headers = Headers(scope)
            calling_app = headers.get("caller")
            if calling_app is None:
                raise ValidationException
            try:
                calling_app_uuid = UUID(calling_app)
            except ValueError:
                raise ValidationException
            request = Request(scope)
            logger.info("%s - %s - %s", request.method, request.url, calling_app_uuid)
        await self.app(scope, receive, send)

This could be:

class HeaderModel(BaseModel):
    caller: UUID

class MyRequestLoggingMiddleware(StarliteBaseMiddleware[HeaderModel]):
    param_model = HeaderModel

    def __init__(self, app: ASGIApp):
        super().__init__(app)
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] == "http":
            request = Request(scope)
            logger.info("%s - %s - %s", request.method, request.url, self.parameters.caller)
        await self.app(scope, receive, send)

I don't understand how wrapping a custom middlware in a container class after it is written can assist the logic inside the class itself like the base class approach does.

peterschutt avatar Jul 06 '22 09:07 peterschutt

I see what you mean now. You want some way to extend typing info regarding parameters to be useful for Middleware. I'm not sure how viable this is tbh. We need to try it out and run some tests etc.

Goldziher avatar Jul 06 '22 09:07 Goldziher

Yes, a way that could provide type info for the middleware required params for documenting (I think that was what the original convo in discord was about) and so just exploring doing it in a way that could provide runtime support for the middleware logic too. Not a hill I'm willing to die on though.

peterschutt avatar Jul 06 '22 09:07 peterschutt

I like the approach of the MiddlewareProtocol

odiseo0 avatar Jul 06 '22 17:07 odiseo0

I think we can close this - layered parameters do the trick for us.

Goldziher avatar Aug 20 '22 10:08 Goldziher