diff --git a/app/__init__.py b/app/__init__.py index 0326ca671..4c4c16c92 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -35,4 +35,7 @@ def create_app(config_name): from .auth import auth as auth_blueprint app.register_blueprint(auth_blueprint, url_prefix='/auth') + from .api import api as api_blueprint + app.register_blueprint(api_blueprint, url_prefix='/api/v1') + return app diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 000000000..f029d5385 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +api = Blueprint('api', __name__) + +from . import authentication, posts, users, comments, errors diff --git a/app/api/authentication.py b/app/api/authentication.py new file mode 100644 index 000000000..a9c66f4e9 --- /dev/null +++ b/app/api/authentication.py @@ -0,0 +1,44 @@ +from flask import g, jsonify +from flask_httpauth import HTTPBasicAuth +from ..models import User +from . import api +from .errors import unauthorized, forbidden + +auth = HTTPBasicAuth() + + +@auth.verify_password +def verify_password(email_or_token, password): + if email_or_token == '': + return False + if password == '': + g.current_user = User.verify_auth_token(email_or_token) + g.token_used = True + return g.current_user is not None + user = User.query.filter_by(email=email_or_token.lower()).first() + if not user: + return False + g.current_user = user + g.token_used = False + return user.verify_password(password) + + +@auth.error_handler +def auth_error(): + return unauthorized('Invalid credentials') + + +@api.before_request +@auth.login_required +def before_request(): + if not g.current_user.is_anonymous and \ + not g.current_user.confirmed: + return forbidden('Unconfirmed account') + + +@api.route('/tokens/', methods=['POST']) +def get_token(): + if g.current_user.is_anonymous or g.token_used: + return unauthorized('Invalid credentials') + return jsonify({'token': g.current_user.generate_auth_token( + expiration=3600), 'expiration': 3600}) diff --git a/app/api/comments.py b/app/api/comments.py new file mode 100644 index 000000000..1ecd4b03f --- /dev/null +++ b/app/api/comments.py @@ -0,0 +1,67 @@ +from flask import jsonify, request, g, url_for, current_app +from .. import db +from ..models import Post, Permission, Comment +from . import api +from .decorators import permission_required + + +@api.route('/comments/') +def get_comments(): + page = request.args.get('page', 1, type=int) + pagination = Comment.query.order_by(Comment.timestamp.desc()).paginate( + page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], + error_out=False) + comments = pagination.items + prev = None + if pagination.has_prev: + prev = url_for('api.get_comments', page=page-1) + next = None + if pagination.has_next: + next = url_for('api.get_comments', page=page+1) + return jsonify({ + 'comments': [comment.to_json() for comment in comments], + 'prev': prev, + 'next': next, + 'count': pagination.total + }) + + +@api.route('/comments/') +def get_comment(id): + comment = Comment.query.get_or_404(id) + return jsonify(comment.to_json()) + + +@api.route('/posts//comments/') +def get_post_comments(id): + post = Post.query.get_or_404(id) + page = request.args.get('page', 1, type=int) + pagination = post.comments.order_by(Comment.timestamp.asc()).paginate( + page, per_page=current_app.config['FLASKY_COMMENTS_PER_PAGE'], + error_out=False) + comments = pagination.items + prev = None + if pagination.has_prev: + prev = url_for('api.get_post_comments', id=id, page=page-1) + next = None + if pagination.has_next: + next = url_for('api.get_post_comments', id=id, page=page+1) + return jsonify({ + 'comments': [comment.to_json() for comment in comments], + 'prev': prev, + 'next': next, + 'count': pagination.total + }) + + +@api.route('/posts//comments/', methods=['POST']) +@permission_required(Permission.COMMENT) +def new_post_comment(id): + post = Post.query.get_or_404(id) + comment = Comment.from_json(request.json) + comment.author = g.current_user + comment.post = post + db.session.add(comment) + db.session.commit() + return jsonify(comment.to_json()), 201, \ + {'Location': url_for('api.get_comment', id=comment.id)} diff --git a/app/api/decorators.py b/app/api/decorators.py new file mode 100644 index 000000000..4b7086821 --- /dev/null +++ b/app/api/decorators.py @@ -0,0 +1,14 @@ +from functools import wraps +from flask import g +from .errors import forbidden + + +def permission_required(permission): + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not g.current_user.can(permission): + return forbidden('Insufficient permissions') + return f(*args, **kwargs) + return decorated_function + return decorator diff --git a/app/api/errors.py b/app/api/errors.py new file mode 100644 index 000000000..d176c8999 --- /dev/null +++ b/app/api/errors.py @@ -0,0 +1,26 @@ +from flask import jsonify +from app.exceptions import ValidationError +from . import api + + +def bad_request(message): + response = jsonify({'error': 'bad request', 'message': message}) + response.status_code = 400 + return response + + +def unauthorized(message): + response = jsonify({'error': 'unauthorized', 'message': message}) + response.status_code = 401 + return response + + +def forbidden(message): + response = jsonify({'error': 'forbidden', 'message': message}) + response.status_code = 403 + return response + + +@api.errorhandler(ValidationError) +def validation_error(e): + return bad_request(e.args[0]) diff --git a/app/api/posts.py b/app/api/posts.py new file mode 100644 index 000000000..c0123b825 --- /dev/null +++ b/app/api/posts.py @@ -0,0 +1,57 @@ +from flask import jsonify, request, g, url_for, current_app +from .. import db +from ..models import Post, Permission +from . import api +from .decorators import permission_required +from .errors import forbidden + + +@api.route('/posts/') +def get_posts(): + page = request.args.get('page', 1, type=int) + pagination = Post.query.paginate( + page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], + error_out=False) + posts = pagination.items + prev = None + if pagination.has_prev: + prev = url_for('api.get_posts', page=page-1) + next = None + if pagination.has_next: + next = url_for('api.get_posts', page=page+1) + return jsonify({ + 'posts': [post.to_json() for post in posts], + 'prev': prev, + 'next': next, + 'count': pagination.total + }) + + +@api.route('/posts/') +def get_post(id): + post = Post.query.get_or_404(id) + return jsonify(post.to_json()) + + +@api.route('/posts/', methods=['POST']) +@permission_required(Permission.WRITE) +def new_post(): + post = Post.from_json(request.json) + post.author = g.current_user + db.session.add(post) + db.session.commit() + return jsonify(post.to_json()), 201, \ + {'Location': url_for('api.get_post', id=post.id)} + + +@api.route('/posts/', methods=['PUT']) +@permission_required(Permission.WRITE) +def edit_post(id): + post = Post.query.get_or_404(id) + if g.current_user != post.author and \ + not g.current_user.can(Permission.ADMIN): + return forbidden('Insufficient permissions') + post.body = request.json.get('body', post.body) + db.session.add(post) + db.session.commit() + return jsonify(post.to_json()) diff --git a/app/api/users.py b/app/api/users.py new file mode 100644 index 000000000..31d05dd6c --- /dev/null +++ b/app/api/users.py @@ -0,0 +1,53 @@ +from flask import jsonify, request, current_app, url_for +from . import api +from ..models import User, Post + + +@api.route('/users/') +def get_user(id): + user = User.query.get_or_404(id) + return jsonify(user.to_json()) + + +@api.route('/users//posts/') +def get_user_posts(id): + user = User.query.get_or_404(id) + page = request.args.get('page', 1, type=int) + pagination = user.posts.order_by(Post.timestamp.desc()).paginate( + page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], + error_out=False) + posts = pagination.items + prev = None + if pagination.has_prev: + prev = url_for('api.get_user_posts', id=id, page=page-1) + next = None + if pagination.has_next: + next = url_for('api.get_user_posts', id=id, page=page+1) + return jsonify({ + 'posts': [post.to_json() for post in posts], + 'prev': prev, + 'next': next, + 'count': pagination.total + }) + + +@api.route('/users//timeline/') +def get_user_followed_posts(id): + user = User.query.get_or_404(id) + page = request.args.get('page', 1, type=int) + pagination = user.followed_posts.order_by(Post.timestamp.desc()).paginate( + page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], + error_out=False) + posts = pagination.items + prev = None + if pagination.has_prev: + prev = url_for('api.get_user_followed_posts', id=id, page=page-1) + next = None + if pagination.has_next: + next = url_for('api.get_user_followed_posts', id=id, page=page+1) + return jsonify({ + 'posts': [post.to_json() for post in posts], + 'prev': prev, + 'next': next, + 'count': pagination.total + }) diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 000000000..2851fa718 --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,2 @@ +class ValidationError(ValueError): + pass diff --git a/app/main/errors.py b/app/main/errors.py index 416c15142..60b5f2276 100644 --- a/app/main/errors.py +++ b/app/main/errors.py @@ -1,17 +1,32 @@ -from flask import render_template +from flask import render_template, request, jsonify from . import main @main.app_errorhandler(403) def forbidden(e): + if request.accept_mimetypes.accept_json and \ + not request.accept_mimetypes.accept_html: + response = jsonify({'error': 'forbidden'}) + response.status_code = 403 + return response return render_template('403.html'), 403 @main.app_errorhandler(404) def page_not_found(e): + if request.accept_mimetypes.accept_json and \ + not request.accept_mimetypes.accept_html: + response = jsonify({'error': 'not found'}) + response.status_code = 404 + return response return render_template('404.html'), 404 @main.app_errorhandler(500) def internal_server_error(e): + if request.accept_mimetypes.accept_json and \ + not request.accept_mimetypes.accept_html: + response = jsonify({'error': 'internal server error'}) + response.status_code = 500 + return response return render_template('500.html'), 500 diff --git a/app/models.py b/app/models.py index 50c27b8fe..8832f76cc 100644 --- a/app/models.py +++ b/app/models.py @@ -4,8 +4,9 @@ from itsdangerous import TimedJSONWebSignatureSerializer as Serializer from markdown import markdown import bleach -from flask import current_app, request +from flask import current_app, request, url_for from flask_login import UserMixin, AnonymousUserMixin +from app.exceptions import ValidationError from . import db, login_manager @@ -239,6 +240,33 @@ def followed_posts(self): return Post.query.join(Follow, Follow.followed_id == Post.author_id)\ .filter(Follow.follower_id == self.id) + def to_json(self): + json_user = { + 'url': url_for('api.get_user', id=self.id), + 'username': self.username, + 'member_since': self.member_since, + 'last_seen': self.last_seen, + 'posts_url': url_for('api.get_user_posts', id=self.id), + 'followed_posts_url': url_for('api.get_user_followed_posts', + id=self.id), + 'post_count': self.posts.count() + } + return json_user + + def generate_auth_token(self, expiration): + s = Serializer(current_app.config['SECRET_KEY'], + expires_in=expiration) + return s.dumps({'id': self.id}).decode('utf-8') + + @staticmethod + def verify_auth_token(token): + s = Serializer(current_app.config['SECRET_KEY']) + try: + data = s.loads(token) + except: + return None + return User.query.get(data['id']) + def __repr__(self): return '' % self.username @@ -276,6 +304,26 @@ def on_changed_body(target, value, oldvalue, initiator): markdown(value, output_format='html'), tags=allowed_tags, strip=True)) + def to_json(self): + json_post = { + 'url': url_for('api.get_post', id=self.id), + 'body': self.body, + 'body_html': self.body_html, + 'timestamp': self.timestamp, + 'author_url': url_for('api.get_user', id=self.author_id), + 'comments_url': url_for('api.get_post_comments', id=self.id), + 'comment_count': self.comments.count() + } + return json_post + + @staticmethod + def from_json(json_post): + body = json_post.get('body') + if body is None or body == '': + raise ValidationError('post does not have a body') + return Post(body=body) + + db.event.listen(Post.body, 'set', Post.on_changed_body) @@ -297,4 +345,23 @@ def on_changed_body(target, value, oldvalue, initiator): markdown(value, output_format='html'), tags=allowed_tags, strip=True)) + def to_json(self): + json_comment = { + 'url': url_for('api.get_comment', id=self.id), + 'post_url': url_for('api.get_post', id=self.post_id), + 'body': self.body, + 'body_html': self.body_html, + 'timestamp': self.timestamp, + 'author_url': url_for('api.get_user', id=self.author_id), + } + return json_comment + + @staticmethod + def from_json(json_comment): + body = json_comment.get('body') + if body is None or body == '': + raise ValidationError('comment does not have a body') + return Comment(body=body) + + db.event.listen(Comment.body, 'set', Comment.on_changed_body) diff --git a/requirements/common.txt b/requirements/common.txt index 753ed73f3..917e318b5 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -5,6 +5,7 @@ click==6.7 dominate==2.3.1 Flask==0.12.2 Flask-Bootstrap==3.3.7.1 +Flask-HTTPAuth==3.2.3 Flask-Login==0.4.0 Flask-Mail==0.9.1 Flask-Migrate==2.0.4 diff --git a/requirements/dev.txt b/requirements/dev.txt index 7044abc86..398d1da71 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,2 +1,9 @@ -r common.txt +certifi==2017.7.27.1 +chardet==3.0.4 faker==0.7.18 +httpie==0.9.9 +idna==2.5 +Pygments==2.2.0 +requests==2.18.2 +urllib3==1.22 diff --git a/tests/test_user_model.py b/tests/test_user_model.py index 39bd090c1..526abdbdd 100644 --- a/tests/test_user_model.py +++ b/tests/test_user_model.py @@ -206,3 +206,14 @@ def test_follows(self): db.session.delete(u2) db.session.commit() self.assertTrue(Follow.query.count() == 1) + + def test_to_json(self): + u = User(email='john@example.com', password='cat') + db.session.add(u) + db.session.commit() + with self.app.test_request_context('/'): + json_user = u.to_json() + expected_keys = ['url', 'username', 'member_since', 'last_seen', + 'posts_url', 'followed_posts_url', 'post_count'] + self.assertEqual(sorted(json_user.keys()), sorted(expected_keys)) + self.assertEqual('/api/v1/users/' + str(u.id), json_user['url'])