From 408ef8c252a5807b1909c1e60690109bfe6708f9 Mon Sep 17 00:00:00 2001 From: vesc Date: Wed, 31 Jan 2018 12:18:15 +0000 Subject: [PATCH 1/6] Updated published_date: Changed it to unix timestamp --- news/schema.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/news/schema.py b/news/schema.py index 5031c39..7ee7da4 100644 --- a/news/schema.py +++ b/news/schema.py @@ -59,8 +59,7 @@ class NewsArticleSchema(Schema): 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() + published_date = UnixTimestamp() news_blob = fields.Str(required=True, error_messages={'required': 'NewsArticle must include news content'}) # TODO add support for Tags From ee3c1108588e431be962b92e0fba3decc7c10fc7 Mon Sep 17 00:00:00 2001 From: vesc Date: Wed, 31 Jan 2018 18:46:28 +0000 Subject: [PATCH 2/6] Fixed timezone issue in test_renders: added utc timezone to datetime call in test added pytz to requirements --- requirements.txt | 1 + tests/test_renders.py | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7ddafb4..c1051b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ marshmallow==2.15.0 psycopg2==2.7.3.2 py==1.4.34 pytest==3.2.3 +pytz==2017.3 requests==2.18.4 SQLAlchemy==1.1.15 uritemplate==3.0.0 diff --git a/tests/test_renders.py b/tests/test_renders.py index 5b9c274..101a606 100644 --- a/tests/test_renders.py +++ b/tests/test_renders.py @@ -1,12 +1,13 @@ import datetime as dt -from decimal import Decimal import json +from pytz import timezone +from decimal import Decimal from news.renders import extended_encoder, JSONRenderer def test_extended_encoder_date_parsing(): - test_date = dt.datetime(2017, 5, 10) + test_date = dt.datetime(2017, 5, 10, tzinfo=timezone('UTC')) assert test_date.timestamp() == extended_encoder(test_date) @@ -18,6 +19,6 @@ def test_extended_encoder_decimal_casting(): def test_render_with_extended_encoder(): test_date = dt.datetime(2017, 5, 10) test_decimal = Decimal('0.1') - expected = dict(my_date=1494388800.0, my_float=0.1) + expected = dict(my_date=1494374400.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) From b4f1f4439f814383d997df758c4323423a4acfa4 Mon Sep 17 00:00:00 2001 From: vesc Date: Wed, 31 Jan 2018 19:39:50 +0000 Subject: [PATCH 3/6] added vim .swp files --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 31496a5..5abd0da 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,7 @@ docs/_build/ target/ # Pycharm -.idea \ No newline at end of file +.idea + +# VIM +*.swp From ded199e618afd2449a3c9f8f609711c02c873640 Mon Sep 17 00:00:00 2001 From: Jesse Cooper Date: Thu, 8 Feb 2018 21:18:41 +0000 Subject: [PATCH 4/6] fixed two issues: add_article: called flush and has been changed to flush() NewsArticleSchema: published_date UnixTimestamp(dump_only=True) --- news/app.py | 2 +- news/schema.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/news/app.py b/news/app.py index 282d2bd..4f1c24e 100644 --- a/news/app.py +++ b/news/app.py @@ -66,7 +66,7 @@ def add_article(session: Session, request_data: http.RequestData, router: Router news_article = NewsArticle(**news_article_data) session.add(news_article) - session.flush + session.flush() headers = {"Location": router.reverse_url('get_source', {"id": news_article.id})} diff --git a/news/schema.py b/news/schema.py index 7ee7da4..56b6c54 100644 --- a/news/schema.py +++ b/news/schema.py @@ -59,7 +59,7 @@ class NewsArticleSchema(Schema): url = fields.URL(required=True, error_messages={'required': 'A NewsArticle must include a URL'}) title = fields.Str() authors = fields.List(fields.Str()) - published_date = UnixTimestamp() + published_date = UnixTimestamp(dump_only=True) news_blob = fields.Str(required=True, error_messages={'required': 'NewsArticle must include news content'}) # TODO add support for Tags From ea35decd3674cf94d5bd803a463524f5352dd7b1 Mon Sep 17 00:00:00 2001 From: androiddrew Date: Fri, 29 Jun 2018 23:51:53 -0400 Subject: [PATCH 5/6] Level up to Apistar 0.5.4 without tests --- .flake8 | 3 ++ HISTORY.md | 5 +++ README.md | 5 ++- dev_requirements.in | 6 ++++ dev_requirements.txt | 29 ++++++++++++++++ news/__init__.py | 3 +- news/app.py | 76 ++++++++++++++++++----------------------- news/models.py | 4 +-- news/renders.py | 23 ------------- news/schema.py | 8 +++-- news/util.py | 38 +++++++++++++++++++++ requirements.in | 5 +++ requirements.txt | 46 +++++++++++++------------ scripts/wsgi_app.py | 11 ++++++ setup.py | 79 +++++++++++++++++++++++++++++++++++++++++++ tests/test_renders.py | 24 ------------- wsgi_app.py | 9 ----- 17 files changed, 246 insertions(+), 128 deletions(-) create mode 100644 .flake8 create mode 100644 HISTORY.md create mode 100644 dev_requirements.in create mode 100644 dev_requirements.txt delete mode 100644 news/renders.py create mode 100644 news/util.py create mode 100644 requirements.in create mode 100644 scripts/wsgi_app.py create mode 100644 setup.py delete mode 100644 tests/test_renders.py delete mode 100644 wsgi_app.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..4716794 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 120 +exclude = .git,__pycache__,htmlcov,.cache,.tox,env,dist,doc,*egg,build \ No newline at end of file diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 0000000..68cf408 --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,5 @@ +#History + +0.2.0 Level up to Apistar 0.5.x + +0.1.0 Initial app \ No newline at end of file diff --git a/README.md b/README.md index ee84ad7..964504d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ # news -A ReSTful api for storing and retrieving news articles \ No newline at end of file +A ReSTful api for storing and retrieving news articles + +## Pip tools +This project uses pip-tools for developement. Reference the [project page](https://github.com/jazzband/pip-tools) for usage instructions. \ No newline at end of file diff --git a/dev_requirements.in b/dev_requirements.in new file mode 100644 index 0000000..4c8ad1e --- /dev/null +++ b/dev_requirements.in @@ -0,0 +1,6 @@ +pip-tools +pytest +tox +flake8 +setuptools >= 38.6.0 +wheel >= 0.31.0 \ No newline at end of file diff --git a/dev_requirements.txt b/dev_requirements.txt new file mode 100644 index 0000000..7dc9f6b --- /dev/null +++ b/dev_requirements.txt @@ -0,0 +1,29 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile --output-file dev_requirements.txt dev_requirements.in +# +--trusted-host pypi.python.org +--trusted-host git.aigalatic.com + +atomicwrites==1.1.5 # via pytest +attrs==18.1.0 # via pytest +click==6.7 # via pip-tools +first==2.0.1 # via pip-tools +flake8==3.5.0 +mccabe==0.6.1 # via flake8 +more-itertools==4.2.0 # via pytest +pip-tools==2.0.2 +pluggy==0.6.0 # via pytest, tox +py==1.5.4 # via pytest, tox +pycodestyle==2.3.1 # via flake8 +pyflakes==1.6.0 # via flake8 +pytest==3.6.2 +six==1.11.0 # via more-itertools, pip-tools, pytest, tox +tox==3.0.0 +virtualenv==16.0.0 # via tox +wheel==0.31.1 + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/news/__init__.py b/news/__init__.py index 526ecad..44a0cc8 100644 --- a/news/__init__.py +++ b/news/__init__.py @@ -1,2 +1 @@ -from .app import application_factory - +from .app import application_factory # noqa: F401 diff --git a/news/app.py b/news/app.py index 4f1c24e..2bda7aa 100644 --- a/news/app.py +++ b/news/app.py @@ -1,13 +1,8 @@ -from apistar import Include, Route, http -from apistar.interfaces import Router -from apistar.frameworks.wsgi import WSGIApp as App -from apistar.backends import sqlalchemy_backend -from apistar.backends.sqlalchemy_backend import Session -from apistar.handlers import docs_urls, static_urls - -from .models import Base, NewsArticle, NewsSource -from .renders import JSONRenderer +from apistar import Route, http, App +from sqlalchemy import create_engine +from .models import NewsArticle, NewsSource from .schema import NewsSourceSchema, NewsArticleSchema +from .util import Session, SQLAlchemySession, SQLAlchemyHook news_source_schema = NewsSourceSchema() news_article_schema = NewsArticleSchema() @@ -15,22 +10,22 @@ news_article_schema = NewsArticleSchema() def get_sources(session: Session): sources = session.query(NewsSource).all() - return http.Response(news_source_schema.dump(sources, many=True).data, status=200) + return http.JSONResponse(news_source_schema.dump(sources, many=True).data, status_code=200) -def add_source(session: Session, request_data: http.RequestData, router: Router): +def add_source(session: Session, request_data: http.RequestData, app: App): news_source_data, errors = news_source_schema.load(request_data) if errors: msg = {"message": "400 Bad Request", "error": errors} - return http.Response(msg, status=400) + return http.JSONResponse(msg, status_code=400) news_source = NewsSource(**news_source_data) session.add(news_source) session.flush() - headers = {"Location": router.reverse_url('get_source', {"id": news_source.id})} + headers = {"Location": app.reverse_url('get_source', {"id": news_source.id})} - return http.Response(news_source_schema.dump(news_source).data, status=201, headers=headers) + return http.JSONResponse(news_source_schema.dump(news_source).data, status_code=201, headers=headers) def delete_source(session: Session, id: int): @@ -38,47 +33,47 @@ def delete_source(session: Session, id: int): news_source = session.query(NewsSource).filter_by(id=id).one_or_none() if news_source is None: msg = {"message": "404 Not Found"} - return http.Response(msg, status=404) + return http.JSONResponse(msg, status_code=404) msg = {"message": "200 OK"} - return http.Response(msg, status=200) + return http.JSONResponse(msg, status_code=200) def get_source(session: Session, id: int): news_source = session.query(NewsSource).filter_by(id=id).one_or_none() if news_source is None: msg = {"message": "404 Not Found"} - return http.Response(msg, status=404) + return http.JSONResponse(msg, status_code=404) - return http.Response(news_source_schema.dump(news_source).data, status=200) + return http.JSONResponse(news_source_schema.dump(news_source).data, status_code=200) def get_articles(session: Session): articles = session.query(NewsArticle).all() - return http.Response(news_article_schema.dump(articles, many=True).data, status=200) + return http.JSONResponse(news_article_schema.dump(articles, many=True).data, status_code=200) -def add_article(session: Session, request_data: http.RequestData, router: Router): +def add_article(session: Session, request_data: http.RequestData, app: App): 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) + return http.JSONResponse(msg, status_code=400) news_article = NewsArticle(**news_article_data) session.add(news_article) session.flush() - headers = {"Location": router.reverse_url('get_source', {"id": news_article.id})} + headers = {"Location": app.reverse_url('get_source', {"id": news_article.id})} - return http.Response(news_article_schema.dump(news_article), status=201, headers=headers) + return http.JSONResponse(news_article_schema.dump(news_article), status_code=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) + return http.JSONResponse(msg, status_code=404) + return http.JSONResponse(news_article_schema.dump(news_article), status_code=200) def delete_article(session: Session, id): @@ -86,10 +81,10 @@ def delete_article(session: Session, 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) + return http.JSONResponse(msg, status_code=404) msg = {"message": "200 OK"} - return http.Response(msg, status=200) + return http.JSONResponse(msg, status_code=200) routes = [ @@ -101,29 +96,22 @@ routes = [ 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) ] -_settings = { - 'DATABASE': { - 'URL': 'postgresql://apistar:local@localhost/news', - 'METADATA': Base.metadata - }, - 'RENDERERS': [JSONRenderer()] -} - routes = routes -commands = sqlalchemy_backend.commands +components = [SQLAlchemySession(engine=create_engine('postgresql://apistar:local@localhost/news'))] -components = sqlalchemy_backend.components +hooks = [SQLAlchemyHook()] -def application_factory(settings={}): +def application_factory(): """Returns an instance of Cookie API""" - app_settings = {**_settings, **settings} - return App(settings=app_settings, - commands=commands, - components=components, + return App(components=components, + event_hooks=hooks, routes=routes) + + +if __name__ == "__main__": + app = application_factory() + app.serve('0.0.0.0', 8080, debug=True) diff --git a/news/models.py b/news/models.py index 3dec3fa..0cc35b4 100644 --- a/news/models.py +++ b/news/models.py @@ -1,7 +1,7 @@ from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Numeric, BigInteger, Text, Table, ARRAY +from sqlalchemy import Column, ForeignKey, DateTime, BigInteger, Text, Table, ARRAY from sqlalchemy.orm import relationship, backref -from sqlalchemy.sql import expression, and_ +from sqlalchemy.sql import expression from sqlalchemy.ext.compiler import compiles from sqlalchemy.types import DateTime as DateTimeType from sqlalchemy.orm.exc import NoResultFound diff --git a/news/renders.py b/news/renders.py deleted file mode 100644 index 59bb97f..0000000 --- a/news/renders.py +++ /dev/null @@ -1,23 +0,0 @@ -import datetime as dt -import decimal -import json - -from apistar import http -from apistar.renderers import Renderer - - -def extended_encoder(obj): - """JSON encoder function with support for ISO 8601 datetime serialization and Decimal to float casting""" - if isinstance(obj, dt.datetime): - return obj.timestamp() - elif isinstance(obj, decimal.Decimal): - return float(obj) - - -class JSONRenderer(Renderer): - """JSON Render with support for ISO 8601 datetime serialization and Decimal to float casting""" - media_type = 'application/json' - charset = None - - def render(self, data: http.ResponseData) -> bytes: - return json.dumps(data, default=extended_encoder).encode('utf-8') diff --git a/news/schema.py b/news/schema.py index 56b6c54..f9c8da3 100644 --- a/news/schema.py +++ b/news/schema.py @@ -5,6 +5,7 @@ from marshmallow import Schema, fields class PassDateTime(fields.DateTime): """Used to Pass Datetime deserialization """ + def _deserialize(self, value, attr, data): if isinstance(value, dt.datetime): return value @@ -40,14 +41,17 @@ class NewsSourceSchema(Schema): created_date = UnixTimestamp(dump_only=True) modified_date = UnixTimestamp(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 type is a required field'}) + 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 type is a required field'}) # TODO add support for Categories class Meta: ordered = True + # TODO deserialization of timestamp to class NewsArticleSchema(Schema): id = fields.Int(dump_only=True) diff --git a/news/util.py b/news/util.py new file mode 100644 index 0000000..7292a9d --- /dev/null +++ b/news/util.py @@ -0,0 +1,38 @@ +import inspect +import typing +from apistar import Component +from apistar.http import Response +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker, scoped_session + +DBSession = scoped_session(sessionmaker()) + +Session = typing.TypeVar("Session") + + +class SQLAlchemySession(Component): + def __init__(self, engine=None): + if not isinstance(engine, Engine): + raise ValueError('SQLAlchemySession must be instantiated with a sqlalchemy.engine.Engine object') + self.engine = engine + DBSession.configure(bind=self.engine) + + def resolve(self): + return DBSession() + + def can_handle_parameter(self, parameter: inspect.Parameter) -> bool: + return parameter.annotation is Session + + +class SQLAlchemyHook: + def on_request(self, session: Session): + return + + def on_response(self, session: Session, response: Response): + DBSession.remove() + return response + + def on_error(self, session: Session, response: Response): + session.rollback() + DBSession.remove() + return response diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..2adda50 --- /dev/null +++ b/requirements.in @@ -0,0 +1,5 @@ +apistar +psycopg2 +marshmallow +SQLAlchemy + diff --git a/requirements.txt b/requirements.txt index c1051b1..76ba3bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,25 @@ -apistar==0.3.9 -certifi==2017.11.5 -chardet==3.0.4 -colorama==0.3.9 -coreapi==2.3.3 -coreschema==0.0.4 -idna==2.6 -itypes==1.1.0 -Jinja2==2.9.6 -MarkupSafe==1.0 -marshmallow==2.15.0 -psycopg2==2.7.3.2 -py==1.4.34 -pytest==3.2.3 -pytz==2017.3 -requests==2.18.4 -SQLAlchemy==1.1.15 -uritemplate==3.0.0 -urllib3==1.22 -Werkzeug==0.12.2 -whitenoise==3.3.1 +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile --output-file requirements.txt requirements.in +# +--trusted-host pypi.python.org +--trusted-host git.aigalatic.com + +apistar==0.5.40 +certifi==2018.4.16 # via requests +chardet==3.0.4 # via requests +click==6.7 # via apistar +idna==2.7 # via requests +jinja2==2.10 # via apistar +markupsafe==1.0 # via jinja2 +marshmallow==2.15.3 +psycopg2==2.7.5 +pyyaml==3.12 # via apistar +requests==2.19.1 # via apistar +sqlalchemy==1.2.9 +urllib3==1.23 # via requests +werkzeug==0.14.1 # via apistar +whitenoise==3.3.1 # via apistar +>>>>>>> Level up to Apistar 0.5.4 without tests diff --git a/scripts/wsgi_app.py b/scripts/wsgi_app.py new file mode 100644 index 0000000..1eb5c34 --- /dev/null +++ b/scripts/wsgi_app.py @@ -0,0 +1,11 @@ +from news import application_factory + +app = application_factory() + + +def main(): + app.serve('0.0.0.0', 8080, debug=True) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6b00d72 --- /dev/null +++ b/setup.py @@ -0,0 +1,79 @@ +from setuptools import setup, find_packages + +with open('README.md') as readme_file: + readme = readme_file.read() + +with open('HISTORY.md') as history_file: + history = history_file.read() + +requirements = [ + 'apistar==0.5.40', + 'certifi==2018.4.16', # via requests + 'chardet==3.0.4', # via requests + 'click==6.7', # via apistar + 'idna==2.7', # via requests + 'jinja2==2.10', # via apistar + 'markupsafe==1.0', # via jinja2 + 'marshmallow==2.15.3', + 'psycopg2==2.7.5', + 'pyyaml==3.12', # via apistar + 'requests==2.19.1', # via apistar + 'sqlalchemy==1.2.9', + 'urllib3==1.23', # via requests + 'werkzeug==0.14.1', # via apistar + 'whitenoise==3.3.1', # via apistar +] + +dev_requirements = [ + 'atomicwrites==1.1.5', # via pytest + 'attrs==18.1.0', # via pytest + 'click==6.7', # via pip-tools + 'first==2.0.1', # via pip-tools + 'flake8==3.5.0', + 'mccabe==0.6.1', # via flake8 + 'more-itertools==4.2.0', # via pytest + 'pip-tools==2.0.2', + 'pluggy==0.6.0', # via pytest, tox + 'py==1.5.4', # via pytest, tox + 'pycodestyle==2.3.1', # via flake8 + 'pyflakes==1.6.0', # via flake8 + 'pytest==3.6.2', + 'six==1.11.0', # via more-itertools, pip-tools, pytest, tox + 'tox==3.0.0', + 'virtualenv==16.0.0', # via tox + 'wheel==0.31.1', +] + +setup( + name='news', + version='0.2.0', + description="A ReSTful api for storing and retrieving news articles", + long_description=readme + '\n\n' + history, + long_description_content_type='text/markdown', + author="Drew Bednar", + author_email='drew@androiddrew.com', + url='https://git.aigalactic.com/Spyglass/news', + packages=find_packages(include=['news', 'scripts']), + include_package_data=True, + install_requires=requirements, + license='BSD', + keywords='news spyglass', + classifiers=[ + 'Development Status :: 2 - Pre-Alpha', + 'Environment :: Backend', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Natural Language :: English', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + ], + extras_require={ + 'dev': dev_requirements, + }, + entry_points={ + 'console_scripts': [ + 'wsgi_serve=scripts.wsgi_app:main' + ] + }, + +) diff --git a/tests/test_renders.py b/tests/test_renders.py deleted file mode 100644 index 101a606..0000000 --- a/tests/test_renders.py +++ /dev/null @@ -1,24 +0,0 @@ -import datetime as dt -import json -from pytz import timezone -from decimal import Decimal - -from news.renders import extended_encoder, JSONRenderer - - -def test_extended_encoder_date_parsing(): - test_date = dt.datetime(2017, 5, 10, tzinfo=timezone('UTC')) - assert test_date.timestamp() == extended_encoder(test_date) - - -def test_extended_encoder_decimal_casting(): - test_decimal = Decimal('1.0') - assert 1.0 == extended_encoder(test_decimal) - - -def test_render_with_extended_encoder(): - test_date = dt.datetime(2017, 5, 10) - test_decimal = Decimal('0.1') - expected = dict(my_date=1494374400.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) diff --git a/wsgi_app.py b/wsgi_app.py deleted file mode 100644 index 92f1090..0000000 --- a/wsgi_app.py +++ /dev/null @@ -1,9 +0,0 @@ -from news import application_factory - -# Override base application settings here -settings = {} - -app = application_factory(settings) - -if __name__ == "__main__": - app.main() \ No newline at end of file From 2c42d9f7f80fed69d9a5a8e1c9bc697913779bac Mon Sep 17 00:00:00 2001 From: androiddrew Date: Tue, 3 Jul 2018 14:55:50 -0400 Subject: [PATCH 6/6] Updated models, fixed commits, added env variable for DB connection --- .gitignore | 1 + README.md | 25 ++++++++++++++++++++++++- news/app.py | 43 ++++++++++++++++++++++++++++--------------- news/models.py | 4 +++- news/schema.py | 3 ++- 5 files changed, 58 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 5abd0da..bcb4bd8 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ htmlcov/ nosetests.xml coverage.xml *,cover +.pytest_cache/ # Translations *.mo diff --git a/README.md b/README.md index 964504d..7efe3ca 100644 --- a/README.md +++ b/README.md @@ -3,4 +3,27 @@ A ReSTful api for storing and retrieving news articles ## Pip tools -This project uses pip-tools for developement. Reference the [project page](https://github.com/jazzband/pip-tools) for usage instructions. \ No newline at end of file +This project uses pip-tools for developement. Reference the [project page](https://github.com/jazzband/pip-tools) for usage instructions. + +## DB Connection + +The database connection string must be set using the `NEWS_DB` environmental variable. + +``` +$ export NEWS_DB=postgresql://apistar:local@localhost/news +``` + +## Console Script +A console script os provided for testing via the Werkzueg development server. + + +``` +$(env)wsgi_serve + +``` + +## Deployment + +``` +$(env) gunicorn -w 2 -b 0.0.0.0:8080 --chdir scripts/ wsgi_app:app +``` \ No newline at end of file diff --git a/news/app.py b/news/app.py index 2bda7aa..5545dd5 100644 --- a/news/app.py +++ b/news/app.py @@ -1,3 +1,4 @@ +import os from apistar import Route, http, App from sqlalchemy import create_engine from .models import NewsArticle, NewsSource @@ -9,11 +10,13 @@ news_article_schema = NewsArticleSchema() def get_sources(session: Session): + """Retrieves all News Sources""" sources = session.query(NewsSource).all() return http.JSONResponse(news_source_schema.dump(sources, many=True).data, status_code=200) def add_source(session: Session, request_data: http.RequestData, app: App): + """Adds a single source to the News Source collection""" news_source_data, errors = news_source_schema.load(request_data) if errors: msg = {"message": "400 Bad Request", "error": errors} @@ -23,23 +26,26 @@ def add_source(session: Session, request_data: http.RequestData, app: App): session.add(news_source) session.flush() - headers = {"Location": app.reverse_url('get_source', {"id": news_source.id})} - + headers = {"Location": app.reverse_url('get_source', id=news_source.id)} + session.commit() return http.JSONResponse(news_source_schema.dump(news_source).data, status_code=201, headers=headers) def delete_source(session: Session, id: int): - """Delete a single News Sources from the collection by id""" + """Delete a single News Source from the collection by id""" news_source = session.query(NewsSource).filter_by(id=id).one_or_none() if news_source is None: msg = {"message": "404 Not Found"} return http.JSONResponse(msg, status_code=404) + session.delete(news_source) + session.commit() msg = {"message": "200 OK"} return http.JSONResponse(msg, status_code=200) def get_source(session: Session, id: int): + """Retrieves a single News Source by id""" news_source = session.query(NewsSource).filter_by(id=id).one_or_none() if news_source is None: msg = {"message": "404 Not Found"} @@ -49,11 +55,22 @@ def get_source(session: Session, id: int): def get_articles(session: Session): + """Retrieves all articles""" articles = session.query(NewsArticle).all() return http.JSONResponse(news_article_schema.dump(articles, many=True).data, status_code=200) +def get_article(session: Session, id: int): + """Retrieves a single article 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.JSONResponse(msg, status_code=404) + return http.JSONResponse(news_article_schema.dump(news_article).data, status_code=200) + + def add_article(session: Session, request_data: http.RequestData, app: App): + """Adds a single article""" news_article_data, errors = news_article_schema.load(request_data) if errors: msg = {"message": "400 Bad Request", "error": errors} @@ -63,17 +80,9 @@ def add_article(session: Session, request_data: http.RequestData, app: App): session.add(news_article) session.flush() - headers = {"Location": app.reverse_url('get_source', {"id": news_article.id})} - - return http.JSONResponse(news_article_schema.dump(news_article), status_code=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.JSONResponse(msg, status_code=404) - return http.JSONResponse(news_article_schema.dump(news_article), status_code=200) + headers = {"Location": app.reverse_url('get_article', id=news_article.id)} + session.commit() + return http.JSONResponse(news_article_schema.dump(news_article).data, status_code=201, headers=headers) def delete_article(session: Session, id): @@ -83,6 +92,8 @@ def delete_article(session: Session, id): msg = {"message": "404 Not Found"} return http.JSONResponse(msg, status_code=404) + session.delete(news_article) + session.commit() msg = {"message": "200 OK"} return http.JSONResponse(msg, status_code=200) @@ -100,7 +111,9 @@ routes = [ routes = routes -components = [SQLAlchemySession(engine=create_engine('postgresql://apistar:local@localhost/news'))] +components = [ + SQLAlchemySession(engine=create_engine(os.getenv('NEWS_DB', 'postgresql://apistar:local@localhost/news'))) +] hooks = [SQLAlchemyHook()] diff --git a/news/models.py b/news/models.py index 0cc35b4..c2daa29 100644 --- a/news/models.py +++ b/news/models.py @@ -77,6 +77,9 @@ class NewsSource(DBMixin, Base): source_type = Column(Text) categories = relationship('Category', secondary=categories, backref=backref('news_sources', lazy='dynamic')) + articles = relationship('NewsArticle', + backref=backref('news_source'), + cascade="all, delete, delete-orphan") class NewsArticle(DBMixin, Base): @@ -87,5 +90,4 @@ class NewsArticle(DBMixin, Base): authors = Column(ARRAY(Text)) 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/schema.py b/news/schema.py index f9c8da3..8ee8580 100644 --- a/news/schema.py +++ b/news/schema.py @@ -64,7 +64,8 @@ class NewsArticleSchema(Schema): title = fields.Str() authors = fields.List(fields.Str()) published_date = UnixTimestamp(dump_only=True) - news_blob = fields.Str(required=True, error_messages={'required': 'NewsArticle must include news content'}) + news_blob = fields.Str(required=True, + error_messages={'required': 'NewsArticle must include news content'}) # TODO add support for Tags