Skip to content

Commit

Permalink
Namespace.login_required decorator can be now applied to Resource cla…
Browse files Browse the repository at this point in the history
…sses
  • Loading branch information
frol committed Sep 27, 2016
1 parent fd5ab4c commit 8a7892c
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 25 deletions.
83 changes: 77 additions & 6 deletions app/extensions/api/namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,54 @@ def login_required(self, oauth_scopes):
minimal authorization level;
* All of the above requirements are put into OpenAPI Specification with
relevant options and in a text description.
Arguments:
oauth_scopes (list) - a list of required OAuth2 Scopes (strings)
Example:
>>> class Users(Resource):
... @namespace.login_required(oauth_scopes=['users:read'])
... def get(self):
... return []
...
>>> @namespace.login_required(oauth_scopes=['users:read'])
... class Users(Resource):
... def get(self):
... return []
...
... @namespace.login_required(oauth_scopes=['users:write'])
... def post(self):
... return User()
...
>>> @namespace.login_required(oauth_scopes=[])
... class Users(Resource):
... @namespace.login_required(oauth_scopes=['users:read'])
... def get(self):
... return []
...
... @namespace.login_required(oauth_scopes=['users:write'])
... def post(self):
... return User()
"""
def decorator(func):
def decorator(func_or_class):
"""
A helper wrapper.
"""
if isinstance(func_or_class, type):
# Handle Resource classes decoration
func_or_class._apply_decorator_to_methods(decorator)
return func_or_class
else:
func = func_or_class

# Avoid circilar dependency
from app.extensions import oauth2
from app.modules.users import permissions

# This way we will avoid unnecessary checks if the decorator is
# applied several times, e.g. when Resource class is decorated.
func.__latest_oauth_decorator_id__ = id(decorator)

# Automatically apply `permissions.ActivatedUserRolePermisson`
# guard if none is yet applied.
if getattr(func, '_role_permission_applied', False):
Expand All @@ -92,8 +131,40 @@ def decorator(func):
permissions.ActivatedUserRolePermission()
)(func)

oauth_protection_decorator = oauth2.require_oauth(*oauth_scopes)
self._register_access_restriction_decorator(func, oauth_protection_decorator)
# Accumulate OAuth2 scopes if @login_required decorator is applied
# several times
if hasattr(protected_func, '__apidoc__') \
and 'security' in protected_func.__apidoc__ \
and '__oauth__' in protected_func.__apidoc__['security']:
_oauth_scopes = (
oauth_scopes + protected_func.__apidoc__['security']['__oauth__']['scopes']
)
else:
_oauth_scopes = oauth_scopes

def oauth_protection_decorator(func):
"""
This helper decorator is necessary to be able to skip redundant
checks when Resource class is also decorated.
"""
oauth_protected_func = oauth2.require_oauth(*_oauth_scopes)(func)

@wraps(oauth_protected_func)
def wrapper(self, *args, **kwargs):
latest_oauth_decorator_id = getattr(
getattr(self, func.__name__),
'__latest_oauth_decorator_id__',
None
)
if id(decorator) == latest_oauth_decorator_id:
_func = oauth_protected_func
else:
_func = func
return _func(self, *args, **kwargs)

return wrapper

self._register_access_restriction_decorator(protected_func, oauth_protection_decorator)
oauth_protected_func = oauth_protection_decorator(protected_func)

return self.doc(
Expand All @@ -102,17 +173,17 @@ def decorator(func):
# `Api.add_namespace`.
'__oauth__': {
'type': 'oauth',
'scopes': oauth_scopes,
'scopes': _oauth_scopes,
}
}
)(
self.response(
code=http_exceptions.Unauthorized.code,
description=(
"Authentication is required"
if not oauth_scopes else
if not _oauth_scopes else
"Authentication with %s OAuth scope(s) is required" % (
', '.join(oauth_scopes)
', '.join(_oauth_scopes)
)
),
)(oauth_protected_func)
Expand Down
9 changes: 5 additions & 4 deletions app/modules/teams/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import logging

from flask_login import current_user
from flask_restplus import Resource
from flask_restplus_patched import Resource
from flask_restplus_patched._http import HTTPStatus

from app.extensions import db
Expand All @@ -27,12 +27,12 @@


@api.route('/')
@api.login_required(oauth_scopes=['teams:read'])
class Teams(Resource):
"""
Manipulations with teams.
"""

@api.login_required(oauth_scopes=['teams:read'])
@api.parameters(PaginationParameters())
@api.response(schemas.BaseTeamSchema(many=True))
def get(self, args):
Expand Down Expand Up @@ -64,6 +64,7 @@ def post(self, args):


@api.route('/<int:team_id>')
@api.login_required(oauth_scopes=['teams:read'])
@api.response(
code=http_exceptions.NotFound.code,
description="Team not found.",
Expand All @@ -73,7 +74,6 @@ class TeamByID(Resource):
Manipulations with a specific team.
"""

