Detect if route is not outermost / wrong “order” of decorators in Flask

Refresh

December 2018

Views

43 time

2

Since the @route decorator has to register the view with the current callback given to the decorator, it has to be the outermost decorator to receive the correct function to invoke when handling a request.

This creates a possible situation where a view has been decorated, but since the decorators are in the wrong order, the decorated function is not invoked. If used for decorating views that require the user to be logged in, have a certain role or have a specific flag, the check will be left out silently.

Our current fix is to have the standard action be to deny access to the resource, then requiring a decorator to allow access. In that case, if the decorator isn't invoked when the request is being handled, the request will fail.

But there are use-cases where this becomes cumbersome since it requires you to decorate all views, except for those few that should be exempt. For a pure hierarchical layout this may work, but for checking single flags the structure can get complicated.

Is there a proper way to detect that we're being invoked in a useful place in the decoratory hierarchy? I.e. can we detect that there hasn't already been a route decorator applied to function we get to wrap?

# wrapped in wrong order - @require_administrator should be after @app.route
@require_administrator
@app.route('/users', methods=['GET'])

Implemented as:

def require_administrator(func):
    @functools.wraps(func)
    def has_administrator(*args, **kwargs):
        if not getattr(g, 'user') or not g.user.is_administrator:
            abort(403)

        return func(*args, **kwargs)

    return has_administrator

Here I'd like to detect if my custom decorator is being wrapped after @app.route, and thus, never will be invoked when the request is handled.

Using functools.wraps replaces the wrapped function with the new one in all ways, so looking at __name__ of the function to be wrapped will fail. This also happens at each step of the decorator wrapping process.

I've tried looking at both traceback and inspect, but haven't found any decent way of determining if the sequence is correct.

Update

My currently best solution is to check the called function name against the set of registered endpoints. But since a Route() decorator can change the name of the endpoint, I'll have to support that for my decorator as well in that case, and it'll silently pass if a different function has used the same endpoint name as the current function.

It also have to iterate the set of registered endpoints since I weren't able to find a simple way to check if just the endpoint name exists (possibly more efficient by attempting to build an URL with it and catch the exception).

def require_administrator_checked(func):
    for rule in app.url_map.iter_rules():
        if func.__name__ == rule.endpoint:
            raise DecoratorOrderError(f"Wrapped endpoint '{rule.endpoint}' has already been registered - wrong order of decorators?")

    # as above ..

2 answers

1

Я добавляю еще один ответ, потому что теперь у меня есть то , что это наименьшее количество Hacky (читай: Я использую инспектировать читать исходный код данной функции вместо того , чтобы читать весь файл сам), работает через модули, и может быть повторно использовать для любых других декораторов , которые всегда должны быть последним. Вы также не должны использовать другой синтаксис , app.routeкак в обновлении моего другого ответа.

Вот как это сделать (Предупреждение: Это довольно затворных момента создания):

import flask
import inspect


class DecoratorOrderError(TypeError):
    pass


def assert_last_decorator(final_decorator):
    """
    Converts a decorator so that an exception is raised when it is not the last    decorator to be used on a function.
    This only works for decorator syntax, not if somebody explicitly uses the decorator, e.g.
    final_decorator = some_other_decorator(final_decorator) will still work without an exception.

    :param final_decorator: The decorator that should be made final.
    :return: The same decorator, but it checks that it is the last one before calling the inner function.
    """
    def check_decorator_order(func):
        # Use inspect to read the code of the function
        code, _ = inspect.getsourcelines(func)
        decorators = []
        for line in code:
            if line.startswith("@"):
                decorators.append(line)
            else:
                break

        # Remove the "@", function calls, and any object calls, such as "app.route". We just want the name of the decorator function (e.g. "route")
        decorator_names_only = [dec.replace("@", "").split("(")[0].split(".")[-1] for dec in decorators]
        is_final_decorator = [final_decorator.__name__ == name for name in decorator_names_only]
        num_finals = sum(is_final_decorator)

        if num_finals > 1 or (num_finals == 1 and not is_final_decorator[0]):
            raise DecoratorOrderError(f"'{final_decorator.__name__}' is not the topmost decorator of function '{func.__name__}'")

        return func

    def handle_arguments(*args, **kwargs):
        # Used to pass the arguments to the final decorator

        def handle_function(f):
            # Which function should be decorated by the final decorator?
            return final_decorator(*args, **kwargs)(check_decorator_order(f))

        return handle_function

    return handle_arguments

