From 7285ed4a00a253d104e60a4abb8973b689e05a29 Mon Sep 17 00:00:00 2001 From: androiddrew Date: Sun, 3 Dec 2017 21:40:06 -0500 Subject: [PATCH] Add marshmallow schemas and and completed base routes --- news/__init__.py | 3 +- news/app.py | 45 +++++++++-- news/models.py | 7 +- news/renders.py | 2 +- news/schema.py | 37 ++++++++- tests/conftest.py | 15 +++- tests/test_app_routes.py | 165 +++++++++++++++++++++++++++++++++------ tests/test_renders.py | 2 +- 8 files changed, 236 insertions(+), 40 deletions(-) diff --git a/news/__init__.py b/news/__init__.py index 0d30fba..18b4575 100644 --- a/news/__init__.py +++ b/news/__init__.py @@ -1 +1,2 @@ -from .app import application_factory \ No newline at end of file +from .app import application_factory +from .app import application_factory diff --git a/news/app.py b/news/app.py index 85d973b..7fbc8c3 100644 --- a/news/app.py +++ b/news/app.py @@ -7,14 +7,15 @@ from apistar.handlers import docs_urls, static_urls from .models import Base, NewsArticle, NewsSource from .renders import JSONRenderer -from .schema import NewsSourceSchema +from .schema import NewsSourceSchema, NewsArticleSchema news_source_schema = NewsSourceSchema() +news_article_schema = NewsArticleSchema() def get_sources(session: Session): sources = session.query(NewsSource).all() - return news_source_schema.dump(sources, many=True).data + return http.Response(news_source_schema.dump(sources, many=True).data, status=200) def add_source(session: Session, request_data: http.RequestData, router: Router): @@ -54,15 +55,42 @@ def get_source(session: Session, id: int): def get_articles(session: Session): articles = session.query(NewsArticle).all() - return [article.to_dict() for article in articles] + # return [article.to_dict() for article in articles] + return http.Response(news_article_schema.dump(articles, many=True).data, status=200) -def add_article(session: Session): - pass +def add_article(session: Session, request_data: http.RequestData, router: Router): + news_article_data, errors = news_article_schema.load(request_data) + if errors: + msg = {"message": "400 Bad Request", "error": errors} + return http.Response(msg, status=400) + news_article = NewsArticle(**news_article_data) + + session.add(news_article) + session.flush + + headers = {"Location": router.reverse_url('get_source', {"id": news_article.id})} + + return http.Response(news_article_schema.dump(news_article), status=201, headers=headers) + + +def get_article(session: Session, id: int): + news_article = session.query(NewsArticle).filter_by(id=id).one_or_none() + if news_article is None: + msg = {"message": "404 Not Found"} + return http.Response(msg, status=404) + return http.Response(news_article_schema.dump(news_article), status=200) -def delete_article(session: Session): - pass +def delete_article(session: Session, id): + """Delete a single News Sources from the collection by id""" + news_article = session.query(NewsArticle).filter_by(id=id).one_or_none() + if news_article is None: + msg = {"message": "404 Not Found"} + return http.Response(msg, status=404) + + msg = {"message": "200 OK"} + return http.Response(msg, status=200) routes = [ @@ -71,6 +99,9 @@ routes = [ Route('/sources/{id}', 'GET', get_source), Route('/sources/{id}', 'DELETE', delete_source), Route('/articles', 'GET', get_articles), + Route('/articles', 'POST', add_article), + Route('/articles/{id}', 'GET', get_article), + Route('/articles/{id}', 'DELETE', delete_article), Include('/docs', docs_urls), Include('/static', static_urls) ] diff --git a/news/models.py b/news/models.py index 3dc6ab0..3dec3fa 100644 --- a/news/models.py +++ b/news/models.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import relationship, backref from sqlalchemy.sql import expression, and_ from sqlalchemy.ext.compiler import compiles from sqlalchemy.types import DateTime as DateTimeType +from sqlalchemy.orm.exc import NoResultFound class utcnow(expression.FunctionElement): @@ -53,7 +54,7 @@ class Tag(DBMixin, Base): def get_or_create(session, name): try: return session.query(Tag).filter_by(tag_name=name).one() - except: + except NoResultFound: return Tag(tag_name=name) @@ -65,7 +66,7 @@ class Category(DBMixin, Base): def get_or_create(session, name): try: return session.query(Category).filter_by(category_name=name).one() - except: + except NoResultFound: return Category(category_name=name) @@ -84,7 +85,7 @@ class NewsArticle(DBMixin, Base): url = Column(Text, unique=True) title = Column(Text) authors = Column(ARRAY(Text)) - publish_date = Column(DateTime) + published_date = Column(DateTime) news_blob = Column(Text) news_source = relationship('NewsSource', backref=backref('articles', lazy='dynamic')) tags = relationship('Tag', secondary=tags, backref=backref('articles', lazy='dynamic')) diff --git a/news/renders.py b/news/renders.py index 4ed0318..59bb97f 100644 --- a/news/renders.py +++ b/news/renders.py @@ -20,4 +20,4 @@ class JSONRenderer(Renderer): charset = None def render(self, data: http.ResponseData) -> bytes: - return json.dumps(data, default=extended_encoder).encode('utf-8') \ No newline at end of file + return json.dumps(data, default=extended_encoder).encode('utf-8') diff --git a/news/schema.py b/news/schema.py index 6d46cb9..ecb6f78 100644 --- a/news/schema.py +++ b/news/schema.py @@ -1,13 +1,44 @@ +import datetime as dt + from marshmallow import Schema, fields +class PassDateTime(fields.DateTime): + def _deserialize(self, value, attr, data): + if isinstance(value, dt.datetime): + return value + return super()._deserialize(value, attr, data) + + class NewsSourceSchema(Schema): id = fields.Int(dump_only=True) created_date = fields.DateTime(dump_only=True) - modified_date = fields.DateTime() + modified_date = fields.DateTime(dump_only=True) url = fields.URL() source_name = fields.Str(required=True, error_messages={'required': 'NewsSource name is a required field'}) - source_type = fields.Str(required=True, error_messages={'required': 'NewsSource tyoe is a required field'}) + source_type = fields.Str(required=True, error_messages={'required': 'NewsSource type is a required field'}) + + # TODO add support for Categories + + class Meta: + ordered = True + + +class NewsArticleSchema(Schema): + id = fields.Int(dump_only=True) + created_date = fields.DateTime(dump_only=True) + modified_date = fields.DateTime(dump_only=True) + news_source_id = fields.Int(required=True, error_messages={ + 'required': 'A NewsArticle must include a NewsSource identified by NewsSource.id'} + ) + url = fields.URL(required=True, error_messages={'required': 'A NewsArticle must include a URL'}) + title = fields.Str() + authors = fields.List(fields.Str()) + # published_date = fields.DateTime() + published_date = PassDateTime() + news_blob = fields.Str(required=True, error_messages={'required': 'NewsArticle must include news content'}) + + # TODO add support for Tags class Meta: - ordered = True \ No newline at end of file + ordered = True diff --git a/tests/conftest.py b/tests/conftest.py index ecf0bb4..1549b0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +from unittest import mock + from apistar.frameworks.wsgi import WSGIApp from apistar.backends.sqlalchemy_backend import SQLAlchemyBackend, get_session @@ -48,4 +50,15 @@ def apistar_app_fixture(): return WSGIApp(settings=settings, commands=commands, components=components, - routes=routes) \ No newline at end of file + routes=routes) + + +@pytest.fixture(name='router', scope="session") +def apistar_router_fixture(): + """Returns a session scoped mock Router""" + mock_router = mock.Mock() + mock_router.configure_mock( + **{"reverse_url.return_value": "/mylocation"} + ) + + return mock_router diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py index 9720da2..6d5b6fe 100644 --- a/tests/test_app_routes.py +++ b/tests/test_app_routes.py @@ -3,14 +3,17 @@ from unittest import mock import pytest -from news.app import get_articles, get_sources, add_source, get_source, delete_source +from news.app import get_articles, get_sources, add_source, get_source, delete_source, add_article, get_article, \ + delete_article from news.models import NewsArticle, NewsSource, Category, Tag # Sources def test_get_empty__news_sources(rb_session): - assert [] == get_sources(rb_session) + result = get_sources(rb_session) + assert 200 == result.status + assert [] == result.content def test_get_news_sources(rb_session): @@ -22,44 +25,36 @@ def test_get_news_sources(rb_session): rb_session.add(test_source) rb_session.flush() - assert 1 == len(get_sources(rb_session)) + result = get_sources(rb_session) + assert 200 == result.status + assert 1 == len(result.content) -def test_add_news_source(rb_session): +def test_add_news_source(rb_session, router): test_source = { 'source_name': 'TEST', 'source_type': 'website', 'url': 'http://money.test.com' } - mock_router = mock.Mock() - mock_router.configure_mock( - **{"reverse_url.return_value": "/mylocation"} - ) - - result = add_source(rb_session, test_source, mock_router) + result = add_source(rb_session, test_source, router) assert 201 == result.status assert 'location' in result.headers.keys() -def test_add_news_source_error(rb_session): +def test_add_news_source_error(rb_session, router): """Testing with missing required field""" test_source = { 'source_type': 'website', 'url': 'http://money.test.com' } - mock_router = mock.Mock() - mock_router.configure_mock( - **{"reverse_url.return_value": "/mylocation"} - ) - - result = add_source(rb_session, test_source, mock_router) + result = add_source(rb_session, test_source, router) print(result.headers) assert 400 == result.status -def test_get_news_source(rb_session): +def test_get_news_source_by_id(rb_session): test_source = NewsSource(url='http://money.test.com', source_name='TEST', source_type='website', @@ -117,11 +112,133 @@ def test_delete_news_source_404(rb_session): # Articles -def test_get_empty_articles(rb_session): - assert get_articles(rb_session) == [] +def test_get_empty_news_articles(rb_session): + result = get_articles(rb_session) + assert 200 == result.status + assert [] == result.content + + +def test_get_news_articles(rb_session): + source = NewsSource(url='http://money.test.com', + source_name='TEST', + source_type='website', + categories=[Category(category_name='finance')] + ) + + article = NewsArticle(url='http://money.test.com/article', + title='article', + authors=['drew', 'jesse'], + published_date=dt.datetime.utcnow(), + news_blob='article content', + news_source=source, + tags=[Tag(tag_name='article')] + ) + + rb_session.add(article) + rb_session.flush() + + result = get_articles(rb_session) + assert 200 == result.status + assert 1 == len(result.content) + + +def test_add_news_article(rb_session, router): + news_source = NewsSource(url='http://money.test.com', + source_name='TEST', + source_type='website', + categories=[Category(category_name='finance')] + ) + + rb_session.add(news_source) + rb_session.flush() + + news_article_data = { + "news_source_id": 1, + "url": "http://money.test.com/article", + "title": "article", + "authors": ["drew", "jesse"], + "published_date": dt.datetime.utcnow(), + "news_blob": "Here's all the great content" + } + + result = add_article(rb_session, news_article_data, router) + + assert 201 == result.status + + +def test_add_news_article_error(rb_session, router): + """Testing with missing required field""" + news_source = NewsSource(url='http://money.test.com', + source_name='TEST', + source_type='website', + categories=[Category(category_name='finance')] + ) + + rb_session.add(news_source) + rb_session.flush() + + news_article_data = { + "news_source_id": 1, + "title": "article", + "authors": ["drew", "jesse"], + "published_date": dt.datetime.utcnow(), + "news_blob": "Here's all the great content" + } + + result = add_article(rb_session, news_article_data, router) + + assert 400 == result.status + + +def test_get_article_by_id(rb_session): + source = NewsSource(url='http://money.test.com', + source_name='TEST', + source_type='website', + categories=[Category(category_name='finance')] + ) + + article = NewsArticle(url='http://money.test.com/article', + title='article', + authors=['drew', 'jesse'], + published_date=dt.datetime.utcnow(), + news_blob='article content', + news_source=source, + tags=[Tag(tag_name='article')] + ) + + rb_session.add(article) + rb_session.flush() + + result = get_article(rb_session, 1) + + assert 200 == result.status + + +def test_delete_article_by_id(rb_session): + source = NewsSource(url='http://money.test.com', + source_name='TEST', + source_type='website', + categories=[Category(category_name='finance')] + ) + + article = NewsArticle(url='http://money.test.com/article', + title='article', + authors=['drew', 'jesse'], + published_date=dt.datetime.utcnow(), + news_blob='article content', + news_source=source, + tags=[Tag(tag_name='article')] + ) + + rb_session.add(article) + rb_session.flush() + + result = delete_article(rb_session, 1) + + assert 200 == result.status -def test_get_articles(rb_session): +def test_delete_article_404(rb_session): source = NewsSource(url='http://money.test.com', source_name='TEST', source_type='website', @@ -131,7 +248,7 @@ def test_get_articles(rb_session): article = NewsArticle(url='http://money.test.com/article', title='article', authors=['drew', 'jesse'], - publish_date=dt.datetime.utcnow(), + published_date=dt.datetime.utcnow(), news_blob='article content', news_source=source, tags=[Tag(tag_name='article')] @@ -140,4 +257,6 @@ def test_get_articles(rb_session): rb_session.add(article) rb_session.flush() - assert 1 == len(get_articles(rb_session)) + result = delete_article(rb_session, 2) + + assert 404 == result.status diff --git a/tests/test_renders.py b/tests/test_renders.py index 01ba1f9..5b9c274 100644 --- a/tests/test_renders.py +++ b/tests/test_renders.py @@ -20,4 +20,4 @@ def test_render_with_extended_encoder(): test_decimal = Decimal('0.1') expected = dict(my_date=1494388800.0, my_float=0.1) test_response = dict(my_date=test_date, my_float=test_decimal) - assert json.dumps(expected).encode('utf-8') == JSONRenderer().render(test_response) \ No newline at end of file + assert json.dumps(expected).encode('utf-8') == JSONRenderer().render(test_response)