diff --git a/earthreader/web/__init__.py b/earthreader/web/__init__.py index 5bdc7e3..5016066 100644 --- a/earthreader/web/__init__.py +++ b/earthreader/web/__init__.py @@ -6,20 +6,23 @@ import os from six.moves import urllib -from flask import Flask, jsonify, render_template, request, url_for +from flask import Flask, g, jsonify, render_template, request, url_for from libearth.codecs import Rfc3339 from libearth.compat import text_type from libearth.crawler import crawl from libearth.parser.autodiscovery import autodiscovery, FeedUrlNotFoundError from libearth.subscribe import Category, Subscription, SubscriptionList from libearth.tz import now, utc +from werkzeug.local import LocalProxy from .util import autofix_repo_url, get_hash from .wsgi import MethodRewriteMiddleware -from .exceptions import (InvalidCategoryID, IteratorNotFound, WorkerNotRunning, - FeedNotFound, EntryNotFound) +from .exceptions import (AutodiscoveryFailed, CategoryCircularReference, + DocumentNotFound, InvalidCategoryID, IteratorNotFound, + WorkerNotRunning, FeedNotFound, FeedNotFoundInCategory, + EntryNotFound) from .worker import Worker -from .stage import stage +from .transaction import SubscriptionTransaction app = Flask(__name__) @@ -51,48 +54,26 @@ def initialize(): worker.start_worker() -class Cursor(): +@app.before_request +def before_request(): + if request.path == '/' and request.method == 'GET': + return + g.transaction = SubscriptionTransaction() - def __init__(self, category_id, return_parent=False): - with stage: - self.subscriptionlist = (stage.subscriptions if stage.subscriptions - else SubscriptionList()) - self.value = self.subscriptionlist - self.path = ['/'] - self.category_id = None - target_name = None - self.target_child = None +transaction = LocalProxy(lambda: g.transaction) - try: - if category_id: - self.category_id = category_id - self.path = [key[1:] for key in category_id.split('/')] - if return_parent: - target_name = self.path.pop(-1) - for key in self.path: - self.value = self.value.categories[key] - if target_name: - self.target_child = self.value.categories[target_name] - except Exception: - raise InvalidCategoryID('The given category ID is not valid') - - def __getattr__(self, attr): - return getattr(self.value, attr) - - def __iter__(self): - return iter(self.value) - - def join_id(self, append): - if self.category_id: - return self.category_id + '/-' + append - return '-' + append + +def join_category_id(base, append): + if base: + return base + '/-' + append + return '-' + append def add_urls(data, keys, category_id, feed_id=None, entry_id=None): APIS = { 'entries_url': 'category_entries', - 'feeds_url': 'feeds', + 'feeds_url': 'list_in_category', 'add_feed_url': 'add_feed', 'add_category_url': 'add_category', 'remove_category_url': 'delete_category', @@ -140,107 +121,107 @@ def index(): @app.route('/feeds/', defaults={'category_id': ''}) @app.route('//feeds/') -def feeds(category_id): - cursor = Cursor(category_id) +def list_in_category(category_id): + category = transaction.get_category(category_id) feeds = [] categories = [] - for child in cursor: - data = {'title': child.label} + for child in category: if isinstance(child, Subscription): - url_keys = ['entries_url', 'remove_feed_url'] - add_urls(data, url_keys, cursor.category_id, child.feed_id) - add_path_data(data, cursor.category_id, child.feed_id) - feeds.append(data) + feeds.append(get_feed_data(category_id, child)) elif isinstance(child, Category): - url_keys = ['feeds_url', 'entries_url', 'add_feed_url', - 'add_category_url', 'remove_category_url', 'move_url'] - add_urls(data, url_keys, cursor.join_id(child.label)) - add_path_data(data, cursor.join_id(child.label)) - categories.append(data) + categories.append(get_category_data(category_id, child)) return jsonify(feeds=feeds, categories=categories) +def get_feed_data(base_category_id, subscription): + feed_data = {'title': subscription.label} + url_keys = ['entries_url', 'remove_feed_url'] + add_urls(feed_data, url_keys, base_category_id, subscription.feed_id) + add_path_data(feed_data, base_category_id, subscription.feed_id) + return feed_data + + +def get_category_data(base_category_id, category): + label = category.label + category_data = {'title': label} + url_keys = ['feeds_url', 'entries_url', 'add_feed_url', + 'add_category_url', 'remove_category_url', 'move_url'] + add_urls(category_data, url_keys, join_category_id(base_category_id, label)) + add_path_data(category_data, join_category_id(base_category_id, label)) + return category_data + + @app.route('/feeds/', methods=['POST'], defaults={'category_id': ''}) @app.route('//feeds/', methods=['POST']) def add_feed(category_id): - cursor = Cursor(category_id) + category = transaction.get_category(category_id) url = request.form['url'] + document = get_document(url) + feed_links = get_feed_links(document, url) + feed_url = feed_links[0].url + feed_url, feed, hints = next(iter(crawl([feed_url], 1))) + transaction.add_feed(category, feed) + transaction.save() + return list_in_category(category_id) + + +def get_document(url): try: f = urllib.request.urlopen(url) document = f.read() f.close() + return document except Exception: - r = jsonify( - error='unreachable-url', - message='Cannot connect to given url' - ) - r.status_code = 400 - return r + raise DocumentNotFound + + +def get_feed_links(document, url): try: - feed_links = autodiscovery(document, url) + return autodiscovery(document, url) except FeedUrlNotFoundError: - r = jsonify( - error='unreachable-feed-url', - message='Cannot find feed url' - ) - r.status_code = 400 - return r - feed_url = feed_links[0].url - feed_url, feed, hints = next(iter(crawl([feed_url], 1))) - with stage: - sub = cursor.subscribe(feed) - stage.subscriptions = cursor.subscriptionlist - stage.feeds[sub.feed_id] = feed - return feeds(category_id) + raise FeedUrlNotFoundError @app.route('/', methods=['POST'], defaults={'category_id': ''}) @app.route('//', methods=['POST']) def add_category(category_id): - cursor = Cursor(category_id) + category = transaction.get_category(category_id) title = request.form['title'] outline = Category(label=title) - cursor.add(outline) - with stage: - stage.subscriptions = cursor.subscriptionlist - return feeds(category_id) + category.add(outline) + transaction.save() + return list_in_category(category_id) @app.route('//', methods=['DELETE']) def delete_category(category_id): - cursor = Cursor(category_id, True) - cursor.remove(cursor.target_child) - with stage: - stage.subscriptions = cursor.subscriptionlist + parent_category = transaction.get_parent_category(category_id) + target_category = transaction.get_category(category_id) + parent_category.remove(target_category) + transaction.save() index = category_id.rfind('/') if index == -1: - return feeds('') + return list_in_category('') else: - return feeds(category_id[:index]) + return list_in_category(category_id[:index]) @app.route('/feeds//', methods=['DELETE'], defaults={'category_id': ''}) @app.route('//feeds//', methods=['DELETE']) def delete_feed(category_id, feed_id): - cursor = Cursor(category_id) + category = transaction.get_category(category_id) target = None - for subscription in cursor: + for subscription in category: if isinstance(subscription, Subscription): if feed_id == subscription.feed_id: target = subscription if target: - cursor.discard(target) + category.discard(target) else: - r = jsonify( - error='feed-not-found-in-path', - message='Given feed does not exist in the path' - ) - r.status_code = 400 - return r - with stage: - stage.subscriptions = cursor.subscriptionlist - return feeds(category_id) + raise FeedNotFoundInCategory + transaction.save() + return list_in_category(category_id) @app.route('//feeds/', methods=['PUT']) @@ -249,30 +230,23 @@ def move_outline(category_id): source_path = request.args.get('from') if '/feeds/' in source_path: parent_category_id, feed_id = source_path.split('/feeds/') - source = Cursor(parent_category_id) + source = transaction.get_category(parent_category_id) target = None for child in source: if child.feed_id == feed_id: target = child else: - source = Cursor(source_path, True) - target = source.target_child - - dest = Cursor(category_id) - if isinstance(target, Category) and target.contains(dest.value): - r = jsonify( - error='circular-reference', - message='Cannot move into child element.' - ) - r.status_code = 400 - return r + source = transaction.get_parent_category(source_path) + target = transaction.get_category(source_path) + + dest = transaction.get_category(category_id) + if isinstance(target, Category) and target.contains(dest): + raise CategoryCircularReference source.discard(target) - with stage: - stage.subscriptions = source.subscriptionlist - dest = Cursor(category_id) + transaction.save() + dest = transaction.get_category(category_id) dest.add(target) - with stage: - stage.subscriptions = dest.subscriptionlist + transaction.save() return jsonify() @@ -426,25 +400,7 @@ def get_entries(self): @app.route('/feeds//entries/', defaults={'category_id': ''}) @app.route('//feeds//entries/') def feed_entries(category_id, feed_id): - try: - Cursor(category_id) - except InvalidCategoryID: - r = jsonify( - error='category-id-invalid', - message='Given category does not exist' - ) - r.status_code = 404 - return r - try: - with stage: - feed = stage.feeds[feed_id] - except KeyError: - r = jsonify( - error='feed-not-found', - message='Given feed does not exist' - ) - r.status_code = 404 - return r + feed = transaction.get_feed(feed_id, category_id) if feed.__revision__: updated_at = feed.__revision__.updated_at if request.if_modified_since: @@ -592,7 +548,7 @@ def get_entries(self): @app.route('/entries/', defaults={'category_id': ''}) @app.route('//entries/') def category_entries(category_id): - cursor = Cursor(category_id) + category = transaction.get_category(category_id) generator = None url_token, entry_after, read, starred = get_optional_args() if url_token: @@ -603,7 +559,7 @@ def category_entries(category_id): else: url_token = text_type(now()) if not generator: - subscriptions = cursor.recursive_subscriptions + subscriptions = category.recursive_subscriptions generator = CategoryEntryGenerator() if entry_after: id_after, time_after = entry_after.split('@') @@ -612,9 +568,8 @@ def category_entries(category_id): id_after = None for subscription in subscriptions: try: - with stage: - feed = stage.feeds[subscription.feed_id] - except KeyError: + feed = transaction.get_feed(subscription.feed_id) + except FeedNotFound: continue feed_title = text_type(feed.title) it = iter(feed.entries) @@ -668,8 +623,8 @@ def category_entries(category_id): @app.route('//entries/', methods=['PUT']) def update_entries(category_id, feed_id=None): if worker.is_running(): - cursor = Cursor(category_id) - worker.add_job(cursor, feed_id) + category = transaction.get_category(category_id) + worker.add_job(category, feed_id) r = jsonify() r.status_code = 202 return r @@ -678,11 +633,7 @@ def update_entries(category_id, feed_id=None): def find_feed_and_entry(feed_id, entry_id): - try: - with stage: - feed = stage.feeds[feed_id] - except KeyError: - raise FeedNotFound('The feed is not reachable') + feed = transaction.get_feed(feed_id) feed_permalink = get_permalink(feed) for entry in feed.entries: entry_permalink = get_permalink(entry) @@ -735,8 +686,8 @@ def feed_entry(category_id, feed_id, entry_id): def read_entry(category_id, feed_id, entry_id): feed, _, entry, _ = find_feed_and_entry(feed_id, entry_id) entry.read = True - with stage: - stage.feeds[feed_id] = feed + transaction.update_feed(feed_id, feed) + transaction.save() return jsonify() @@ -747,8 +698,8 @@ def read_entry(category_id, feed_id, entry_id): def unread_entry(category_id, feed_id, entry_id): feed, _, entry, _ = find_feed_and_entry(feed_id, entry_id) entry.read = False - with stage: - stage.feeds[feed_id] = feed + transaction.update_feed(feed_id, feed) + transaction.save() return jsonify() @@ -761,8 +712,8 @@ def read_all_entries(category_id='', feed_id=None): if feed_id: feed_ids = [feed_id] else: - cursor = Cursor(category_id) - feed_ids = [sub.feed_id for sub in cursor.recursive_subscriptions] + category = transaction.get_category(category_id) + feed_ids = [sub.feed_id for sub in category.recursive_subscriptions] try: codec = Rfc3339() @@ -771,23 +722,12 @@ def read_all_entries(category_id='', feed_id=None): last_updated = None for feed_id in feed_ids: - try: - with stage: - feed = stage.feeds[feed_id] - for entry in feed.entries: - if not last_updated or entry.updated_at <= last_updated: - entry.read = True - stage.feeds[feed_id] = feed - except KeyError: - if feed_id: - r = jsonify( - error='feed-not-found', - message='Given feed does not exist' - ) - r.status_code = 404 - return r - else: - continue + feed = transaction.get_feed(feed_id) + for entry in feed.entries: + if not last_updated or entry.updated_at <= last_updated: + entry.read = True + transaction.update_feed(feed_id, feed) + transaction.save() return jsonify() @@ -798,8 +738,8 @@ def read_all_entries(category_id='', feed_id=None): def star_entry(category_id, feed_id, entry_id): feed, _, entry, _ = find_feed_and_entry(feed_id, entry_id) entry.starred = True - with stage: - stage.feeds[feed_id] = feed + transaction.update_feed(feed_id, feed) + transaction.save() return jsonify() @@ -810,6 +750,6 @@ def star_entry(category_id, feed_id, entry_id): def unstar_entry(category_id, feed_id, entry_id): feed, _, entry, _ = find_feed_and_entry(feed_id, entry_id) entry.starred = False - with stage: - stage.feeds[feed_id] = feed + transaction.update_feed(feed_id, feed) + transaction.save() return jsonify() diff --git a/earthreader/web/exceptions.py b/earthreader/web/exceptions.py index 648f093..a26f64b 100644 --- a/earthreader/web/exceptions.py +++ b/earthreader/web/exceptions.py @@ -12,12 +12,13 @@ class IteratorNotFound(ValueError): class JsonException(HTTPException): """Base exception to return json response when raised. - Exceptions inherit this class must declare `error` and `message`. + Exceptions inherit this class must declare `error`, `message`, + and `status_code`. """ def get_response(self, environ=None): r = jsonify(error=self.error, message=self.message) - r.status_code = 404 + r.status_code = self.status_code return r @@ -26,6 +27,7 @@ class InvalidCategoryID(ValueError, JsonException): error = 'category-id-invalid' message = 'Given category id is not valid' + status_code = 404 class FeedNotFound(ValueError, JsonException): @@ -33,6 +35,7 @@ class FeedNotFound(ValueError, JsonException): error = 'feed-not-found' message = 'The feed you request does not exsist' + status_code = 404 class EntryNotFound(ValueError, JsonException): @@ -40,6 +43,7 @@ class EntryNotFound(ValueError, JsonException): error = 'entry-not-found' message = 'The entry you request does not exist' + status_code = 404 class WorkerNotRunning(ValueError, JsonException): @@ -48,3 +52,36 @@ class WorkerNotRunning(ValueError, JsonException): error = 'worker-not-running' message = 'The worker thread that crawl feeds in background is not' \ 'running.' + status_code = 404 + + +class DocumentNotFound(ValueError, JsonException): + """Raised when the document is not reachable""" + + error = 'unreachable-url' + message = 'Cannot connect to given url' + status_code = 404 + + +class AutodiscoveryFailed(ValueError, JsonException): + """Raised when a feed url is not found""" + + error = 'unreachable-feed-url' + message = 'Cannot find feed url' + status_code = 404 + + +class FeedNotFoundInCategory(ValueError, JsonException): + """Raised whan the feed does not exist in category""" + + error = 'feed-not-found-in-path' + message = 'Given feed does not exist in the path' + status_code = 400 + + +class CategoryCircularReference(ValueError, JsonException): + """Raised when category structure does not make sense""" + + error = 'circular-reference' + message = 'Cannot move into child element.' + status_code = 400 diff --git a/earthreader/web/templates/index.html b/earthreader/web/templates/index.html index e5e97bd..e271b8c 100644 --- a/earthreader/web/templates/index.html +++ b/earthreader/web/templates/index.html @@ -7,7 +7,7 @@ href="{{ url_for('static', filename='img/favicon_32.ico')}}"/>