Теперь вы можете заменить app.routeфункцию с помощью этой функции, применительно к app.routeфункции. Это важно и должно быть сделано перед любым использованием app.routeдекоратора, так что я предлагаю , чтобы просто сделать это при создании приложения.

app = flask.Flask(__name__)
app.route = assert_last_decorator(app.route)


def require_administrator(func):
    @functools.wraps(func)
    def has_administrator(*args, **kwargs):
        print("Would check admin now")

        return func(*args, **kwargs)

    return has_administrator


@app.route("/good", methods=["GET"])  # Works
@require_administrator
def test_good():
    return "ok"

@require_administrator
@app.route("/bad", methods=["GET"])  # Raises an Exception
def test_bad():
    return "not ok"

Я считаю, что это довольно много, что вы хотите в вашем вопросе.

2

Обновление 2 : Смотрите другой мой ответ на более многоразовый, меньше рубить-у раствора.

Обновление : Вот явно меньше рубить-у решения. Тем не менее, он требует , чтобы использовать пользовательскую функцию вместо app.route. Он принимает произвольное число декораторов, и применяет их в заданном порядке, а затем убеждается , что app.route называют в качестве конечной функции. Для этого необходимо использовать только этот декоратор для каждой функции.

def safe_route(rule, app, *decorators, **options):
    def _route(func):
        for decorator in decorators:
            func = decorator(func)
        return app.route(rule, **options)(func)
    return _route

Вы можете использовать его как это:

def require_administrator(func):
    @functools.wraps(func)
    def has_administrator(*args, **kwargs):
        print("Would check admin now")

        return func(*args, **kwargs)

    return has_administrator

@safe_route("/", app, require_administrator, methods=["GET"])
def test2():
    return "foo"

test2()
print(test2.__name__)

Это печатает:

Would check admin now
foo
test2

Таким образом , если все поставленные декораторы используют functools.wraps, это также сохраняет test2имя.

Старый ответ : Если вы КИ с по общему признанию хака-у раствора, вы можете свернуть свой собственный осмотр, прочитав файл построчно. Вот очень грубая функция , которая делает это. Вы можете уточнить это совсем немного, например , на данный момент он опирается на приложение называют «приложение», определения функций , имеющих по крайней мере одну пустую строку перед ними (нормальный PEP-8 поведение, но все еще может быть проблемой), .. ,

Вот полный код, который я использовал, чтобы проверить его.

import flask
import functools
from itertools import groupby


class DecoratorOrderError(TypeError):
    pass


app = flask.Flask(__name__)


def require_administrator(func):
    @functools.wraps(func)
    def has_administrator(*args, **kwargs):
        print("Would check admin now")

        return func(*args, **kwargs)

    return has_administrator


@require_administrator  # Will raise a custom exception
@app.route("/", methods=["GET"])
def test():
    return "ok"


def check_route_is_topmost_decorator():
    # Read own source
    with open(__file__) as f:
        content = [line.strip() for line in f.readlines()]

    # Split source code on line breaks
    split_by_lines = [list(group) for k, group in groupby(content, lambda x: x == "") if not k]

    # Find consecutive decorators
    decorator_groups = dict()
    for line_group in split_by_lines:
        decorators = []
        for line in line_group:
            if line.startswith("@"):
                decorators.append(line)
            elif decorators:
                decorator_groups[line] = decorators
                break
            else:
                break

    # Check if app.route is the last one (if it exists)
    for func_def, decorators in decorator_groups.items():
        is_route = [dec.startswith("@app.route") for dec in decorators]
        if sum(is_route) > 1 or (sum(is_route) == 1 and not decorators[0].startswith("@app.route")):
            raise DecoratorOrderError(f"@app.route is not the topmost decorator for '{func_def}'")


check_route_is_topmost_decorator()

Этот фрагмент кода даст вам следующее сообщение об ошибке:

Traceback (most recent call last):
  File "/home/vXYZ/test_sso.py", line 51, in <module>
    check_route_is_topmost_decorator()
  File "/home/vXYZ/test_sso.py", line 48, in check_route_is_topmost_decorator
    raise DecoratorOrderError(f"@app.route is not the topmost decorator for '{func_def}'")
__main__.DecoratorOrderError: @app.route is not the topmost decorator for 'def test():'

Если изменить порядок декоратора для test()функции, он просто ничего не делает.

Одним из недостатков является то, что вы должны вызвать этот метод явно в каждом файле. Я не знаю точно, насколько надежно это, я признаю, что это очень некрасиво, и я не буду брать на себя ответственность, если она сломается, но это только начало! Я уверен, что должно быть лучше.