@api.login_required(oauth_scopes=['teams:read'])
@api.resolve_object_by_model(Team, 'team')
@api.permission_required(
permissions.OwnerRolePermission,
Expand Down Expand Up @@ -130,6 +130,7 @@ def delete(self, team):


@api.route('/<int:team_id>/members/')
@api.login_required(oauth_scopes=['teams:read'])
@api.response(
code=http_exceptions.NotFound.code,
description="Team not found.",
Expand All @@ -139,7 +140,6 @@ class TeamMembers(Resource):
Manipulations with members of a specific team.
"""

@api.login_required(oauth_scopes=['teams:read'])
@api.resolve_object_by_model(Team, 'team')
@api.permission_required(
permissions.OwnerRolePermission,
Expand Down Expand Up @@ -187,6 +187,7 @@ def post(self, args, team):


@api.route('/<int:team_id>/members/<int:user_id>')
@api.login_required(oauth_scopes=['teams:read'])
@api.response(
code=http_exceptions.NotFound.code,
description="Team or member not found.",
Expand Down
4 changes: 2 additions & 2 deletions app/modules/users/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import logging

from flask_login import current_user
from flask_restplus import Resource
from flask_restplus_patched import Resource

from app.extensions.api import Namespace, abort, http_exceptions
from app.extensions.api.parameters import PaginationParameters
Expand Down Expand Up @@ -73,6 +73,7 @@ def get(self):


@api.route('/<int:user_id>')
@api.login_required(oauth_scopes=['users:read'])
@api.response(
code=http_exceptions.NotFound.code,
description="User not found.",
Expand All @@ -82,7 +83,6 @@ class UserByID(Resource):
Manipulations with a specific user.
"""

@api.login_required(oauth_scopes=['users:read'])
@api.resolve_object_by_model(User, 'user')
@api.permission_required(
permissions.OwnerRolePermission,
Expand Down
35 changes: 22 additions & 13 deletions flask_restplus_patched/namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ def resolve_object(self, object_arg_name, resolver):
"""
def decorator(func_or_class):
if isinstance(func_or_class, type):
func_or_class.method_decorators = (
[decorator] + func_or_class.method_decorators
)
# Handle Resource classes decoration
# pylint: disable=protected-access
func_or_class._apply_decorator_to_methods(decorator)
return func_or_class

@wraps(func_or_class)
Expand Down Expand Up @@ -129,35 +129,44 @@ def response(self, model=None, code=200, description=None, **kwargs):
elif code == HTTPStatus.NO_CONTENT:
description = 'Request fulfilled, nothing follows'

def dump_response_with_model_decorator(func):
def response_serializer_decorator(func):
"""
This decorator handles responses to serialize the returned value
with a given model.
"""
def dump_wrapper(*args, **kwargs):
# pylint: disable=missing-docstring
response = func(*args, **kwargs)

if isinstance(response, flask.Response):
return response

elif response is None:
if code == HTTPStatus.NO_CONTENT:
return flask.Response(status=HTTPStatus.NO_CONTENT, content_type='application/json')
return flask.Response(
status=HTTPStatus.NO_CONTENT,
content_type='application/json'
)
raise ValueError("Reponse must not be empty with code 200")

return model.dump(response).data

return dump_wrapper

def decorator(func_or_class):
if code in http_exceptions.default_exceptions:
# If the code is handled by raising an exception, it will
# produce a response later, so we don't need to apply a dump
# produce a response later, so we don't need to apply a useless
# wrapper.
decorated_func_or_class = func_or_class
elif isinstance(func_or_class, type):
# Make a copy of `method_decorators` as otherwise we will
# modify the behaviour of all flask-restful.Resource-based
# classes
func_or_class.method_decorators = (
[dump_response_with_model_decorator] + func_or_class.method_decorators
)
# Handle Resource classes decoration
# pylint: disable=protected-access
func_or_class._apply_decorator_to_methods(response_serializer_decorator)
decorated_func_or_class = func_or_class
else:
decorated_func_or_class = wraps(func_or_class)(
dump_response_with_model_decorator(func_or_class)
response_serializer_decorator(func_or_class)
)

if code == HTTPStatus.NO_CONTENT:
Expand Down
17 changes: 17 additions & 0 deletions flask_restplus_patched/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ class Resource(OriginalResource):
def __init__(self, *args, **kwargs):
super(Resource, self).__init__(*args, **kwargs)

@classmethod
def _apply_decorator_to_methods(cls, decorator):
"""
This helper can apply a given decorator to all methods on the current
Resource.
NOTE: In contrast to ``Resource.method_decorators``, which has a
similar use-case, this method applies decorators directly and override
methods in-place, while the decorators listed in
``Resource.method_decorators`` are applied on every request which is
quite a waste of resources.
"""
for method in cls.methods:
method_name = method.lower()
decorated_method_func = decorator(getattr(cls, method_name))
setattr(cls, method_name, decorated_method_func)

def options(self, *args, **kwargs):
"""
Implementation of universal OPTIONS method for resources
Expand Down

0 comments on commit 8a7892c

Please sign in to comment.