From b2618e9404b84cc62d4becb02436233a0d53b375 Mon Sep 17 00:00:00 2001 From: Cédric Bonhomme Date: Wed, 25 Nov 2015 22:45:43 +0100 Subject: Rfactorization. Just a start... --- web/views/api/__init__.py | 31 ++++++ web/views/api/article.py | 63 ++++++++++++ web/views/api/common.py | 245 ++++++++++++++++++++++++++++++++++++++++++++++ web/views/api/feed.py | 69 +++++++++++++ 4 files changed, 408 insertions(+) create mode 100644 web/views/api/__init__.py create mode 100644 web/views/api/article.py create mode 100644 web/views/api/common.py create mode 100644 web/views/api/feed.py (limited to 'web/views/api') diff --git a/web/views/api/__init__.py b/web/views/api/__init__.py new file mode 100644 index 00000000..24472ebe --- /dev/null +++ b/web/views/api/__init__.py @@ -0,0 +1,31 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# jarr - A Web based news aggregator. +# Copyright (C) 2010-2015 Cédric Bonhomme - http://JARR-aggregator.org/ +# +# For more information : https://github.com/JARR-aggregator/JARR/ +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +__author__ = "Cedric Bonhomme" +__version__ = "$Revision: 0.2 $" +__date__ = "$Date: 2014/06/18 $" +__revision__ = "$Date: 2014/07/05 $" +__copyright__ = "Copyright (c) Cedric Bonhomme" +__license__ = "AGPLv3" + +from web.views.api import article, feed + +__all__ = ['article', 'feed'] diff --git a/web/views/api/article.py b/web/views/api/article.py new file mode 100644 index 00000000..51844b20 --- /dev/null +++ b/web/views/api/article.py @@ -0,0 +1,63 @@ +#! /usr/bin/env python +# -*- coding: utf-8 - + +from flask import g +import dateutil.parser + +from web.controllers import ArticleController +from web.views.api.common import PyAggAbstractResource,\ + PyAggResourceNew, \ + PyAggResourceExisting, \ + PyAggResourceMulti + + +ARTICLE_ATTRS = {'user_id': {'type': int}, + 'feed_id': {'type': int}, + 'entry_id': {'type': str}, + 'link': {'type': str}, + 'title': {'type': str}, + 'readed': {'type': bool}, 'like': {'type': bool}, + 'content': {'type': str}, + 'date': {'type': str}, 'retrieved_date': {'type': str}} + + +class ArticleNewAPI(PyAggResourceNew): + controller_cls = ArticleController + attrs = ARTICLE_ATTRS + to_date = ['date', 'retrieved_date'] + + +class ArticleAPI(PyAggResourceExisting): + controller_cls = ArticleController + attrs = ARTICLE_ATTRS + to_date = ['date', 'retrieved_date'] + + +class ArticlesAPI(PyAggResourceMulti): + controller_cls = ArticleController + attrs = ARTICLE_ATTRS + to_date = ['date', 'retrieved_date'] + + +class ArticlesChallenge(PyAggAbstractResource): + controller_cls = ArticleController + attrs = {'ids': {'type': list, 'default': []}} + to_date = ['date', 'retrieved_date'] + + def get(self): + parsed_args = self.reqparse_args() + for id_dict in parsed_args['ids']: + for key in self.to_date: + if key in id_dict: + id_dict[key] = dateutil.parser.parse(id_dict[key]) + + result = list(self.wider_controller.challenge(parsed_args['ids'])) + return result or None, 200 if result else 204 + + +g.api.add_resource(ArticleNewAPI, '/article', endpoint='article_new.json') +g.api.add_resource(ArticleAPI, '/article/', + endpoint='article.json') +g.api.add_resource(ArticlesAPI, '/articles', endpoint='articles.json') +g.api.add_resource(ArticlesChallenge, '/articles/challenge', + endpoint='articles_challenge.json') diff --git a/web/views/api/common.py b/web/views/api/common.py new file mode 100644 index 00000000..3476cad9 --- /dev/null +++ b/web/views/api/common.py @@ -0,0 +1,245 @@ +#! /usr/bin/env python +# -*- coding: utf-8 - + +"""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 ast +import json +import logging +import dateutil.parser +from functools import wraps +from werkzeug.exceptions import Unauthorized, BadRequest +from flask import request, g, session, Response +from flask.ext.restful import Resource, reqparse + +from web.lib.utils import default_handler +from web.models import User + +logger = logging.getLogger(__name__) + + +def authenticate(func): + """ + Decorator for the authentication to the web services. + """ + @wraps(func) + def wrapper(*args, **kwargs): + logged_in = False + if not getattr(func, 'authenticated', True): + logged_in = True + # authentication based on the session (already logged on the site) + elif 'email' in session or g.user.is_authenticated: + logged_in = True + else: + # authentication via HTTP only + auth = request.authorization + if auth is not None: + user = User.query.filter( + User.nickname == auth.username).first() + if user and user.check_password(auth.password) \ + and user.activation_key == "": + g.user = user + logged_in = True + if logged_in: + return func(*args, **kwargs) + raise Unauthorized({'WWWAuthenticate': 'Basic realm="Login Required"'}) + return wrapper + + +def to_response(func): + """Will cast results of func as a result, and try to extract + a status_code for the Response object""" + def wrapper(*args, **kwargs): + status_code = 200 + result = func(*args, **kwargs) + if isinstance(result, Response): + return result + elif isinstance(result, tuple): + result, status_code = result + return Response(json.dumps(result, default=default_handler), + status=status_code) + return wrapper + + +class PyAggAbstractResource(Resource): + method_decorators = [authenticate, to_response] + attrs = {} + to_date = [] # list of fields to cast to datetime + + def __init__(self, *args, **kwargs): + super(PyAggAbstractResource, self).__init__(*args, **kwargs) + + @property + def controller(self): + return self.controller_cls(getattr(g.user, 'id', None)) + + @property + def wider_controller(self): + if g.user.is_admin(): + return self.controller_cls() + return self.controller_cls(getattr(g.user, 'id', None)) + + def reqparse_args(self, req=None, strict=False, default=True, args=None): + """ + 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 + """ + parser = reqparse.RequestParser() + for attr_name, attrs in (args or self.attrs).items(): + if attrs.pop('force_default', False): + parser.add_argument(attr_name, location='json', **attrs) + elif not default and (not request.json + or request.json and attr_name not in request.json): + continue + else: + parser.add_argument(attr_name, location='json', **attrs) + parsed = parser.parse_args(strict=strict) if req is None \ + else parser.parse_args(req, strict=strict) + for field in self.to_date: + if parsed.get(field): + try: + parsed[field] = dateutil.parser.parse(parsed[field]) + except Exception: + logger.exception('failed to parse %r', parsed[field]) + return parsed + + +class PyAggResourceNew(PyAggAbstractResource): + + def post(self): + """Create a single new object""" + return self.controller.create(**self.reqparse_args()), 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(default=False) + new_values = {key: args[key] for key in + set(args).intersection(self.attrs)} + if 'user_id' in new_values and g.user.is_admin(): + controller = self.wider_controller + else: + controller = self.controller + return controller.update({'id': obj_id}, new_values), 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) + query = self.controller.read(**request.json) + except: + args = {} + for k, v in request.args.items(): + if k in self.attrs.keys(): + if self.attrs[k]['type'] in [bool, int]: + args[k] = ast.literal_eval(v) + else: + args[k] = v + limit = request.args.get('limit', 10) + order_by = request.args.get('order_by', 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] + + def post(self): + """creating several objects. payload should be a list of dict. + """ + if 'application/json' not in request.headers.get('Content-Type'): + raise BadRequest("Content-Type must be application/json") + status = 201 + results = [] + for attrs in request.json: + try: + results.append(self.controller.create(**attrs).id) + except Exception as error: + status = 206 + results.append(str(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 + + def put(self): + """creating several objects. payload should be: + >>> payload + [[obj_id1, {attr1: val1, attr2: val2}] + [obj_id2, {attr1: val1, attr2: val2}]] + """ + if 'application/json' not in request.headers.get('Content-Type'): + raise BadRequest("Content-Type must be application/json") + status = 200 + results = [] + for obj_id, attrs in request.json: + try: + new_values = {key: attrs[key] for key in + set(attrs).intersection(self.attrs)} + self.controller.update({'id': obj_id}, new_values) + results.append('ok') + except Exception as error: + status = 206 + results.append(str(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 + + def delete(self): + """will delete several objects, + a list of their ids should be in the payload""" + if 'application/json' not in request.headers.get('Content-Type'): + raise BadRequest("Content-Type must be application/json") + status = 204 + results = [] + 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/web/views/api/feed.py b/web/views/api/feed.py new file mode 100644 index 00000000..2bb9814f --- /dev/null +++ b/web/views/api/feed.py @@ -0,0 +1,69 @@ +#! /usr/bin/env python +# -*- coding: utf-8 - + +from flask import g + +from web.controllers.feed import (FeedController, + DEFAULT_MAX_ERROR, + DEFAULT_LIMIT, + DEFAULT_REFRESH_RATE) + +from web.views.api.common import PyAggAbstractResource, \ + PyAggResourceNew, \ + PyAggResourceExisting, \ + PyAggResourceMulti + +FEED_ATTRS = {'title': {'type': str}, + 'description': {'type': str}, + 'link': {'type': str}, + 'user_id': {'type': int}, + 'site_link': {'type': str}, + 'enabled': {'type': bool, 'default': True}, + 'etag': {'type': str, 'default': ''}, + 'icon_url': {'type': str, 'default': ''}, + 'last_modified': {'type': str}, + 'last_retrieved': {'type': str}, + 'last_error': {'type': str}, + 'error_count': {'type': int, 'default': 0}} + +class FeedNewAPI(PyAggResourceNew): + controller_cls = FeedController + attrs = FEED_ATTRS + to_date = ['date', 'last_retrieved'] + +class FeedAPI(PyAggResourceExisting): + controller_cls = FeedController + attrs = FEED_ATTRS + to_date = ['date', 'last_retrieved'] + +class FeedsAPI(PyAggResourceMulti): + controller_cls = FeedController + attrs = FEED_ATTRS + to_date = ['date', 'last_retrieved'] + +class FetchableFeedAPI(PyAggAbstractResource): + controller_cls = FeedController + to_date = ['date', 'last_retrieved'] + attrs = {'max_error': {'type': int, 'default': DEFAULT_MAX_ERROR}, + 'limit': {'type': int, 'default': DEFAULT_LIMIT}, + 'refresh_rate': {'type': int, 'default': DEFAULT_REFRESH_RATE}, + 'retreive_all': {'type': bool, 'default': False}} + + def get(self): + args = self.reqparse_args() + if g.user.refresh_rate: + args['refresh_rate'] = g.user.refresh_rate + + if args.pop('retreive_all', False): + contr = self.wider_controller + else: + contr = self.controller + result = [feed for feed in contr.list_fetchable(**args)] + return result or None, 200 if result else 204 + + +g.api.add_resource(FeedNewAPI, '/feed', endpoint='feed_new.json') +g.api.add_resource(FeedAPI, '/feed/', endpoint='feed.json') +g.api.add_resource(FeedsAPI, '/feeds', endpoint='feeds.json') +g.api.add_resource(FetchableFeedAPI, '/feeds/fetchable', + endpoint='fetchable_feed.json') -- cgit