From b4b175e0a8bb3844c1c6f846c1b4eb1970520864 Mon Sep 17 00:00:00 2001 From: Cédric Bonhomme Date: Thu, 14 Apr 2016 00:02:47 +0200 Subject: testing a new API --- src/web/views/api/v2/__init__.py | 3 + src/web/views/api/v2/article.py | 53 ++++++++++ src/web/views/api/v2/category.py | 27 +++++ src/web/views/api/v2/common.py | 218 +++++++++++++++++++++++++++++++++++++++ src/web/views/api/v2/feed.py | 49 +++++++++ 5 files changed, 350 insertions(+) create mode 100644 src/web/views/api/v2/__init__.py create mode 100644 src/web/views/api/v2/article.py create mode 100644 src/web/views/api/v2/category.py create mode 100644 src/web/views/api/v2/common.py create mode 100644 src/web/views/api/v2/feed.py (limited to 'src/web/views/api/v2') diff --git a/src/web/views/api/v2/__init__.py b/src/web/views/api/v2/__init__.py new file mode 100644 index 00000000..46760261 --- /dev/null +++ b/src/web/views/api/v2/__init__.py @@ -0,0 +1,3 @@ +from web.views.api.v2 import article, feed, category + +__all__ = ['article', 'feed', 'category'] diff --git a/src/web/views/api/v2/article.py b/src/web/views/api/v2/article.py new file mode 100644 index 00000000..71201538 --- /dev/null +++ b/src/web/views/api/v2/article.py @@ -0,0 +1,53 @@ +from conf import API_ROOT +import dateutil.parser +from datetime import datetime +from flask import current_app +from flask.ext.restful import Api + +from web.views.common import api_permission +from web.controllers import ArticleController +from web.views.api.v2.common import (PyAggAbstractResource, + PyAggResourceNew, PyAggResourceExisting, PyAggResourceMulti) + + +class ArticleNewAPI(PyAggResourceNew): + controller_cls = ArticleController + + +class ArticleAPI(PyAggResourceExisting): + controller_cls = ArticleController + + +class ArticlesAPI(PyAggResourceMulti): + controller_cls = ArticleController + + +class ArticlesChallenge(PyAggAbstractResource): + controller_cls = ArticleController + attrs = {'ids': {'type': list, 'default': []}} + + @api_permission.require(http_exception=403) + def get(self): + parsed_args = self.reqparse_args(right='read') + # collecting all attrs for casting purpose + attrs = self.controller_cls._get_attrs_desc('admin') + for id_dict in parsed_args['ids']: + keys_to_ignore = [] + for key in id_dict: + if key not in attrs: + keys_to_ignore.append(key) + if issubclass(attrs[key]['type'], datetime): + id_dict[key] = dateutil.parser.parse(id_dict[key]) + for key in keys_to_ignore: + del id_dict[key] + + result = list(self.controller.challenge(parsed_args['ids'])) + return result or None, 200 if result else 204 + +api = Api(current_app, prefix=API_ROOT) + +api.add_resource(ArticleNewAPI, '/article', endpoint='article_new.json') +api.add_resource(ArticleAPI, '/article/', endpoint='article.json') +api.add_resource(ArticlesAPI, '/articles', endpoint='articles.json') +api.add_resource(ArticlesChallenge, '/articles/challenge', + endpoint='articles_challenge.json') diff --git a/src/web/views/api/v2/category.py b/src/web/views/api/v2/category.py new file mode 100644 index 00000000..21459fc5 --- /dev/null +++ b/src/web/views/api/v2/category.py @@ -0,0 +1,27 @@ +from conf import API_ROOT +from flask import current_app +from flask.ext.restful import Api + +from web.controllers.category import CategoryController +from web.views.api.v2.common import (PyAggResourceNew, + PyAggResourceExisting, + PyAggResourceMulti) + + +class CategoryNewAPI(PyAggResourceNew): + controller_cls = CategoryController + + +class CategoryAPI(PyAggResourceExisting): + controller_cls = CategoryController + + +class CategoriesAPI(PyAggResourceMulti): + controller_cls = CategoryController + + +api = Api(current_app, prefix=API_ROOT) +api.add_resource(CategoryNewAPI, '/category', endpoint='category_new.json') +api.add_resource(CategoryAPI, '/category/', + endpoint='category.json') +api.add_resource(CategoriesAPI, '/categories', endpoint='categories.json') diff --git a/src/web/views/api/v2/common.py b/src/web/views/api/v2/common.py new file mode 100644 index 00000000..ace6ba3a --- /dev/null +++ b/src/web/views/api/v2/common.py @@ -0,0 +1,218 @@ +"""For a given resources, classes in the module intend to create the following +routes : + GET resource/ + -> to retrieve one + POST resource + -> to create one + PUT resource/ + -> to update one + DELETE resource/ + -> to delete one + + GET resources + -> to retrieve several + POST resources + -> to create several + PUT resources + -> to update several + DELETE resources + -> to delete several +""" +import logging +from functools import wraps +from werkzeug.exceptions import Unauthorized, BadRequest, Forbidden, NotFound +from flask import request +from flask.ext.restful import Resource, reqparse +from flask.ext.login import current_user + +from web.views.common import admin_permission, api_permission, \ + login_user_bundle, jsonify +from web.controllers import UserController + +logger = logging.getLogger(__name__) + + +def authenticate(func): + @wraps(func) + def wrapper(*args, **kwargs): + if request.authorization: + ucontr = UserController() + try: + user = ucontr.get(login=request.authorization.username) + except NotFound: + raise Forbidden("Couldn't authenticate your user") + if not ucontr.check_password(user, request.authorization.password): + raise Forbidden("Couldn't authenticate your user") + if not user.is_active: + raise Forbidden("User is desactivated") + login_user_bundle(user) + if current_user.is_authenticated: + return func(*args, **kwargs) + raise Unauthorized() + return wrapper + + +class PyAggAbstractResource(Resource): + method_decorators = [authenticate, jsonify] + controller_cls = None + attrs = None + + @property + def controller(self): + if admin_permission.can(): + return self.controller_cls() + return self.controller_cls(current_user.id) + + def reqparse_args(self, right, req=None, strict=False, default=True, + allow_empty=False): + """ + strict: bool + if True will throw 400 error if args are defined and not in request + default: bool + if True, won't return defaults + args: dict + the args to parse, if None, self.attrs will be used + """ + try: + in_values = req.json if req else (request.json or {}) + if not in_values and allow_empty: + return {} + except BadRequest: + if allow_empty: + return {} + raise + parser = reqparse.RequestParser() + if self.attrs is not None: + attrs = self.attrs + elif admin_permission.can(): + attrs = self.controller_cls._get_attrs_desc('admin') + elif api_permission.can(): + attrs = self.controller_cls._get_attrs_desc('api', right) + else: + attrs = self.controller_cls._get_attrs_desc('base', right) + assert attrs, "No defined attrs for %s" % self.__class__.__name__ + + for attr_name, attr in attrs.items(): + if not default and attr_name not in in_values: + continue + else: + parser.add_argument(attr_name, location='json', **attr) + return parser.parse_args(req=req, strict=strict) + + +class PyAggResourceNew(PyAggAbstractResource): + + @api_permission.require(http_exception=403) + def post(self): + """Create a single new object""" + return self.controller.create(**self.reqparse_args(right='write')), 201 + + +class PyAggResourceExisting(PyAggAbstractResource): + + def get(self, obj_id=None): + """Retreive a single object""" + return self.controller.get(id=obj_id) + + def put(self, obj_id=None): + """update an object, new attrs should be passed in the payload""" + args = self.reqparse_args(right='write', default=False) + if not args: + raise BadRequest() + return self.controller.update({'id': obj_id}, args), 200 + + def delete(self, obj_id=None): + """delete a object""" + self.controller.delete(obj_id) + return None, 204 + + +class PyAggResourceMulti(PyAggAbstractResource): + + def get(self): + """retrieve several objects. filters can be set in the payload on the + different fields of the object, and a limit can be set in there as well + """ + try: + limit = request.json.pop('limit', 10) + order_by = request.json.pop('order_by', None) + args = self.reqparse_args(right='read', default=False) + except BadRequest: + limit, order_by, args = 10, None, {} + query = self.controller.read(**args) + if order_by: + query = query.order_by(order_by) + if limit: + query = query.limit(limit) + return [res for res in query] + + @api_permission.require(http_exception=403) + def post(self): + """creating several objects. payload should be: + >>> payload + [{attr1: val1, attr2: val2}, {attr1: val1, attr2: val2}] + """ + assert 'application/json' in request.headers.get('Content-Type') + status, fail_count, results = 200, 0, [] + + class Proxy: + pass + for attrs in request.json: + try: + Proxy.json = attrs + args = self.reqparse_args('write', req=Proxy, default=False) + obj = self.controller.create(**args) + results.append(obj) + except Exception as error: + fail_count += 1 + results.append(str(error)) + if fail_count == len(results): # all failed => 500 + status = 500 + elif fail_count: # some failed => 206 + status = 206 + return results, status + + def put(self): + """updating several objects. payload should be: + >>> payload + [[obj_id1, {attr1: val1, attr2: val2}] + [obj_id2, {attr1: val1, attr2: val2}]] + """ + assert 'application/json' in request.headers.get('Content-Type') + status, results = 200, [] + + class Proxy: + pass + for obj_id, attrs in request.json: + try: + Proxy.json = attrs + args = self.reqparse_args('write', req=Proxy, default=False) + result = self.controller.update({'id': obj_id}, args) + if result: + results.append('ok') + else: + results.append('nok') + except Exception as error: + results.append(str(error)) + if results.count('ok') == 0: # all failed => 500 + status = 500 + elif results.count('ok') != len(results): # some failed => 206 + status = 206 + return results, status + + def delete(self): + """will delete several objects, + a list of their ids should be in the payload""" + assert 'application/json' in request.headers.get('Content-Type') + status, results = 204, [] + for obj_id in request.json: + try: + self.controller.delete(obj_id) + results.append('ok') + except Exception as error: + status = 206 + results.append(error) + # if no operation succeded, it's not partial anymore, returning err 500 + if status == 206 and results.count('ok') == 0: + status = 500 + return results, status diff --git a/src/web/views/api/v2/feed.py b/src/web/views/api/v2/feed.py new file mode 100644 index 00000000..686dcd76 --- /dev/null +++ b/src/web/views/api/v2/feed.py @@ -0,0 +1,49 @@ +from conf import API_ROOT +from flask import current_app +from flask.ext.restful import Api + +from web.views.common import api_permission +from web.controllers.feed import (FeedController, + DEFAULT_MAX_ERROR, + DEFAULT_LIMIT, + DEFAULT_REFRESH_RATE) + +from web.views.api.v2.common import PyAggAbstractResource, \ + PyAggResourceNew, \ + PyAggResourceExisting, \ + PyAggResourceMulti + + +class FeedNewAPI(PyAggResourceNew): + controller_cls = FeedController + + +class FeedAPI(PyAggResourceExisting): + controller_cls = FeedController + + +class FeedsAPI(PyAggResourceMulti): + controller_cls = FeedController + + +class FetchableFeedAPI(PyAggAbstractResource): + controller_cls = FeedController + attrs = {'max_error': {'type': int, 'default': DEFAULT_MAX_ERROR}, + 'limit': {'type': int, 'default': DEFAULT_LIMIT}, + 'refresh_rate': {'type': int, 'default': DEFAULT_REFRESH_RATE}} + + @api_permission.require(http_exception=403) + def get(self): + args = self.reqparse_args(right='read', allow_empty=True) + result = [feed for feed + in self.controller.list_fetchable(**args)] + return result or None, 200 if result else 204 + + +api = Api(current_app, prefix=API_ROOT) + +api.add_resource(FeedNewAPI, '/feed', endpoint='feed_new.json') +api.add_resource(FeedAPI, '/feed/', endpoint='feed.json') +api.add_resource(FeedsAPI, '/feeds', endpoint='feeds.json') +api.add_resource(FetchableFeedAPI, '/feeds/fetchable', + endpoint='fetchable_feed.json') -- cgit