Sanic: RFC: pytest style yield middleware for sanic

Created on 15 Nov 2017  路  4Comments  路  Source: sanic-org/sanic

Wanted to bounce this idea off the sanic community. In an internal code base, we heavily use sanic's request/response middlewares. While great for pre/post-processing responses and filtering requests, some limitations with the split middleware design that we experienced are:

  • Duplication of common context loading code, e.g. loading auth info in middlewares
  • Not always immediately clear as to what the previous / next middleware could be doing
  • Chances of doubly mutating objects in context

We ended up writing an extended yield_middleware decorator that use async generators introduced in python-3.6 that accepts async generator functions which can contain both request and response middleware code in the same function.

If this is something others in community believe can be a useful pattern, happy to make a PR with implementation / tests.

Example:

from sanic.response import text

from ..utils.custom_classes import Blueprint

yield_middleware_test: Blueprint = Blueprint(
    'yield_middleware_test', url_prefix="/yield_test")

@yield_middleware_test.yield_middleware
async def log_state(request):

    print(f"enter middleware, request: {request}")

    response = yield

    print(f"enter middleware, response: {response}")


@yield_middleware_test.get("/")
async def yield_test(request):

    print("route handler")
    return text("hi")

Sample implementation code:

import inspect

from typing import AsyncGenerator, Awaitable, Callable, Dict, Union
from uuid import uuid4 as uuid

from sanic import Blueprint as SanicBlueprint


AsyncOrGen = Union[AsyncGenerator, Awaitable]


class Blueprint(SanicBlueprint):

    _yield_middleware_generators: Dict[str, AsyncOrGen] = {}

    def yield_middleware(self, fn: Callable) -> Callable:

        is_asyncgen_fn: bool = inspect.isasyncgenfunction(fn)

        assert is_asyncgen_fn or inspect.isawaitable(fn), \
            f"Passed object neither async nor asyncgen function. Invalid {fn}"

        fn_id: str = uuid()
        register_req_middleware: Callable = self.middleware("request")
        register_res_middleware: Callable = self.middleware("response")

        if not is_asyncgen_fn:
            return register_req_middleware(fn)

        faux_req_middleware: Callable = self._make_req_middleware(fn, fn_id)
        register_req_middleware(faux_req_middleware)

        faux_res_middleware: Callable = self._make_res_middleware(fn_id)
        register_res_middleware(faux_res_middleware)

    def _make_req_middleware(self, fn: Callable, fn_id: str) -> Callable:

        middleware_map: dict = self._yield_middleware_generators
        assert not middleware_map.get(fn_id), \
            f"UUID collision for {fn_id} : {fn}"

        async def faux_req_middleware(request):

            middleware_gen: Generator = fn(request)
            middleware_map[fn_id] = middleware_gen

            return await middleware_gen.asend(None)

        return faux_req_middleware

    def _make_res_middleware(self, fn_id: str) -> Callable:

        middleware_map: dict = self._yield_middleware_generators

        async def faux_res_middleware(request, response):

            # Get and remove gen
            middleware_gen: Generator = middleware_map.pop(fn_id, None)

            if not middleware_gen:
                return

            # Exhaust generator
            result = None

            while True:
                try:
                    result = await middleware_gen.asend(response)

                except StopAsyncIteration:
                    break

            return result

        return faux_res_middleware
stale

Most helpful comment

Of course @r0fls

Currently:

@bp.middleware("request")
async def auth(request):

    user = await get_user_from_db(request.form.get("user_id"))
    if not user:
        return text("Unauthorized", 401)

    logging.log("Auth succeded")

@bp.middleware("response")
async def wrap_token(request, response):

    user = await get_user_from_db(request.form.get("user_id"))

    return html(f"""
        <input type="hidden" id="some-user-token" value="{user.token}">
        {response.body}
    """)

With this change:

@bp.yield_middleware
async def wrap_token(request):

    user = await get_user_from_db(request.form.get("user_id"))
    if not user:
        return text("Unauthorized", 401)

    logging.log("Auth succeded")

    response = yield        # Request handler fired here

    return html(f"""
        <input type="hidden" id="some-user-token" value="{user.token}">
        {response.body}
    """)

That saves the extra db lookup but more importantly it is immediately clear how the entire middleware logic manipulates the request and response in the same function. Happy to give an even more elaborate example but I felt that this captures the essence.

All 4 comments

Looks cool! Can you show a slightly more practical use case, and how it would be performed now vs with your update? Or if what you're doing is simply not possible now, then skip that part... 馃槃

Of course @r0fls

Currently:

@bp.middleware("request")
async def auth(request):

    user = await get_user_from_db(request.form.get("user_id"))
    if not user:
        return text("Unauthorized", 401)

    logging.log("Auth succeded")

@bp.middleware("response")
async def wrap_token(request, response):

    user = await get_user_from_db(request.form.get("user_id"))

    return html(f"""
        <input type="hidden" id="some-user-token" value="{user.token}">
        {response.body}
    """)

With this change:

@bp.yield_middleware
async def wrap_token(request):

    user = await get_user_from_db(request.form.get("user_id"))
    if not user:
        return text("Unauthorized", 401)

    logging.log("Auth succeded")

    response = yield        # Request handler fired here

    return html(f"""
        <input type="hidden" id="some-user-token" value="{user.token}">
        {response.body}
    """)

That saves the extra db lookup but more importantly it is immediately clear how the entire middleware logic manipulates the request and response in the same function. Happy to give an even more elaborate example but I felt that this captures the essence.

@r0fls any update?

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. If this is incorrect, please respond with an update. Thank you for your contributions.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

rainyear picture rainyear  路  3Comments

olalonde picture olalonde  路  3Comments

tonyliuatmobius picture tonyliuatmobius  路  4Comments

eseglem picture eseglem  路  4Comments

ZeeRoc picture ZeeRoc  路  3Comments