falconry / falcon

The no-magic web data plane API and microservices framework for Python developers, with a focus on reliability, correctness, and performance at scale.
https://falcon.readthedocs.io/en/stable/
Apache License 2.0
9.49k stars 933 forks source link

Express-style middlewares #1760

Open sisp opened 4 years ago

sisp commented 4 years ago

This issue summarizes a discussion with @CaselIT and @vytas7 at https://gitter.im/falconry/dev on August 10 about adding an Express-style (see also chi and Negroni in Golang) API for Falcon middlewares. This issue is related to several others:

This is how it started:

I've been wondering about middleware design in Falcon. Many web frameworks implement middlewares like Express which is a simple yet flexible and effective approach. Each middleware can implement some request processing logic before the next() call and some response processing logic after the next() call. If a middleware wants to abort the request, it can simply not call next(). With this simple approach, many standard middlewares such as logging, authentication etc. could be used as global, resource-specific, and individual responder middlewares without modification. With Express-style middlewares, error handlers would also become obsolete because an error handling middleware could wrap next() with a try-except block to catch all (or some) exceptions and translate them into appropriate HTTP responses. I argue that such a design is easier to implement and more transparent to developers using Falcon. The API surface would also become smaller which makes Falcon easier to adopt.

The main topics of discussion were about:

Next steps should include concrete examples of what a new middleware API could look like in Falcon.

open-collective-bot[bot] commented 4 years ago

Hi :wave:,

Thanks for using Falcon. The large amount of time and effort needed to maintain the project and develop new features is not sustainable without the generous financial support of community members like you.

Please consider helping us secure the future of the Falcon framework with a one-time or recurring donation.

Thank you for your support!

vytas7 commented 4 years ago

Hi @sisp , and thank you for reaching out to us on Gitter!

Could you add some concrete examples how the proposed changes would translate to Falcon (and Python)? (Thinking not everyone is necessarily familiar with the mentioned Golang and Node.js frameworks; neither do concepts always map exactly between different languages).

sisp commented 4 years ago

Sorry for the delay.

This is an example inspired by go-chi:

def custom_middleware_factory(some_config, ...):
    # notice that `next` and `handle` have the same signature
    def middleware(next):
        def handle(req, resp):
            # do something before the request is handled, e.g. authenticate user
            # the request object can be modified here as well, e.g. add context information

            # call the next middleware
            # can be omitted if the request should be aborted
            next(req, resp)

            # do something after the request has been handled, e.g. set a response header
        return handle
    return middleware

I'd avoid raising HTTP exceptions for creating error responses like raise HTTPUnauthorized() altogether and rather set the proper response directly. Exceptions should be handled by the user and converted to proper HTTP responses. If an unexpected exception occurs, it should be handled by a "recovery" middleware, which catches any uncaught exceptions, returns HTTP 500, and prevents the process from exiting. If you need to maintain a compatibility bridge, I guess each middleware call could be wrapped in a try/except block to convert HTTP exceptions to error responses, but I still think it's too much "magic" at the cost of transparency.

A few more (simplified) examples:

# access log middleware without configuration
def access_log_middleware(next):
    def handle(req, resp):
        time_start = time.time()
        next(req, resp)
        time_end = time.time()
        logger.info('{0} {1} {2} - {3}sec'.format(req.method, req.relative_uri, resp.status[:3], time_end - time_start))
    return handle

# basic auth middleware with fixed credentials and custom error handler/serializer
def basic_auth_middleware(username, password, error_handler):
    def middleware(next):
        def handle(req, resp):
            token = req.get_header('Authorization')
            try:
                credentials = decode_token(token)
            except BasicAuthDecodeError as exc:
                # create custom error response according to the user's desired format
                error_handler(req, resp, exc)
            else:
                if credentials['username'] != username or credentials['password] != password:
                    error_handler(req, resp, BasicAuthInvalidCredentialsError())
                else:
                    req.context['auth'] = {'username': username, 'password': password}
                    next(req, resp)
        return handle
    return middleware

# recovery middleware
def recovery_middleware(next):
    def handle(req, resp):
        try:
            next(req, resp)
        except Exception as exc:
            # log exception
            logger.error('internal error: {}'.format(exc))
            # write error response
            # a custom error handler/serializer like in the `basic_auth_middleware` could also be used here
            resp.status = falcon.HTTP_500
            resp.body = 'An unexpected error occurred'
    return handle

Then, a middleware stack can be added to an API:

def basic_auth_error_handler(req, resp, exc):
    resp.status = falcon.HTTP_403
    resp.body = str(exc)  # or JSON or ...

api = falcon.API(middlewares=[
    access_log_middleware,
    recovery_middleware,
    basic_auth_middleware('admin', 's3cr3t', lambda req, basic_auth_error_handler)
])

The above middleware concept uses higher-order functions. One level of nested functions could be removed by moving the next argument to the handle function:

def custom_middleware_factory(some_config, ...):
    def handle(req, resp, next):
        # do something before the request is handled, e.g. authenticate user
        # the request object can be modified here as well, e.g. add context information

        # call the next middleware
        # can be omitted if the request should be aborted
        next(req, resp)

        # do something after the request has been handled, e.g. set a response header
    return handle

The downside of this approach is that handle and next don't have the same signature anymore.

This higher-order function concept can also be implemented using classes:

class CustomMiddleware:
    def __init__(self, some_config, ...):
        self._some_config = some_config
        # ...

    def handle(self, req, resp, next):
        # do something before the request is handled, e.g. authenticate user
        # the request object can be modified here as well, e.g. add context information

        # call the next middleware
        # can be omitted if the request should be aborted
        next(req, resp)

        # do something after the request has been handled, e.g. set a response header

Still, what makes, e.g., go-chi so powerful and composable is the fact that request handlers, middlewares, and routers all implement the standard Go http.Handler interface. This way, routers can be composed and sub-routers and individual request handlers can have their own middlewares as needed (like hooks, but with support for dependency injection - something I consider extremely important and totally missing in the design of most other Python web frameworks like Flask). Resource-oriented design could be a convenience API on top of this highly flexible foundation.

Opinions?

CaselIT commented 4 years ago

Thanks for the examples!

Express.js is very similar, but IIRC next does different things depending on the number of arguments (or what are the argument). Does next returns something in go-chi? I don't remember if it did in express.js.

sisp commented 4 years ago

In go-chi, next is of type http.Handler:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

When in my example next(req, resp) is called, in go-chi next.ServeHTTP(w, r.withContext(ctx)) is called and it doesn't return anything. See an example in the go-chi README.